mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-17 23:00:27 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5423d04c55 | ||
|
|
267cfc5d07 | ||
|
|
e2df305ab8 | ||
|
|
358f47a41f | ||
|
|
8741bcecb1 | ||
|
|
5a274a3e57 | ||
|
|
bc1812eb7d | ||
|
|
1babeeaad8 | ||
|
|
8a6ab23f83 | ||
|
|
d9946eba19 | ||
|
|
1a9544d5b2 | ||
|
|
8ccb6b02c0 | ||
|
|
8ba6c77c95 | ||
|
|
74dc85524e | ||
|
|
ca62be5682 | ||
|
|
f2adff9e12 | ||
|
|
ffac411559 | ||
|
|
cd19772da9 | ||
|
|
d0122eb2c5 | ||
|
|
c2d1b978aa | ||
|
|
0d23860151 | ||
|
|
5641ea544d | ||
|
|
bb43a64d3d | ||
|
|
7267be68a6 | ||
|
|
8721561b2f | ||
|
|
baaddad2ab | ||
|
|
f7be1185eb | ||
|
|
9f23227ccd | ||
|
|
f84ab3d9a2 | ||
|
|
a59b3ee77a | ||
|
|
453822921b | ||
|
|
eed3a933e0 | ||
|
|
0ec49c5177 | ||
|
|
53be44d55f | ||
|
|
2ec7378c6a | ||
|
|
0aba1aa2b5 | ||
|
|
7e7b944e81 | ||
|
|
d5795d8d8c | ||
|
|
4cda8e5be3 | ||
|
|
cc7b8a4d5e |
No files matched your search
@@ -40,9 +40,34 @@ jobs:
|
||||
- name: Verify package installation in the rootless build container
|
||||
run: python scripts/check-build-container.py
|
||||
|
||||
native-arm64:
|
||||
# Fully link the patched VR game; source-only tests cannot catch every
|
||||
# missing guest/host bridge. No retail files or headset runtime are used.
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Build and fully link the shipped native ARM64 VR game
|
||||
run: python scripts/check-native-build.py --timeout-seconds 2400 --report "$RUNNER_TEMP/native-build-proof.json"
|
||||
- name: Save build proof without publishing native game binaries
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: HaloFrameSetup-native-build-proof
|
||||
path: ${{ runner.temp }}/native-build-proof.json
|
||||
if-no-files-found: error
|
||||
|
||||
windows-executable:
|
||||
needs: [test, build-container]
|
||||
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:
|
||||
@@ -63,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
|
||||
|
||||
@@ -6,24 +6,37 @@
|
||||
<img src="resources/artwork/halo-ce-logo.png" alt="Halo: Combat Evolved" width="180">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Halo CE Steam Frame VR Mod</h1>
|
||||
|
||||
<h2 align="center">
|
||||
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.3/Halo-Steam-Frame-Mod-Setup-1.4.3.exe">Download 1.4.3 — Windows installer (.exe)</a>
|
||||
</h2>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.exe"><strong>DOWNLOAD VR MOD INSTALLER v1.3.4 — WINDOWS EXE</strong></a><br>
|
||||
<sub>One installer file · No administrator rights needed</sub><br>
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.4">Release notes & checksum</a>
|
||||
<strong>Halo-Steam-Frame-Mod-Setup-1.4.3.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.3">Release notes & SHA256 checksum</a> · <a href="docs/BUILD_PROVENANCE.md">Verify release provenance</a>
|
||||
</p>
|
||||
|
||||
**Community VR mod installer for Halo: Combat Evolved on Steam Frame.** Installs an experimental native ARM64/OpenXR port with Xbox-style controls and Steam library artwork.
|
||||
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.
|
||||
|
||||
You need **Windows 10/11 x64**, a **Steam Frame**, and your own authorized **original Xbox Halo CE ISO/XISO or extracted maps**. **USA Rev 2 is validated**; Halo PC, Xbox 360 Anniversary, and MCC data are unsupported.
|
||||
**1.4.3 adds refined vehicle controls and seated views, upright menus with a controller pointer, and fixes for hand visibility, reticle tracking and interrupted builds.** SD-card installation, standing mode, snap turning and the earlier tutorial and flashlight fixes are included.
|
||||
|
||||
No retail game executable, disc image, maps, product keys, or soundtrack is included or downloaded. Halo artwork and trademarks retain their owners' rights; software components keep their licenses. This community project is unaffiliated with Microsoft, Xbox, Bungie, or Valve.
|
||||
## Install or upgrade
|
||||
|
||||
1. Run the EXE, choose **Install**, and select your ISO or maps folder.
|
||||
2. Enable Frame Developer Mode, set its user password, and connect over **USB-C** or **Wi-Fi / Ethernet**. The app guides you through SSH setup.
|
||||
3. Save and close games, keep **Steam Home** running, and click **Install Halo VR**. The first build can take tens of minutes; your Frame needs internet access.
|
||||
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.
|
||||
|
||||
Steam artwork and the description/control guide in **Notes** are added automatically during Install and Repair. Already installed? Choose **Repair** to refresh the game, or **Uninstall** to remove it with an optional save backup.
|
||||
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. **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.
|
||||
|
||||
The port is experimental; newer USB and library features still need physical Frame verification.
|
||||
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**.
|
||||
|
||||
[Detailed setup & troubleshooting](docs/SETUP.md) · [Controls](docs/CONTROLS.md) · [Sources](docs/SOURCES.md) · [Third-party notices](docs/THIRD_PARTY.md) · [Build from source](docs/SETUP.md#develop-and-rebuild-the-executable)
|
||||
[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 build 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
@@ -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.
|
||||
|
||||
|
||||
@@ -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. It has GitHub's signed build-provenance attestation for that exact executable, introduced in 1.4.2. 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.3.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.
|
||||
+39
-10
@@ -1,13 +1,13 @@
|
||||
# Steam Frame controls
|
||||
# Steam Frame controls — 1.4.3
|
||||
|
||||
The default installation uses tracked controller aiming with Xbox-style buttons. Point the right controller to aim; move your head to look around. These controls assume the game's default profile button layout.
|
||||
Point the right controller to aim; on foot, the left stick moves relative to your headset's horizontal direction. These Xbox-style controls assume the game's default profile button layout. Driver-seat controls are listed below.
|
||||
|
||||
| Frame input | Action |
|
||||
| --- | --- |
|
||||
| Left stick | Move |
|
||||
| Left stick | Move relative to headset direction |
|
||||
| Left stick click | Crouch |
|
||||
| Right stick left/right | Turn |
|
||||
| Right stick click | Zoom |
|
||||
| Right stick click | Halo's original weapon zoom |
|
||||
| Right trigger | Fire |
|
||||
| Left trigger | Throw grenade |
|
||||
| A | Jump; accept a menu selection |
|
||||
@@ -19,24 +19,53 @@ The default installation uses tracked controller aiming with Xbox-style buttons.
|
||||
| Left controller's d-pad | Navigate menus |
|
||||
| View | Scoreboard/back |
|
||||
| Menu | Pause |
|
||||
| Both grips held | Recenter the headset view; release a held foregrip first |
|
||||
| Both grips held | Recenter outside driver seats; release a held foregrip first |
|
||||
| Left grip near a long gun's foregrip | Aim the gun with both hands |
|
||||
|
||||
The installer defaults to controller aiming, seated height, 72 Hz, resolution scale 1.0, smooth turning at 90 degrees per second, and two-handed aiming. Gesture melee is disabled so **B** remains the predictable melee input. These are installer preferences, not the upstream defaults. You can change supported options in **Settings > VR Setup** or the game's `config.toml`. Choose snap turning in VR Setup if you prefer turning in steps. Some display changes require restarting the game.
|
||||
Install and Repair apply controller aiming, standing height, snap turning and physical punch-to-melee. **B** also remains available for on-foot melee. A fresh install uses 30-degree snap turns, 72 Hz, resolution scale 1.0 and two-handed aiming. Repair keeps other preferences, including snap angle, player height and refresh rate. Use **Pause > VR Setup** or **Settings > VR Setup** to change supported options; smooth turning remains available. Zoomable weapons use Halo's original zoom and HUD, with direct controller aiming. The experimental gun-mounted scope and custom zoom smoothing have been removed. Some display changes require restarting the game.
|
||||
|
||||
## Vehicles
|
||||
|
||||
Your head looks around freely while the seated view follows the vehicle's full pitch, roll and bounce. Entering a seat captures a separate forward-facing reference and uses its authored camera where available. Hold **View/Back for one second** to recenter after physically sitting down. Your standing reference is retained when you exit.
|
||||
|
||||
| Seat | Controls |
|
||||
| --- | --- |
|
||||
| Warthog driver | Hold **A** for gas and **B** to brake. Left-stick left/right steers; backward selects reverse, even with A held. Left-stick forward also works. B takes priority over gas and reverse. |
|
||||
| Ghost driver | Left stick forward/reverse and strafe; right stick turns. |
|
||||
| Scorpion driver | Left stick forward/reverse; right stick aims the turret and guides the hull as in Halo's original controls. |
|
||||
| Banshee pilot | Left stick throttle; right stick yaw/pitch using your flight-inversion setting. |
|
||||
| Gunner or passenger | Existing controller aiming, Xbox buttons and contextual use/exit. |
|
||||
|
||||
For the optional Warthog wheel, hold both grips with your hands apart, then rotate the line between them like a steering wheel: clockwise steers right. Looking sideways does not steer. Hold A for gas, B for braking, or use stick throttle. Release either grip to return to stick steering. Release A and both grips once after boarding, closing a menu or recovering tracking before re-engaging. Driver input stops on tracking or focus loss; release the right stick after exiting to resume ordinary on-foot turning. Physical punches do not trigger Warthog braking.
|
||||
|
||||
The occupied Warthog windshield is hidden only in the local first-person VR view. Scripted flights and unrecognized vehicles retain their existing controls.
|
||||
|
||||
## Menus and aiming
|
||||
|
||||
Opening Pause hides gameplay hands, gun, reticle and zoom overlays. The menu stays level and captures your current gaze and position; close/reopen or recenter to place it in front again. Point the right controller at a menu, release its trigger, then press to select. A/B and the d-pad still navigate.
|
||||
|
||||
Valid controller poses continue to aim when your hands leave the headset's view. Missing right-controller tracking hides the gameplay rig and controller reticle until tracking returns; a missing left-controller pose hides only that hand. A ray pointing behind you or outside the displayed image does not create a centered HUD reticle. Visible first-person hands and weapons also remain tracked during scripted fly-ins.
|
||||
|
||||
When a weapon is equipped, the reticle previews the nominal primary shot using Halo's firing calculation and projectile collision mask. On foot it uses render-frame aim, so its direction is no longer limited to the unit's 30 Hz updates; the hit is traced anew each frame. The headset tester confirmed this behavior in the 1.4.0 baseline. Random spread and ballistic motion can still move individual impacts away from that point. Stereo sun glow defaults to `vr.sun_glow_strength = 0.5` in `config.toml`; 0 disables its glow, and 1 restores full intensity. Repair preserves a custom value.
|
||||
|
||||
Before a weapon is equipped, the flashlight also follows the rendered aiming hand with bounded elevation. Its presentation uses current light bounds without changing the simulation's light state. Equipped weapons keep their original flashlight markers.
|
||||
|
||||
## Local changes to upstream
|
||||
|
||||
The [included patch](../resources/frame-controls.patch) makes two changes:
|
||||
The [included patch](../resources/frame-controls.patch) includes these controls and menu changes:
|
||||
|
||||
1. It sets **LB = change grenade** and **RB = flashlight**. The pinned upstream branch assigns those two bumpers the other way around.
|
||||
2. Physical headset movement counts toward the campaign's look-direction tutorial while motion-controller aiming is active. Follow the technician's instructions and physically look up, down, left or right as requested. The patch ignores small tracking jitter and does not add head rotation to the weapon's aim. It lets the tutorial complete at the selected difficulty; it does not skip the campaign or raise its difficulty.
|
||||
2. Before a weapon is equipped, the unarmed/default reticle follows the aiming hand. Look at the first mission's technician with your headset. The five calibration dots also recognize pointing with the hand reticle, using the original five-degree target cone. Cryopod look calibration accepts physical headset movement and legitimate snap/smooth stick turns, while ignoring tracking jitter and recenter/menu/cinematic transitions. Follow the technician's instructions and look up, down, left or right as requested. These tutorial inputs do not add head rotation to the equipped weapon's aim or change the campaign difficulty.
|
||||
3. On foot, left-stick movement follows the headset's horizontal heading while your weapon still follows the controllers. Tracking loss suppresses walking instead of producing arbitrary movement. Driver seats use the vehicle controls above. Set `vr.movement = "aim"` (or `HALO_VR_MOVEMENT=aim`) to restore aim-relative movement; the installer defaults to `vr.movement = "head"`.
|
||||
|
||||
4. **Multiplayer > CO-OP CAMPAIGN** offers **HOST LOCAL (LAN)**, **JOIN LOCAL (LAN)**, **HOST ONLINE (INTERNET)**, **JOIN ONLINE (INTERNET)**, and **JOIN INVITE LINK**. Choose a host route, then campaign level and difficulty. Your friend uses the corresponding join route. Online hosting stays private by default; **LISTING = PUBLIC** allows server-browser joining by anyone. Each machine has one campaign player in VR and flat mode; campaign has no second-controller or split-screen option. See the [co-op setup guide](SETUP.md#online-campaign-co-op-with-a-friend).
|
||||
|
||||
The game's Steam shortcut also suppresses Steam's duplicate virtual Xbox gamepad with `SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0`. Frame controller poses, buttons and haptics still use OpenXR. This setting applies only to this Halo shortcut.
|
||||
|
||||
## First launch
|
||||
|
||||
Keep Steam Home and the headset runtime awake during setup. Launch **Halo: Combat Evolved VR (Native)** from the Frame's library and create a profile. Use the d-pad to select menu items and **A** to accept. Keep the headset on and hold both grips if the menu appears outside your view.
|
||||
Keep Steam Home and the headset runtime awake during setup. Launch **Halo: Combat Evolved VR (Native)**, or its **SD card** entry, from the Frame's library. 1.4.3 keeps existing 1.4.x profiles and saves in the selected installation's `save-v1.4`. When upgrading from 1.3.5, create a new profile and campaign because its old checkpoints are incompatible and setup does not migrate those profiles. Use the d-pad to select menu items and **A** to accept.
|
||||
|
||||
For a control issue, first check **Settings > Gamepads** for a changed profile layout. Then check **Settings > VR Setup**: this layout uses controller aiming. Upstream also offers gamepad aiming, which moves aim with the right stick and has different camera behavior.
|
||||
|
||||
The original controls and all VR configuration options are documented in the [pinned upstream VR guide](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr). The [SDL hint source](https://github.com/libsdl-org/SDL/blob/release-3.4.16/include/SDL3/SDL_hints.h) documents virtual-gamepad filtering.
|
||||
The original controls and all VR configuration options are documented in the [pinned upstream VR guide](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr). The [SDL hint source](https://github.com/libsdl-org/SDL/blob/release-3.4.16/include/SDL3/SDL_hints.h) documents virtual-gamepad filtering.
|
||||
+37
-9
@@ -1,17 +1,45 @@
|
||||
# Halo CE Steam Frame VR Mod 1.3.4
|
||||
# Halo CE Steam Frame VR Mod 1.4.3
|
||||
|
||||
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Mod-Setup-1.3.4.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.exe)**. This is the complete Windows mod installer; no separate ZIP or Python installation is needed.
|
||||
[**Download Halo-Steam-Frame-Mod-Setup-1.4.3.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.3/Halo-Steam-Frame-Mod-Setup-1.4.3.exe) for Windows 10/11 x64. One EXE includes the installer runtime and USB tools. The [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.3) lists its checksum; [verify release provenance](BUILD_PROVENANCE.md).
|
||||
|
||||
The window title, app text, project description, and documentation now identify this project as a **community Steam Frame VR mod installer**. The downloaded file includes setup code and tools; it does not include or download a retail Halo executable, ISO, maps, product keys, or soundtrack. Supply your own authorized original Xbox game data. Setup builds the pinned community VR source on your Frame.
|
||||
This release promotes the headset-tested **1.4.3-Test9** gameplay patch without changing its bytes.
|
||||
|
||||
Commercial game-download site references have been removed from the current project documentation. The remaining references document the community source, official setup tools, and artwork provenance; setup provides no commercial game-download links.
|
||||
- **Menus and tracking:** gameplay hands and weapons hide while a menu is open, while the controller pointer and Xbox navigation remain available. Menus stay upright when your head tilts. Valid offscreen controller poses keep tracked aiming; unavailable poses hide the reticle until recovery rather than producing a centered fallback. Malformed left-hand poses hide only that hand.
|
||||
- **Vehicle controls:** in the Warthog driver seat, hold **A for forward**, hold **B to brake**, and steer with the left stick. Backward selects reverse; B takes priority over gas and reverse. Optional two-grip steering works with the same gas/brake buttons. Other vehicles keep their own throttle, steering and turret controls. [Vehicle controls](CONTROLS.md).
|
||||
- **Vehicle views:** a separate seated reference uses the authored seat camera and follows the vehicle's full tilt and bounce. Physical head movement remains free, acceleration-animation sway is excluded, and the occupied Warthog windshield is cleared locally.
|
||||
- **Aiming and presentation:** first-person scripted arrivals retain controller hands without enabling gameplay inputs. Stock zoom and direct controller aiming replace the experimental gun-mounted scope. Install/Repair disables the old optical settings, enables physical melee, and retains standing and snap-turn defaults.
|
||||
- **Rendering:** stereo visibility bounds include both eyes near the camera. Invalid framebuffers are retried, incomplete frames are not submitted, and unfinished GPU buffer slots get fresh storage. The compositor receives the exact eye pose used to render the completed frame.
|
||||
- **Setup reliability:** remote source is delivered without an oversized shell argument; cancellation reports unconfirmed remote stopping accurately. A temporary idle/sleep inhibitor covers native compilation when available and is released afterward. Setup prevents concurrent installer builds and runs the current 22 engine checks with compiler/linker names resolved from PATH.
|
||||
|
||||
The installer includes Halo identification artwork and third-party software with their existing attribution and license notices. Halo artwork, logos, names, and trademarks retain their rights holders' rights, including Microsoft; details remain available in the [third-party guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.4/docs/THIRD_PARTY.md). The project's code license does not grant rights to commercial Halo game data.
|
||||
**Updating:** close Halo, select its existing Internal/SD storage location, then choose **Repair** to rebuild with verified maps without another ISO. Existing 1.4.x profiles and saves stay on the selected location. Upgrading from 1.3.5 retains old saves but requires a new profile and campaign. SD installation, Steam artwork/Notes and the earlier tutorial, flashlight, sun-glow and online campaign fixes remain included.
|
||||
|
||||
This wording patch retains automatic Steam artwork/Notes during Install and Repair, the responsive music loader, Xbox controls, USB/network transfer, repair, uninstall, and the Steam Home safeguards. The native ARM64/OpenXR mod remains experimental. Direct USB transfer, a fresh first install, full uninstall, and the newer Steam banner/logo/icon/Notes still need physical Frame verification.
|
||||
The tester confirmed Test9 worked on their Frame. Local verification passed 1,034 installer tests (14 skipped), all 22 engine checks, ARM64 compilation and partial linking, plus the frozen EXE smoke test. Public publication additionally requires the repository's Windows/Linux, rootless-container, complete native ARM64 link and attested Windows EXE checks. No measured FPS improvement or complete multiplayer/hardware matrix is claimed.
|
||||
|
||||
**Original Xbox USA Rev 2 is validated**; other retail revisions need supported map headers. PC, Xbox 360 Anniversary, MCC, and Quest game-data inputs are unsupported.
|
||||
Engine source remains pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`, native peer protocol **17**. Use matching builds and maps for LAN/online co-op. Retail PC and Custom Edition servers remain incompatible; restrictive NAT can prevent Internet connections because there is no gameplay relay.
|
||||
|
||||
The EXE is unsigned. Its SHA256 appears below. [Matching source and third-party notices](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.4) remain available for rebuilding. GitHub's automatic source downloads are optional for developers.
|
||||
Supply your own authorized original Xbox Halo CE data. No retail executable, ISO, maps, keys or soundtrack is bundled or downloaded. Halo artwork and licensed components retain their notices. [Sources and credits](SOURCES.md) · [Third-party notices](THIRD_PARTY.md).
|
||||
|
||||
[Setup guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.4/docs/SETUP.md) · [Sources and credits](SOURCES.md).
|
||||
## 1.4.2
|
||||
|
||||
[**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).
|
||||
|
||||
- **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).
|
||||
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
[Setup guide](SETUP.md) · [Controls](CONTROLS.md) · [Sources and credits](SOURCES.md) · [Third-party notices](THIRD_PARTY.md)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Install Halo VR on an SD card
|
||||
|
||||
Choose **Internal storage / SD card** for your installation. [Download the Windows installer](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.3/Halo-Steam-Frame-Mod-Setup-1.4.3.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).
|
||||
+83
-28
@@ -1,42 +1,76 @@
|
||||
# Halo CE Steam Frame VR mod setup
|
||||
# Halo CE Steam Frame VR mod — setup 1.4.3
|
||||
|
||||
[Back to the project README](../README.md)
|
||||
|
||||
This community VR mod installer guides you through installing the experimental native Halo CE VR port. This guide covers USB-C and network connections, repair, Steam library updates, removal, troubleshooting, and rebuilding the Windows executable.
|
||||
1.4.3 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 the single **[Halo-Steam-Frame-Mod-Setup-1.3.4.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.4/Halo-Steam-Frame-Mod-Setup-1.3.4.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. The [release notes](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.4) include the EXE's SHA256 checksum.
|
||||
Download and run the single [**Halo-Steam-Frame-Mod-Setup-1.4.3.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.3/Halo-Steam-Frame-Mod-Setup-1.4.3.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.3) and follow [Verify release provenance](BUILD_PROVENANCE.md).
|
||||
|
||||
Use your own authorized original Xbox Halo CE ISO/XISO or extracted maps. USA Rev 2 is validated; 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.
|
||||
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 native game, Xbox controls, and older Steam shortcut/covers were previously tested on a physical Frame. USB transfer, a complete fresh-device install/uninstall, and the newer banner/logo/icon/Notes still need hardware verification. The upstream VR implementation is experimental, and performance varies by scene and device.
|
||||
The user confirmed that the private 1.4.3 candidate works on their Frame. Earlier reports also confirmed SD installation, menu placement and LAN/online campaign co-op. Automated checks cover the new controls, tracking and renderer guards; no measured FPS gain, frame-timing results or complete hardware/network test matrix is claimed.
|
||||
|
||||
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.
|
||||
|
||||
## Engine and save compatibility
|
||||
|
||||
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.3 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.x 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.3 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.
|
||||
|
||||
**Multiplayer > CO-OP CAMPAIGN** opens network campaign choices in VR and flat mode: **HOST LOCAL (LAN)**, **JOIN LOCAL (LAN)**, **HOST ONLINE (INTERNET)**, **JOIN ONLINE (INTERNET)**, and **JOIN INVITE LINK**. Campaign uses one local player per machine; there is no second-controller or split-screen campaign route. Hosts start with **SINGLEPLAYER** maps selected. All peers need compatible **protocol 17** builds, matching game data and capacity limits; stable protocol 11 and retail Xbox/PC/Custom Edition/MCC clients cannot join. Extra co-op enemies default to `"none"`; Repair preserves a configured enemy mode.
|
||||
|
||||
The build stages `brokers.txt` for internet invite/server discovery. The relative default `network.brokers_file = "brokers.txt"` resolves beside the game's config; a custom setting is preserved. The tester confirmed LAN and online campaign work; successful joining still depends on compatible peers, reachable brokers, and each network's connectivity. [Pinned co-op implementation and protocol sources](SOURCES.md#retained-140-engine-changes) explain these requirements.
|
||||
|
||||
## Online campaign co-op with a friend
|
||||
|
||||
Both players should use **the same 1.4.3 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.
|
||||
3. **Friend:** open **Multiplayer > CO-OP CAMPAIGN > JOIN LOCAL (LAN)** or **JOIN ONLINE (INTERNET)** to match the host. Choose **REFRESH** if needed, select the host's game and press **A**. When both names appear in the lobby, let the countdown finish or select **START NOW**. If joining a game already underway opens its preview, select **JOIN GAME**.
|
||||
|
||||
For invite-only play, keep **LISTING = PRIVATE**. When the host's **INVITE LINK** row shows a `halo://join/` link, select that row and press **A** to copy it. Share it yourself through an app on the host's Frame. The friend copies it onto **their Frame's clipboard**, opens **Multiplayer > CO-OP CAMPAIGN > JOIN INVITE LINK**, selects **PASTE LINK**, then selects the host's game. Returning to the game can also consume the clipboard invite automatically. The PC and Frame have separate clipboards. The invite becomes unusable after the hosting game quits; switching a public game to private makes a new invite.
|
||||
|
||||
If a public game is missing, check that internet play is enabled on both games and that both use at least one reachable common broker. The browser needs broker support for retained listings; the included list supplies the defaults. Repairs preserve custom network settings, so an existing `network.online = false` or `network.public_lobby = false` must be corrected. **Settings > Network Setup > INTERNET PLAY** can enable internet play; restart Halo afterward.
|
||||
|
||||
If a game is visible but joining says **FAILED**, check matching builds/maps first. Gameplay uses **direct UDP**, STUN, and optional UPnP; the MQTT brokers carry discovery/signaling, **not a gameplay relay**. Strict NAT or carrier-grade NAT can prevent a distant friend joining. Try hosting from the other network. If an accessible home router still blocks it, choose a fixed `network.tunnel_port` in that game's `config.toml`, restart Halo, and forward that **UDP** port to the Frame on that router. UPnP cannot bypass a second upstream NAT. The successful headset report does not specify distant-peer or restrictive-NAT test coverage. [Primary internet-play and menu sources](SOURCES.md#online-co-op-menu-and-connection).
|
||||
|
||||
## Install Halo VR
|
||||
|
||||
1. **Game data.** Choose **Install**, then 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. 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.
|
||||
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 and stay level, even with your head tilted. They stay in place while open; close/reopen or recenter to place one in front again. Opening Pause hides gameplay hands, gun, reticle and zoom overlays while keeping the controller pointer and Xbox menu buttons available.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
### Transfer over a USB-C cable
|
||||
|
||||
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.
|
||||
|
||||
The cable carries encrypted SSH/SFTP traffic between the PC and Frame. **The Frame still needs its own internet connection** to fetch native source, the build container, and compiler dependencies. USB does not provide internet sharing or change Wi-Fi/Steam settings. Cable transfer may help on a weak or congested wireless network; actual speed depends on the cable, USB ports, and device. It does not speed up the native compilation phase. USB behavior has automated software coverage; physical Steam Frame USB transfer verification is pending. [Valve's USB/ADB guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) and [Google's ADB reference](https://developer.android.com/tools/adb) describe the underlying connection and forwarding tools.
|
||||
|
||||
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress; a remaining manual Steam or artwork step stays at 99%, and only full success reaches 100%.
|
||||
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress. Pending Steam information, uninstall files, or connection cleanup stays at 99%, with the remaining action explained; only full success reaches 100%.
|
||||
|
||||
Setup explicitly closes its SFTP and command channels and the underlying SSH connection before reporting completion. The activity feed shows disconnecting and disconnected stages; failure and cancellation also close that connection. 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.
|
||||
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. 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).
|
||||
|
||||
@@ -46,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 is validated with this installer.** 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.3 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 |
|
||||
| --- | --- |
|
||||
@@ -54,32 +88,40 @@ Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 is validated wit
|
||||
| `01.08.15.1749` | NTSC |
|
||||
| `01.01.14.2342` | PAL |
|
||||
|
||||
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
|
||||
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
|
||||
|
||||
### Already have Halo installed?
|
||||
|
||||
Choose **Repair** in the **Game data** step, connect your Frame, and click **Repair Halo VR** in the last step. Setup detects the standard native folder `~/Games/HaloCENativeVR` and reinstalls the native executable, SDL library, controls/tutorial patch, and notices; it restores the required VR/control settings while preserving saves and unrelated settings. Valid original Xbox maps already on the Frame can be reused without selecting another ISO. If maps are missing or damaged, select a valid local image/maps folder and retry. Previous installs made by this installer or the original guided native setup are recognized. A hand-installed native game in the same folder can be adopted only through the explicit Repair action, after its native executable and original Xbox maps validate. Unknown folders and other install locations are not overwritten.
|
||||
To update an existing installation, close **Halo: Combat Evolved VR (Native)** and choose **Repair**. Setup rebuilds the native program and applies the 1.4.3 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.x 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
|
||||
|
||||
Install and Repair automatically update the native Steam shortcut, missing artwork, and **Halo CE VR — About & controls** note after the game is ready. There is no separate Steam info option to select. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects. If registration, artwork, or Notes remains pending at the end, **Add to Steam again** retries that step without transferring maps or rebuilding the game.
|
||||
Install and Repair automatically update the native Steam shortcut, missing artwork, and **Halo CE VR — About & controls** note after the game is ready. The note includes online hosting/joining instructions. Only exact unchanged earlier installer notes are upgraded; edited or unfamiliar notes remain untouched. There is no separate Steam info option to select. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects. If registration, artwork, or Notes remains pending at the end, **Add to Steam again** retries that step without transferring maps or rebuilding the game.
|
||||
|
||||
Open **Notes** on the Halo game's Steam details page or in its overlay to read the description and control guide. These are private game notes using Steam's normal notes synchronization. They do not replace the non-Steam About panel or add a store page, achievements, reviews, or another game's Steam identity. Existing notes and edited installer notes remain unchanged. If Notes is unavailable or synchronization cannot be verified, setup shows copyable text for manual use. Missing artwork or an icon awaiting Steam's shortcut-file persistence is reported separately and can be retried with **Add to Steam again**. Notes requires a running, verified Steam client; setup leaves a closed Steam client closed.
|
||||
|
||||
### 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 asks the running client to remove the exact native shortcut from its active account, then verifies that 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. An owned installation build still running also 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 saves inside the native game folder and the original config to `~/Games/HaloCENativeVR-saves-<operation ID>`, and 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.
|
||||
|
||||
With the client running, Steam controls artwork cleanup for the removed entry; setup does not delete its live grid files. With Steam already closed, setup removes only artwork matching the installer's known image hashes and preserves custom images.
|
||||
|
||||
## Xbox-style controls
|
||||
|
||||
On foot and in menus:
|
||||
|
||||
| Frame control | Action |
|
||||
| --- | --- |
|
||||
| A | Jump / accept |
|
||||
@@ -91,9 +133,13 @@ With the client running, Steam controls artwork cleanup for the removed entry; s
|
||||
| Right stick / click | Turn / zoom |
|
||||
| Left bumper / right bumper | Change grenade / flashlight |
|
||||
| Menu / View | Pause / scoreboard |
|
||||
| Both grips held | Recenter |
|
||||
| Both grips held | Recenter outside driver seats |
|
||||
|
||||
Motion aiming stays enabled. Smooth turning defaults to 90 degrees/second, refresh rate to 72 Hz, and render scale to 1.0. Repairs refresh the VR/control defaults and keep unrelated settings and saves. Head movement also satisfies the first-level look tutorial. The patch does not skip the campaign or increase combat difficulty.
|
||||
Install and Repair apply motion aiming, standing mode, snap turning and physical punch-to-melee. Fresh installs use 30-degree snap turns, 72 Hz and render scale 1.0. Repairs keep other settings and saves. **Pause > VR Setup** opens Controls and Display options. Weapons use Halo's original zoom and direct controller aiming; the experimental gun-mounted scope and custom zoom smoothing are removed.
|
||||
|
||||
In the **Warthog driver's seat**, hold **A for gas**, hold **B to brake**, and steer with left-stick left/right. Left-stick backward reverses; B takes priority over gas and reverse. Both grips engage an optional two-hand wheel; release either grip to return to the stick. Release A and both grips once after boarding, a menu or tracking loss. Seated views capture a forward-facing reference and follow the vehicle's full tilt and bounce; hold View/Back for one second to recenter after sitting down. See [all vehicle controls](CONTROLS.md#vehicles).
|
||||
|
||||
Valid controller poses continue aiming outside the headset's view. Missing right-controller tracking hides the gameplay rig and reticle; missing left-controller tracking hides only that hand. Tracking loss does not create centered HUD aim. Head movement and deliberate stick turns satisfy the first-level look tutorial; the calibration dots also accept the unarmed hand reticle. Campaign difficulty is unchanged.
|
||||
|
||||
The native shortcut uses this **per-game** launch option to prevent the same physical controller from being read through both OpenXR and Steam's virtual gamepad:
|
||||
|
||||
@@ -105,22 +151,24 @@ See [controls and patch details](CONTROLS.md).
|
||||
|
||||
## What gets installed
|
||||
|
||||
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New-install saves are in that game's separate `save` folder; repairs preserve existing save locations. Build workspace: `~/.cache/halo-frame-installer`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup does not remove another Halo installation or touch unrelated games.
|
||||
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New engine saves use `save-v1.4`; old 1.3.5 saves stay in `save` or their original configured location. Build workspace: `~/.cache/halo-frame-installer`; previous program/config backups are retained inside the owned installer run's `previous-program-files` folder. Windows installer state uses `%LOCALAPPDATA%/HaloFrameInstaller`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup replaces the recognized 1.3.5 native program and leaves other Halo installations and unrelated games alone.
|
||||
|
||||
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the project-drawn Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is reported as pending rather than replacing an uncertain user choice.
|
||||
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the project-drawn Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is left unchanged and recorded in Show activity. This optional icon does not block completion: successful Install and Repair show Complete at 100%.
|
||||
|
||||
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; Install, Repair, and any completion retry use the same handling. No artwork download is needed. The new bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](../resources/artwork/README.md). The user confirmed the older entry and covers on the Frame tested with 1.2.3; the newer banner, logo, icon, and Notes still need a physical Frame check.
|
||||
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; Install, Repair, and any completion retry use the same handling. No artwork download is needed. The bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](../resources/artwork/README.md). The user confirmed the entry and covers on the Frame tested with 1.2.3. Banner, logo, icon, and Notes integration also have automated coverage.
|
||||
|
||||
The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenXR VR and competitive multiplayer come from the port. The PC-only Chimera, PC Restored maps, and HaloCEVR DLLs do not belong in this native build. Multiplayer requires compatible native/OpenCE protocol builds; Halo PC/Custom Edition/MCC servers and PC save files are incompatible.
|
||||
|
||||
## Sources and credits
|
||||
|
||||
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
|
||||
- [Exact VR source commit `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106), with a documented local controller/tutorial patch.
|
||||
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr).
|
||||
- [Exact VR source commit `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4), with a documented local controller/tutorial patch.
|
||||
- [astromaddie/HaloCE-VR](https://github.com/astromaddie/HaloCE-VR/tree/cff675537d961051d4aaf016c1e437542420b7df), for the menu, radar and optional buffer-streaming adaptation references credited in [SOURCES.md](SOURCES.md#143-vr-refinements).
|
||||
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr).
|
||||
- [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.
|
||||
|
||||
@@ -137,16 +185,21 @@ 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.
|
||||
- **Uninstall interrupted or folders preserved:** run Uninstall again to finish verified leftovers. Save backups remain separate and their verified paths are reported. If files cannot be verified, setup lists the preserved folders and remains at 99%; review the log before any manual removal. An invalid matching Steam shortcut must be corrected in Steam before retrying uninstall.
|
||||
- **Unsupported ISO or maps:** choose original Xbox retail data matching a [supported map build](#supported-xbox-iso-revisions). USA Rev 2 is validated; a filename containing Rev 1 or Rev 2 does not establish compatibility by itself. Keep all required maps from one release together.
|
||||
- **Frame slept during compilation:** reconnect, wake the Frame and keep it awake before retrying. Setup requests a temporary sleep inhibitor during the build and refuses to start a second installer build while the earlier container is still running. If stopping cannot be verified, follow the displayed recovery command first.
|
||||
- **Flat window instead of VR:** wake the headset and start SteamVR before launching. The port falls back to a flat window if no usable OpenXR session is available.
|
||||
- **Controls doubled:** check the native shortcut's launch option above. Steam Input may otherwise add a second virtual controller stream.
|
||||
- **Library entry absent:** click **Add to Steam again** to retry registration through the running client. If its library interface remains unavailable, use Steam's **Add a Non-Steam Game** with the executable and launch option above. Enable **Include in VR Library** and leave forced compatibility tools disabled in its properties. Setup's direct library-file edits require Steam to be already closed.
|
||||
- **Artwork or description absent:** Install and Repair add these automatically. If setup reports pending Steam information, click **Add to Steam again** after the run. The description is under **Notes**, not the non-Steam About panel. Pending artwork, icon, or Notes is reported separately from the shortcut; manual Notes includes copyable text. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
|
||||
- **Artwork or description absent:** Install and Repair add these automatically. If setup reports pending Steam information, click **Add to Steam again** after the run. The description is under **Notes**, not the non-Steam About panel. Pending artwork or Notes is reported separately from the shortcut; manual Notes includes copyable text. Optional shortcut-icon details stay in the activity log. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
|
||||
- **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 release executable is unsigned. Verify its SHA256 against the checksum in the release description; this project does not ask you to disable antivirus or other security protections. Review logs for private information before posting an issue.
|
||||
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
|
||||
|
||||
@@ -158,7 +211,9 @@ python -m venv .venv
|
||||
.\.venv\Scripts\python scripts/build_release.py
|
||||
```
|
||||
|
||||
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.4 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.4)** so you can modify and rebuild it, including its LGPL Paramiko dependency. GitHub's automatically generated Source code ZIP is optional for developers and is not needed to run setup. 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, and the original notices are retained. The build script packages installer code/resources and verified ADB components with their notices; no retail game executable, user ISO, maps, SSH password, or private key is included. GitHub Actions runs tests and builds the Windows setup executable. A local rebuild also writes a source bundle and checksum file to `dist/`; the public release attaches only the clearly named mod installer executable and prints its checksum in the release description.
|
||||
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.3 Git tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.3), 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.
|
||||
|
||||
The workflow also includes an ARM64 Linux check, [check-build-container.py](../scripts/check-build-container.py), that uses the same rootless Podman arguments as the Frame build. It checks APT package installation, `setgroups`, `seteuid`, `setegid`, `chown`, and that container output belongs to the ordinary host user. The Windows executable CI job depends on that check as well as the unit tests. This container check uses no game data and does not replace a full Steam Frame installation test.
|
||||
|
||||
|
||||
+80
-16
@@ -1,26 +1,85 @@
|
||||
# VR mod sources and acknowledgements
|
||||
# Halo CE Steam Frame VR mod sources and acknowledgements
|
||||
|
||||
This community VR mod installer builds the experimental native ARM64 Linux VR port of **Halo: Combat Evolved** from the revision below. It provides a guided installation; the decompilation, renderer, OpenXR implementation and networking are the work of the credited upstream contributors. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded; users provide authorized original Xbox game data.
|
||||
|
||||
Sources were checked on 2026-10-05. Links to pinned files describe the build used here; moving documentation may describe newer behavior.
|
||||
References are pinned to the reviewed engine revisions. Release 1.4.3 retains the tutorial, flashlight, SD-storage and campaign-co-op changes, and adds the VR refinements below. The user confirmed the private candidate works on their Frame. Production C regressions and ARM64 compile/import checks provide additional software coverage; no measured FPS improvement or complete hardware/network test matrix is claimed.
|
||||
|
||||
## Installer contributions
|
||||
|
||||
[dime-online](https://github.com/dime-online) contributed the fixes for [interrupted uninstalls (#7)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/7), [renamed native Steam shortcuts (#8)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/8), and [SSH cancellation/error handling (#9)](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/9), addressing issues [#1](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/1), [#2](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/2), and [#3](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/3). Release 1.3.5 builds on those contributions with operation-record and directory-identity checks before resuming file removal, explicit pending-removal and cleanup results, refusal of malformed native shortcut IDs before writes, and one bounded cancellation recovery connection using the already-approved host key.
|
||||
|
||||
Those contributions remain in 1.4.3. The 1.4.0 release changed the engine pin and movement patch; subsequent releases retain that pin. Automated regression coverage does not replace physical Frame verification of interruption, cancellation and full uninstall.
|
||||
|
||||
## Native game and VR
|
||||
|
||||
| Source | Contribution or verified requirement |
|
||||
| --- | --- |
|
||||
| [startupfoundry's native Steam Frame VR work, OpenCE PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85) | OpenXR stereo, tracked head and controllers, native ARM64 host, desktop OpenGL and stereo multiview. This is experimental work in an open pull request. The normal upstream CI builds do not enable `--vr`. |
|
||||
| [Pinned fork revision `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106) | Exact source revision selected for this installer, rather than a moving branch. |
|
||||
| [Upstream build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#64-bit-arm) | Native ARM64 compiler requirements, SDL 3.4.16, musl 1.2.5, and the `linux_arm64` target. |
|
||||
| [Upstream VR documentation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr) | `configure.py --vr`, OpenXR runtime requirements, motion aiming, gamepad mode, comfort options, and experimental limitations. |
|
||||
| [startupfoundry's native Steam Frame VR work, OpenCE PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85) | OpenXR stereo, tracked head and controllers, native ARM64 host, desktop OpenGL and stereo multiview. This experimental work is documented in the pull request. The normal upstream CI builds do not enable `--vr`. |
|
||||
| [Pinned fork revision `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4) | Exact source revision selected for this installer, rather than a moving branch. |
|
||||
| [Upstream build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#64-bit-arm) | Native ARM64 compiler requirements, SDL 3.4.16, musl 1.2.5, and the `linux_arm64` target. |
|
||||
| [Upstream VR documentation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr) | `configure.py --vr`, OpenXR runtime requirements, motion aiming, gamepad mode, comfort options, and experimental limitations. |
|
||||
| [OpenCommunityEdition/OpenCE](https://github.com/OpenCommunityEdition/OpenCE) | Community port and integration work from which the VR fork descends. |
|
||||
| [bnunu/halo-1](https://github.com/bnunu/halo-1) and [punpckhdq/halo](https://github.com/punpckhdq/halo) | Decompilation lineage identified by the upstream README. |
|
||||
| [Upstream XDK declarations](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/include/xdk/README.md) | The port supplies its own declarations; installing the proprietary Xbox SDK is unnecessary. |
|
||||
| [Upstream XDK declarations](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/include/xdk/README.md) | The port supplies its own declarations; installing the proprietary Xbox SDK is unnecessary. |
|
||||
|
||||
The installer applies [its documented controller and tutorial patch](../resources/frame-controls.patch) to that pinned revision. [Controls and defaults](CONTROLS.md) distinguish these changes from upstream behavior. Performance depends on game scene, headset software and settings; native ARM64 does not imply unlimited performance.
|
||||
The installer applies [its documented VR integration patch](../resources/frame-controls.patch) to that pinned revision. The matching source and patch identity are recorded in the release's [build provenance](BUILD_PROVENANCE.md). [Controls and defaults](CONTROLS.md) distinguish the local changes from upstream behavior. Performance depends on scene, headset software and settings.
|
||||
|
||||
### 1.4.3 VR refinements
|
||||
|
||||
[astromaddie/HaloCE-VR](https://github.com/astromaddie/HaloCE-VR/tree/cff675537d961051d4aaf016c1e437542420b7df), reviewed at `cff675537d961051d4aaf016c1e437542420b7df`, supplied menu, radar and optional buffer-streaming ideas adapted to this native multiview backend. The local patch retains the startupfoundry engine pin; it does not import the fork's complete renderer or host ABI. The fork's [published source license](https://github.com/astromaddie/HaloCE-VR/blob/cff675537d961051d4aaf016c1e437542420b7df/LICENSE.md) and the existing component notices remain separate from rights in Halo retail data.
|
||||
|
||||
- **Vehicle controls and view:** Warthog A acceleration, B braking, stick steering and an optional two-grip wheel feed Halo's existing vehicle controls and brake physics. Seats capture a separate forward-facing reference, use authored cameras where available, and share the interpolated hull's full pitch/roll/bounce between head, hands and aim. The local occupied Warthog windshield is hidden for its first-person VR view. [Driving guide](CONTROLS.md#vehicles).
|
||||
- **Menu and reticle:** menus stay level and retain controller-pointer/Xbox navigation. Live menu visibility hides gameplay hands, gun, reticle and zoom overlays. Controller mode keeps ownership through tracking or projection loss: valid poses outside the headset's view continue aiming; unavailable or behind-head reticles cannot fall back to centered HUD aim. Scripted first-person arrivals retain tracked hands. Head/gamepad aiming and stock zoom remain available; the experimental optical scope and custom zoom smoothing are removed.
|
||||
- **Rendering safeguards:** each world submission retains the exact eye poses/FOV used to render it. Stereo near-plane bounds include both eyes; invalid framebuffers are discarded. Buffer reuse uses short GPU-fence waits and fresh storage when needed, with ordered visibility-counter resets. Experimental buffer streaming remains off by default. These changes address identified render paths without claiming a measured performance gain.
|
||||
|
||||
The patch includes production regressions for the driver/action pipeline, camera/hand transitions, menu dispatch, reticle projection, tracking recovery and renderer imports. The menu and reticle tests also reject the previous failing behavior. OpenXR pose validity follows the [Khronos view/projection specification](https://registry.khronos.org/OpenXR/specs/1.1/html/xrspec.html#rendering-views-projections); an inferred pose with valid position/orientation is not rejected merely because the controller is outside camera view. The compiler build uses a temporary [systemd sleep inhibitor](https://systemd.io/INHIBITOR_LOCKS/) where available, without changing permanent power settings.
|
||||
|
||||
### 1.4.1 tutorial changes
|
||||
|
||||
The local patch adds a controller-directed unarmed/default reticle without replacing the equipped weapon's nominal firing preview. It uses local headset gaze for look-at-person checks. Only the five named first-mission calibration panels also accept the unarmed hand-reticle ray within their original five-degree cone. Cryopod look calibration resets across tracking/test transitions and accepts headset movement and legitimate snap or smooth view turns, while preserving recenter, menu, cinematic, AI and remote-player guards. Install and Repair apply standing mode and snap turning while keeping other preferences. These changes require their own headset validation; the 1.4.0 report does not cover them.
|
||||
|
||||
Tutorial Back consumes the gameplay actions mapped to B until the physical button is released, while keeping the original inversion-setting and profile-save logic. Production C checks reproduce the prior melee leak; the reported headset crash has not been reproduced locally. OpenXR pose queries clear old validity before querying and require both position and orientation validity, so failed or partial tracking cannot complete a gaze/hand target with an old pose. Focused tests cover those failure and recovery paths using the checked-in Khronos headers.
|
||||
|
||||
The tutorial-button regression includes the actual `vr.h` declaration and links the extracted production `halo_vr_running` definition from a separate translation unit. This avoids a test stub masking a missing session-query function. An offline ARM64 relocatable-link check also verifies the new tutorial bridges across the compiled guest objects; it is not a fully linked game or hardware test.
|
||||
|
||||
### 1.4.1 unarmed flashlight and build verification
|
||||
|
||||
The unarmed flashlight uses the current cached rendered controller camera for its origin and roll, with the same bounded direction as the unarmed reticle. A rendering-only light copy supplies its visibility and diffuse/specular bounds. The active local flashlight is included despite stale tick cluster membership, with scene-list capacity checks and a zero-intensity guard. Equipped weapon markers, remote lights, simulation attachment/reconnection and actual shot calculations retain their behavior. Dynamic object-light selection remains tick-based; physical beam placement and appearance need a headset test.
|
||||
|
||||
`tools/test_vr_flashlight.py` compiles the actual presentation and light-bounds functions and checks render poses between simulation ticks, elevation, roll, eligibility, stale culling, capacity and unchanged armed/tick math. The release workflow also runs [the full native ARM64 VR build check](../scripts/check-native-build.py), using the exact shipped source pin, patch, build script and rootless container flags. It exports JSON proof only, without game data or a native game binary. A successful compile/link does not establish headset appearance or measured frame rate.
|
||||
|
||||
### Retained 1.4.0 engine changes
|
||||
|
||||
- [Two-pass texture-stage resolution and binding](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/d3d8_gl.c#L2669-L2743) resolves/uploads all textures before binding each sampler stage, avoiding an upload overwriting a previous stage's binding.
|
||||
- [Vertex clip-position handling](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/nv2a_vsh.c#L309-L402) retains the pre-reciprocal clip position and moves zero/NaN clip `w` behind the camera. These changes are upstream renderer candidates; they do not diagnose the user's white flashes or prove improved Frame performance.
|
||||
- [Network co-op implementation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/network_coop.c) and [distributed-netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/NETCODE.md) describe campaign maps hosted over LAN/internet, synchronized script presentation/events, and host-run AI. The local patch replaces the split-screen campaign entry with explicit LAN and Internet choices.
|
||||
- [Native network version](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/include/halo_port_limits.h#L54-L74) is **17**, compared with **11** in the stable engine. Compatible peers need matching versions and capacity limits; legacy retail/PC/MCC clients are incompatible.
|
||||
- [Capacity and saved-game warning](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/include/halo_port_capacity.h#L19-L45) records the larger game-state/AI pools and explicitly invalidates earlier save layouts. 1.4.0 replaces the verified 1.3.5 program at `~/Games/HaloCENativeVR` and keeps **Halo: Combat Evolved VR (Native)**. Old saves stay unchanged at their original location; the new engine uses `save-v1.4` and a new profile/campaign. The original config and program are backed up in the owned installer run's `previous-program-files` folder. Checkpoints and profiles are not automatically migrated. Existing 1.4.0a2–a5 Experimental copies remain untouched.
|
||||
- [Co-op and broker defaults](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/port_config.c#L340-L375) include 50% additional enemies per extra player and `network.brokers_file`. [The ARM64 staging graph](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/tools/linux_arm64_build.py#L182-L187) includes [the broker list](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/assets/network/brokers.txt); setup stages it while preserving custom broker settings.
|
||||
|
||||
The local patch adds independent head-directed stick movement (`vr.movement = "head"`, `HALO_VR_MOVEMENT`), with `"aim"` retaining the previous behavior. It transforms local on-foot throttle after aiming and before queuing the player's action, leaving controller weapon aiming, vehicles, cinematics, menus, gamepad/flat input and remote players separate. Finite-pose/focus checks stop invalid movement; near-vertical head direction uses the last usable horizontal heading. The existing Xbox bumper mapping and physical-head tutorial hook are ported to this source.
|
||||
|
||||
[check-experimental-engine.py](../scripts/check-experimental-engine.py) verifies the exact checkout, clean patch applicability, these renderer/network/configuration invariants, and the actual offline ARM64 Ninja graph. Focused production C math checks cover locomotion. These checks complement the successful headset report; they do not measure comfort or hardware performance or establish co-op behavior across every network.
|
||||
|
||||
### Reticle and sun-glow changes
|
||||
|
||||
The local renderer patch previews the nominal primary shot through shared calculations from [weapon projectile creation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/items/weapons.c) and [player projectile aiming](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/game/aim_assist.c). The preview omits target/timestamp writes and firing randomness. It uses the projectile collision mask and current hit depth instead of the controller-origin ray and smoothed reticle depth. This predicts the nominal line, not random spread or later ballistic drop. Production C checks cover the shared calculation and render integration. The tester confirmed alignment in 1.4.0a4 and confirmed the updated reticle motion in the 1.4.0a5 build used for 1.4.0.
|
||||
|
||||
The render-rate preview supplies an optional presentation pose for the local on-foot player: current-frame aim and the unshaken interpolated observer camera. It keeps the game's muzzle projection and trigger offsets, rather than adding physical headset/hand translations to the shot origin. The unit, weapon and aim-assist helpers share their ordinary math; real firing uses no presentation override. Limited turrets retain the authoritative aiming path. Controller mode hides an unavailable preview instead of inventing centered HUD aim; intentional head/gamepad modes keep their ordinary HUD behavior. The reticle traces a new hit each rendered frame and projects it into both eyes, without interpolating previous hit points across walls. This adds no extra per-eye aim search; actual Frame timing still needs measurement.
|
||||
|
||||
The sun-glow patch retains the original mask/depth/blur routine and scales only its final VR composite by `vr.sun_glow_strength` (default 0.5, bounded 0–1). Non-finite settings fall back to 0.5; flat rendering uses 1.0. The production C sun tests verify asymmetric stereo projection, per-eye occlusion and source sampling, state restoration and intensity bounds.
|
||||
|
||||
### Online co-op menu and connection
|
||||
|
||||
The [pinned internet-play guide](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#internet-play) documents invite links, public server browsing, broker discovery, UDP hole punching and UPnP. It explicitly provides **no gameplay relay**: some pairs of strict NATs cannot connect without a usable forwarded port. The broker list and [configuration declarations](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/port_config.c#L320-L383) enable internet play and the browser by default, while `network.coop_public` defaults to false. A custom broker file and network settings remain user choices.
|
||||
|
||||
[Upstream menu code](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/menu_functions.c) implements **Create Game > Internet**, a **SINGLEPLAYER** map chooser, campaign difficulty, **Server Setup > LISTING**, **Join Game > Server Browser**, and **Direct Link > PASTE LINK**. [Cooperative server setup](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/interface/ui_widget_event_handler_functions.c#L5950-L5976) marks a selected campaign map as a network co-op game. The direct-link paste operation reads the clipboard on the machine running Halo, so the Frame's clipboard is needed, not its installer PC's.
|
||||
|
||||
The [local integration patch](../resources/frame-controls.patch) replaces **CO-OP CAMPAIGN** with explicit LAN/Internet host and join actions in VR and flat mode, selecting campaign maps with one local player. Its scoped map intent is cleared during synchronous widget loading; failed loading disposes the new host/client. The campaign XML and its generator are changed together so regeneration cannot restore the split-screen path. Generic profile widgets and competitive routes are retained. `tools/test_online_coop_menu.py` executes the production host/map/action helpers and XML event contracts, including failures, cancellation and single-local-player campaign behavior. These tests do not open a network connection or prove gameplay with a distant friend. [Practical hosting and joining steps](SETUP.md#online-campaign-co-op-with-a-friend).
|
||||
|
||||
## Game data and compatibility
|
||||
|
||||
[Upstream game-data instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/README.md#game-data) require an **original Xbox Halo: Combat Evolved** disc image (`.iso` or `.xiso`) or its extracted maps. This installer uses the exact released-map build list in the [pinned cache compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631):
|
||||
[Upstream game-data instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/README.md#game-data) require an **original Xbox Halo: Combat Evolved** disc image (`.iso` or `.xiso`) or its extracted maps. This installer uses the exact released-map build list in the [pinned cache compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c#L616-L631):
|
||||
|
||||
| Accepted Xbox version-5 map build | Upstream region |
|
||||
| --- | --- |
|
||||
@@ -28,13 +87,13 @@ The installer applies [its documented controller and tutorial patch](../resource
|
||||
| `01.08.15.1749` | NTSC |
|
||||
| `01.01.14.2342` | PAL |
|
||||
|
||||
**USA Rev 2 is validated with this installer.** Other original Xbox retail revisions and regions, including an image labeled USA Rev 1 or the original USA release, are accepted only when all 24 required maps have one of these builds. [Upstream PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) identify shared NTSC maps across three releases and explain the PAL timing conversion. Those source files do not provide a definitive filename-to-Rev-label mapping; setup validates actual headers instead of trusting a filename. Incomplete or mixed-build sets and other build strings are rejected. This is a supported-data rule, not a claim that every disc pressing has been tested on a Frame.
|
||||
**USA Rev 2 was validated with the stable installer.** 1.4.0 retains its map-header checks. Other original Xbox retail revisions and regions, including an image labeled USA Rev 1 or the original USA release, are accepted only when all 24 required maps have one of these builds. [Upstream PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/pal_tags.c#L4-L22) identify shared NTSC maps across three releases and explain the PAL timing conversion. Those source files do not provide a definitive filename-to-Rev-label mapping; setup validates actual headers instead of trusting a filename. Incomplete or mixed-build sets and other build strings are rejected. This is a supported-data rule, not a claim that every disc pressing has been tested on a Frame.
|
||||
|
||||
Select a local disc image or maps that you are authorized to use. The installer checks original Xbox map headers and extracts the required data locally. The repository and downloadable installer contain no game disc image or commercial map data and provide no unauthorized game-download links.
|
||||
|
||||
Halo PC, Custom Edition, Xbox 360 Anniversary and MCC data are different formats. This is the native **Steam Frame** Linux VR port; the Android port in the upstream repository is not a promise of a Quest VR installation. PC Chimera, PC Restored map replacements and HaloCEVR DLLs are not loaded by this ARM64 build. Its native renderer and VR implementation supply the relevant graphics and headset support. PC Halo saves and profiles also differ from the native port's Xbox formats.
|
||||
|
||||
[Upstream multiplayer](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/README.md#multiplayer) supports LAN and internet play among compatible builds of this port. The [netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/NETCODE.md) describes its replacement networking. Use matching native/OpenCE protocol versions; compatibility with legacy Halo PC, Custom Edition, MCC or retail Xbox servers is not established by this project.
|
||||
[Upstream multiplayer](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/README.md#multiplayer) supports LAN and internet play among compatible builds of this port. The [netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/NETCODE.md) describes its replacement networking. Use matching native/OpenCE protocol versions; compatibility with legacy Halo PC, Custom Edition, MCC or retail Xbox servers is not established by this project.
|
||||
|
||||
## Steam Frame setup and connection
|
||||
|
||||
@@ -55,7 +114,7 @@ Version 1.3.0 adds **USB-C cable** alongside **Network (Wi-Fi or Ethernet)**. US
|
||||
|
||||
The bundled components are ADB 37.0.1 (protocol 1.0.41) from the pinned [Google Windows archive](https://dl.google.com/android/repository/platform-tools_r37.0.1-win.zip), whose SHA256 is `45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99`. [resources/usb/manifest.json](../resources/usb/manifest.json) records selected-file checksums and [THIRD_PARTY.md](THIRD_PARTY.md#adb-for-usb-transfer) links the retained notices, AOSP source-license references, and Google's section 3.5 open-source component terms. The build helper fetches and verifies the archive when creating the EXE; setup does not download SDK tools during an installation.
|
||||
|
||||
Runtime prefers an existing ADB client on `PATH`, then its bundled files; an explicit advanced selection overrides discovery. Bundled files and notices are verified and cached in a persistent, versioned per-user folder. Setup checks server/client protocol compatibility before normal commands, clears inherited remote-server overrides, and verifies actual OS loopback bindings for its forward before sending the SSH password. Unsupported or unverifiable results stop USB setup and show corrective steps rather than resetting shared services.
|
||||
An explicit advanced ADB selection overrides discovery. Otherwise setup prefers its verified bundled files before SDK/PATH fallback. Bundled files and notices are cached in a persistent, versioned per-user folder. Setup checks server/client protocol compatibility before normal commands, clears inherited remote-server overrides, and verifies actual OS loopback bindings for its forward before sending the SSH password. Unsupported or unverifiable results stop USB setup and show corrective steps rather than resetting shared services.
|
||||
|
||||
The cable carries PC-to-Frame transfer and commands. The Frame still needs internet for source, containers, and compiler dependencies, and the native compilation runs on the Frame as before. Neither cited reference establishes an installer speed benchmark or guarantees faster transfers. USB selection, forwarding, authentication integration, and cleanup have software tests; a physical Steam Frame USB transfer has not yet been verified. The previously tested native game and live Steam-library behavior do not establish USB hardware compatibility on every SteamOS release.
|
||||
|
||||
@@ -99,8 +158,9 @@ The Windows installer uses the classic Halo PC helmet-and-shoulders icon in
|
||||
1.3.2. [HaloNet's favicon ICO](https://halonet.net/favicon.ico) is byte-identical
|
||||
to icon group 102 of the locally installed retail Halo PC executable. Only the
|
||||
icon is bundled; its [resource notice](../resources/ui/README.md) records the
|
||||
verification and exact hashes. The README displays only the banner and title
|
||||
logo, with the installer icon separate from Steam's retained project drawing.
|
||||
verification and exact hashes. The README displays the banner, title logo and
|
||||
credited community gameplay example below, with the installer icon separate
|
||||
from Steam's retained project drawing.
|
||||
|
||||
The bundled portrait image is the original **Halo: Combat Evolved** cover from [Halopedia's cover-art file page](https://www.halopedia.org/File:HCE_Cover_Art.jpg), with the [original JPEG](https://www.halopedia.org/images/8/8e/HCE_Cover_Art.jpg). Halopedia records the [official Halo Facebook image](https://www.facebook.com/Halo/photos/a.137195553028391/1561119617302637/) as its source. The landscape library image arranges the same cover in a wider layout without stretching it.
|
||||
|
||||
@@ -114,10 +174,14 @@ The new adapter's artwork type mapping, `SetCustomArtworkForApp`, `SetShortcutIc
|
||||
|
||||
[Valve's June 2023 Steam update](https://store.steampowered.com/news/app/593110/view/3687931965598906184), also available in its [official announcement feed](https://store.steampowered.com/news/posts/?enddate=1687195871&feed=steam_blog), introduces rich-text Notes accessible from a game's details page and overlay, with Steam synchronization. This patch uses Notes for an original Halo summary, Xbox controls, compatibility guidance, and source links. It does not replace Steam's non-Steam About text or create official store metadata.
|
||||
|
||||
The helper matches the current account and exact native executable before writing, synchronizes before reading, preserves the full existing document, guards against intervening changes, saves one identified note, and verifies the result. User-edited installer notes remain untouched. Unsupported schemas, account/name ambiguity, or synchronization failures produce a manual, copyable description. Notes use Steam's normal private-note synchronization; setup sends no description to another game's app ID or outside service. The banner, logo, icon, and Notes have automated tests but still need verification on a physical Frame.
|
||||
The helper matches the current account and exact native executable before writing, synchronizes before reading, preserves the full existing document, guards against intervening changes, saves one identified note, and verifies the result. User-edited installer notes remain untouched. Unsupported schemas, account/name ambiguity, or synchronization failures produce a manual, copyable description. Notes use Steam's normal private-note synchronization; setup sends no description to another game's app ID or outside service. The banner, logo, icon, and Notes integration have automated tests.
|
||||
|
||||
## README gameplay example
|
||||
|
||||
The [README image](images/halocevr-steam-frame-example.jpeg) is the unchanged screenshot posted by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/) in **Halo (HaloCEVR). Working great on Steam Frame standalone. (Proton ARM)**. It illustrates the PC HaloCEVR mod running on Steam Frame through Proton ARM, not this project's native 1.4.0 build. The original image, exact hash and rights notice are recorded in [the image source notice](images/README.md).
|
||||
|
||||
## Local Halo menu music
|
||||
|
||||
The preview reads only the `sound\music\title1\loops` sound tag from the user's selected original Xbox `ui.map`, either inside an ISO/XISO or in extracted maps. Cache and sound structures are based on the pinned native port's [cache_files.c](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c) and [sound_definitions.h](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/sound/sound_definitions.h). Xbox ADPCM decoding follows its [dsound_sdl.c implementation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/src/dsound_sdl.c).
|
||||
The installer reads only the `sound\music\title1\loops` sound tag from the user's selected original Xbox `ui.map`, either inside an ISO/XISO or in extracted maps. Cache and sound structures are based on the pinned native port's [cache_files.c](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c) and [sound_definitions.h](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/sound/sound_definitions.h). Xbox ADPCM decoding follows its [dsound_sdl.c implementation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/dsound_sdl.c).
|
||||
|
||||
The installer decodes the loop into a private temporary Windows WAV with a fixed 12% sample-amplitude gain. Playback loops automatically after local game data validates. Closing the installer stops playback and clears its temporary audio. No soundtrack is present in the repository or executable, fetched from a website, or uploaded over SSH. Audio-file extensions are rejected by the release packager. Remote-only repair has no local audio preview, and an unavailable preview does not block installation. The original music remains copyrighted game content; the installer's code license does not apply to it. Playback still needs a listening check.
|
||||
+11
-6
@@ -53,14 +53,15 @@ The Windows download contains the mod installer and build instructions; no retai
|
||||
|
||||
| Upstream component | Notice/reference |
|
||||
| --- | --- |
|
||||
| [Halo native port / decompilation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/LICENSE.md) | CC0-1.0 for the upstream source. This does not grant rights in the commercial game data or third parties' rights. |
|
||||
| [Local controller/tutorial patch](../resources/frame-controls.patch) | A documented modification of the pinned upstream source; see [CONTROLS.md](CONTROLS.md). |
|
||||
| [Halo native port / decompilation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/LICENSE.md) | CC0-1.0 for the upstream source. This does not grant rights in the commercial game data or third parties' rights. |
|
||||
| [Local VR integration patch](../resources/frame-controls.patch) | Documented controller, tutorial, vehicle, menu, tracking and renderer changes to the pinned upstream source; see [CONTROLS.md](CONTROLS.md). |
|
||||
| [astromaddie/HaloCE-VR, reviewed revision](https://github.com/astromaddie/HaloCE-VR/tree/cff675537d961051d4aaf016c1e437542420b7df) | Menu, radar and optional buffer-streaming adaptation reference. Its [published source license](https://github.com/astromaddie/HaloCE-VR/blob/cff675537d961051d4aaf016c1e437542420b7df/LICENSE.md) is CC0-1.0; component notices and rights in retail game data remain separate. |
|
||||
| [SDL 3.4.16](https://github.com/libsdl-org/SDL/blob/release-3.4.16/LICENSE.txt) | zlib license; retain the original notice with the native library. |
|
||||
| [musl 1.2.5](https://git.musl-libc.org/cgit/musl/tree/COPYRIGHT?h=v1.2.5) | MIT and notices identified in its COPYRIGHT file. Used for the upstream ILP32 guest. |
|
||||
| [Khronos OpenXR SDK headers](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party/openxr/README.md) | OpenXR SDK 1.1.63, Apache-2.0 OR MIT. Header provenance is documented upstream. |
|
||||
| [mbedTLS, expat, miniupnpc, KCP, tomlc17 and stb](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party) | Their individual upstream license files apply. Preserve the actual files when producing the native installation. |
|
||||
| [extract-xiso](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party/extract-xiso/LICENSE.TXT) | Custom BSD-style notice with acknowledgement and attribution conditions. This product includes software developed by in <in@fishtank.com>. |
|
||||
| [Upstream font resources](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106/port/assets/fonts) | Retain Overpass's SIL Open Font License and Newtown's public-domain notice when using those upstream resources. |
|
||||
| [Khronos OpenXR SDK headers](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party/openxr/README.md) | OpenXR SDK 1.1.63, Apache-2.0 OR MIT. Header provenance is documented upstream. |
|
||||
| [mbedTLS, expat, miniupnpc, KCP, tomlc17 and stb](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party) | Their individual upstream license files apply. Preserve the actual files when producing the native installation. |
|
||||
| [extract-xiso](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party/extract-xiso/LICENSE.TXT) | Custom BSD-style notice with acknowledgement and attribution conditions. This product includes software developed by in <in@fishtank.com>. |
|
||||
| [Upstream font resources](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/assets/fonts) | Retain Overpass's SIL Open Font License and Newtown's public-domain notice when using those upstream resources. |
|
||||
|
||||
The rootless build environment installs [Ubuntu packages](https://ubuntu.com/legal/open-source) and [LLVM](https://llvm.org/docs/DeveloperPolicy.html#license) under their own licenses inside its container. The installer does not relicense those tools or SteamOS/SteamVR. Game artwork, names and data remain the property of their respective rights holders.
|
||||
|
||||
@@ -80,6 +81,10 @@ Existing library art and icons are retained, including custom images with anothe
|
||||
|
||||
The Steam Notes description is original summary and control-guide text, not copied store text. Notes remain a private Steam feature governed by Steam's own behavior; no Steam client code is redistributed by the helper. Observed client interfaces are credited in [SOURCES.md](SOURCES.md#description-and-controls-in-steam-notes).
|
||||
|
||||
## README gameplay image
|
||||
|
||||
`docs/images/halocevr-steam-frame-example.jpeg` is an unchanged community screenshot by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/), showing the PC HaloCEVR mod on Steam Frame through Proton ARM. It is labeled as a community example and is not a native 1.4.0 capture. The screenshot and underlying Halo content retain their respective rights; the installer's MIT code license does not apply to this image. See [the image source notice](images/README.md) for its original URL and SHA256.
|
||||
|
||||
## User-supplied menu audio
|
||||
|
||||
The original Halo menu music is not bundled with the installer. After the user selects valid local original Xbox data, setup reads the menu loop from that game's `ui.map`, decodes it quietly to a private temporary WAV, and automatically plays it locally. It is not downloaded or uploaded to the Frame, and its temporary decoded file is cleared on close. Repair with only remote maps does not supply a preview. The original music retains its rights holders' copyright and is separate from the installer's MIT code license.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# README gameplay image source
|
||||
|
||||
`halocevr-steam-frame-example.jpeg` is an unchanged 1,920 × 1,080 JPEG screenshot by **u/Sea-Communication760**, retrieved on 2026-10-06 from their [HaloCEVR Steam Frame post](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/).
|
||||
|
||||
- Original image: [Reddit-hosted JPEG](https://i.redd.it/u721sjfvxnrh1.jpeg).
|
||||
- SHA256: `612f7d0471940fd1fba4e7b0f6052384a69164e401729ce7e95a2c0d62c562f0`.
|
||||
- Depicted software: the PC **HaloCEVR** mod running on Steam Frame through **Proton ARM**, as identified by the original post. This is not a capture of this project's native 1.4.0 build.
|
||||
- Rights: the screenshot and underlying Halo content retain their respective rights. The project's MIT code license does not apply to this image; no open image license is claimed.
|
||||
|
||||
The local copy keeps the README image available without depending on the external image host. No pixels were cropped, resized or retouched; the README only sets its displayed width.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "halo-steam-frame-installer"
|
||||
version = "1.3.4"
|
||||
version = "1.4.3"
|
||||
description = "Community Halo CE VR mod installer for Steam Frame; original Xbox game data must be supplied separately"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
|
||||
@@ -5,6 +5,18 @@ cd /build
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NINJA_STATUS='[%f/%t] '
|
||||
phase() { printf 'HFI_BUILD_PHASE %s %s\n' "$1" "$2"; }
|
||||
phase dependencies 'Checking bundled native build tests before downloading packages...'
|
||||
# Read the commands from this same script so a removed or unshipped regression
|
||||
# fails before package downloads. This needs only Bash, before Python is installed.
|
||||
while IFS= read -r _hfi_test_command || [[ -n "$_hfi_test_command" ]]; do
|
||||
if [[ "$_hfi_test_command" =~ ^python3[[:space:]]+(tools/test_[[:alnum:]_]+\.py)([[:space:]]|$) ]]; then
|
||||
_hfi_test_script="${BASH_REMATCH[1]}"
|
||||
if [[ ! -f "/build/src/$_hfi_test_script" ]]; then
|
||||
printf 'Missing native build test: %s\n' "$_hfi_test_script" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done < "${BASH_SOURCE[0]}"
|
||||
phase dependencies 'Installing isolated ARM64 build dependencies...'
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl gnupg git python3 \
|
||||
@@ -17,6 +29,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 +45,35 @@ for tool in clang ld.lld llvm-ar; do
|
||||
done
|
||||
clang --version
|
||||
cd /build/src
|
||||
phase configure 'Verifying pinned build-source download safeguards...'
|
||||
python3 tools/test_build_sources.py
|
||||
phase configure 'Checking Xbox controls, VR menus and pointer, pause settings, GPU buffer and framebuffer recovery, vehicle view and steering, tracking, stock zoom, tutorial, flashlight, reticle, sun glow and campaign menus...'
|
||||
python3 tools/test_vr_locomotion.py --cc clang
|
||||
python3 tools/test_vr_menu.py --cc clang
|
||||
python3 tools/test_vr_pointer_radar.py --cc clang
|
||||
python3 tools/test_vr_pause_menu.py --cc clang
|
||||
python3 tools/test_persistent_stream_buffers.py --cc clang
|
||||
python3 tools/test_vr_framebuffer.py --cc clang
|
||||
python3 tools/test_vr_vehicle.py --cc clang
|
||||
python3 tools/test_vr_vehicle_pose.py --cc clang
|
||||
python3 tools/test_vr_cinematic_hands.py --cc clang
|
||||
python3 tools/test_vr_zoom_aim.py --cc clang
|
||||
python3 tools/test_vr_stock_zoom_hud.py --cc clang
|
||||
python3 tools/test_vr_renderer_link.py --cc clang --ld ld.lld --sdk /usr/include
|
||||
python3 tools/test_vr_driving.py --cc clang
|
||||
python3 tools/test_vr_driving_game.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
|
||||
python3 tools/test_vr_flashlight.py --cc clang
|
||||
python3 tools/test_vr_sun_glow.py --cc clang
|
||||
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...'
|
||||
|
||||
+16041
-25
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -106,15 +106,15 @@
|
||||
},
|
||||
{
|
||||
"name": "halo-steam-frame-installer",
|
||||
"version": "1.3.4",
|
||||
"version": "1.4.3",
|
||||
"license_expression": "MIT",
|
||||
"declared_license": null,
|
||||
"project_urls": [],
|
||||
"notices": [
|
||||
{
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.4.dist-info/licenses/LICENSE",
|
||||
"sha256": "b19db6d1e8942380f6a0dc3b9ec39cec802606c9d1c08a6050eae2990568afbb",
|
||||
"bytes": 1318
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.4.3.dist-info/licenses/LICENSE",
|
||||
"sha256": "04a51df2edd89917e3099e2f6f171c36891f1d6ac234015867b57a1d10f928d5",
|
||||
"bytes": 1343
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -215,28 +215,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pillow",
|
||||
"version": "12.3.0",
|
||||
"license_expression": "MIT-CMU",
|
||||
"declared_license": null,
|
||||
"project_urls": [
|
||||
"Release notes, https://pillow.readthedocs.io/en/stable/releasenotes/index.html",
|
||||
"Changelog, https://github.com/python-pillow/Pillow/releases",
|
||||
"Documentation, https://pillow.readthedocs.io",
|
||||
"Funding, https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi",
|
||||
"Homepage, https://python-pillow.github.io",
|
||||
"Mastodon, https://fosstodon.org/@pillow",
|
||||
"Source, https://github.com/python-pillow/Pillow"
|
||||
],
|
||||
"notices": [
|
||||
{
|
||||
"path": "packages/pillow/pillow-12.3.0.dist-info/licenses/LICENSE",
|
||||
"sha256": "4f7866a74802c6326f81faff59a56546b6aec2b10b91973e0e9308de95e79857",
|
||||
"bytes": 78016
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pip",
|
||||
"version": "25.0.1",
|
||||
@@ -524,7 +502,7 @@
|
||||
"sha256": "6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f",
|
||||
"bytes": 1630743,
|
||||
"source": "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz",
|
||||
"metadata_source": "https://pypi.org/pypi/paramiko/4.0.0/json"
|
||||
"checksum_source": "repository-pinned scripts/gather_notices.py"
|
||||
},
|
||||
{
|
||||
"path": "runtime/openssl-3.5.8-LICENSE.txt",
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
+25
-25
@@ -1,25 +1,25 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
+25
-25
@@ -1,25 +1,25 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
+25
-25
@@ -1,25 +1,25 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
+980
-101
File diff suppressed because it is too large.
Load diff
+23
-11
@@ -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 {})}
|
||||
+145
-16
@@ -6,11 +6,13 @@ 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"
|
||||
NOTE_CONTENT = """[h1]Halo: Combat Evolved VR (Native)[/h1]
|
||||
# Exact unchanged 1.4.0a2 note, verified from its local source ZIP.
|
||||
# UTF-8 SHA256: a4e1776caa60548620e0510acbe194aa414645be8d042771d9cbe1ec49edc05e
|
||||
A2_NOTE_CONTENT = """[h1]Halo: Combat Evolved VR (Experimental)[/h1]
|
||||
Play the original 2001 Halo: Combat Evolved campaign as Master Chief, fighting
|
||||
the Covenant and uncovering the secrets of a mysterious ringworld.
|
||||
|
||||
@@ -21,7 +23,7 @@ vary by scene, headset software, and settings.
|
||||
|
||||
[h2]Xbox-style Frame controls[/h2]
|
||||
Point the right controller to aim; move your head to look around.
|
||||
Left stick: move. Left stick click: crouch.
|
||||
Left stick: move relative to your head direction. Left stick click: crouch.
|
||||
Right stick left/right: turn. Right stick click: zoom.
|
||||
Right trigger: fire. Left trigger: throw grenade.
|
||||
A: jump; accept menu selections. B: melee; go back in menus.
|
||||
@@ -36,14 +38,110 @@ VR options are available in Settings > VR Setup.
|
||||
|
||||
[h2]Multiplayer[/h2]
|
||||
LAN and internet play require compatible native/OpenCE protocol builds.
|
||||
This experimental build uses protocol 17; stable protocol 11 cannot join it.
|
||||
For campaign co-op, host a LAN or internet game and choose a SINGLEPLAYER map.
|
||||
The experimental game and saves use a separate folder. Older checkpoints
|
||||
cannot be loaded. Extra co-op enemies are disabled by default.
|
||||
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
|
||||
provided by this installer.
|
||||
|
||||
[h2]Sources and setup help[/h2]
|
||||
[url=https://github.com/ClassicWasTaken/HaloCE-SteamFrame]Installer, controls, and troubleshooting[/url]
|
||||
[url=https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106]Pinned native port source[/url]
|
||||
[url=https://github.com/ClassicWasTaken/HaloSteamFrameMod]Stable installer project and sources[/url]
|
||||
[url=https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4]Pinned native port source[/url]
|
||||
"""
|
||||
|
||||
A4_NOTE_CONTENT = A2_NOTE_CONTENT.replace(
|
||||
"""[h2]Multiplayer[/h2]
|
||||
LAN and internet play require compatible native/OpenCE protocol builds.
|
||||
This experimental build uses protocol 17; stable protocol 11 cannot join it.
|
||||
For campaign co-op, host a LAN or internet game and choose a SINGLEPLAYER map.
|
||||
The experimental game and saves use a separate folder. Older checkpoints
|
||||
cannot be loaded. Extra co-op enemies are disabled by default.
|
||||
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
|
||||
provided by this installer.
|
||||
""",
|
||||
"""[h2]Online campaign co-op with a friend[/h2]
|
||||
LAN and internet play require compatible native/OpenCE protocol builds.
|
||||
Both players should install the same experimental version and supply their
|
||||
own supported original Xbox maps. This build uses protocol 17; stable
|
||||
protocol 11, Halo PC, Custom Edition, MCC and retail Xbox clients cannot join.
|
||||
|
||||
Host: in an active headset, Multiplayer > CO-OP CAMPAIGN opens online
|
||||
hosting with SINGLEPLAYER campaign maps. Choose a level, then difficulty.
|
||||
In Server Setup, set LISTING to PUBLIC for controller-only discovery.
|
||||
PUBLIC lets anyone find and join the game; PRIVATE is the default.
|
||||
Choose START GAME. Friend: Multiplayer > Join Game > Server Browser,
|
||||
REFRESH if needed, select the host's game and press A. When both players
|
||||
appear in the lobby, wait for the countdown or select START NOW.
|
||||
Use the left d-pad to navigate/change row choices, A to accept, B to go back.
|
||||
Each player uses their own headset; no local second controller is required.
|
||||
|
||||
For invite-only play, keep PRIVATE. On the host, select the INVITE LINK row
|
||||
and press A to copy its halo://join/ link; share it yourself. The friend
|
||||
copies it on their Frame, then uses Join Game > Direct Link > PASTE LINK
|
||||
and selects the host's game. A PC clipboard is separate from the Frame's.
|
||||
An invite stops working after its hosting game quits.
|
||||
|
||||
Internet play uses direct UDP with signaling brokers, not a gameplay relay.
|
||||
Strict NAT/CGNAT can prevent joining even when a listing is visible. UPnP,
|
||||
hosting from the other network, or forwarding a fixed network.tunnel_port
|
||||
on an accessible router may help; distant-peer VR co-op remains unverified.
|
||||
The experimental game/saves stay separate; older checkpoints cannot load.
|
||||
Extra co-op enemies are disabled by default. For the original menu route,
|
||||
use Create Game > Internet and change the Map chooser to SINGLEPLAYER.
|
||||
Flat-screen CO-OP CAMPAIGN retains local split-screen.
|
||||
""")
|
||||
|
||||
# Exact previously shipped a3/a4 guide; upgrade only unchanged managed notes.
|
||||
# UTF-8 SHA256: 55785ec794857e844722ba758a4ce47fdb607a6bba2c30f23a437dd8f320ae30
|
||||
# Exact unchanged stable 1.3.5 note; UTF-8 SHA256: ceed0d2d403fa18904c5d529ae409c8bfedad733fa1f1a7d93a6e2c2fdb8d32a
|
||||
STABLE_NOTE_CONTENT = "[h1]Halo: Combat Evolved VR (Native)[/h1]\nPlay the original 2001 Halo: Combat Evolved campaign as Master Chief, fighting\nthe Covenant and uncovering the secrets of a mysterious ringworld.\n\nThis independent community installation uses your original Xbox game data\nwith an experimental native ARM64 Linux/OpenXR VR port on Steam Frame.\nIt is unaffiliated with Microsoft, Bungie, or Valve. VR support and performance\nvary by scene, headset software, and settings.\n\n[h2]Xbox-style Frame controls[/h2]\nPoint the right controller to aim; move your head to look around.\nLeft stick: move. Left stick click: crouch.\nRight stick left/right: turn. Right stick click: zoom.\nRight trigger: fire. Left trigger: throw grenade.\nA: jump; accept menu selections. B: melee; go back in menus.\nX: reload/use; hold for context actions such as exchanging a weapon.\nY: change weapon. Left bumper: change grenade type.\nRight bumper: toggle flashlight. Left controller d-pad: navigate menus.\nView: scoreboard/back. Menu: pause.\nBoth grips held: recenter the headset view; release a held foregrip first.\nLeft grip near a long gun's foregrip: aim the gun with both hands.\nThese controls assume the game's default profile button layout.\nVR options are available in Settings > VR Setup.\n\n[h2]Multiplayer[/h2]\nLAN and internet play require compatible native/OpenCE protocol builds.\nHalo PC, Custom Edition, MCC, and retail Xbox server compatibility is not\nprovided by this installer.\n\n[h2]Sources and setup help[/h2]\n[url=https://github.com/ClassicWasTaken/HaloCE-SteamFrame]Installer, controls, and troubleshooting[/url]\n[url=https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106]Pinned native port source[/url]\n"
|
||||
PREVIOUS_NOTE_CONTENTS = (A2_NOTE_CONTENT, A4_NOTE_CONTENT, STABLE_NOTE_CONTENT)
|
||||
NOTE_CONTENT = (A4_NOTE_CONTENT[:A4_NOTE_CONTENT.index("[h2]Online campaign co-op with a friend[/h2]")]
|
||||
+ """[h2]LAN and online campaign co-op[/h2]
|
||||
LAN and internet play require compatible native/OpenCE protocol builds.
|
||||
Both players should install the same experimental version and supply their
|
||||
own supported original Xbox maps. This build uses protocol 17; stable
|
||||
protocol 11, Halo PC, Custom Edition, MCC and retail Xbox clients cannot join.
|
||||
|
||||
Open Multiplayer > CO-OP CAMPAIGN. Each machine uses one local player.
|
||||
On the same network: HOST LOCAL (LAN) and JOIN LOCAL (LAN).
|
||||
Across different networks: HOST ONLINE (INTERNET) and JOIN ONLINE (INTERNET).
|
||||
Choose a campaign level and difficulty on the host, then START GAME.
|
||||
The friend selects the host's game; REFRESH if needed. Once both names
|
||||
appear in the lobby, wait for the countdown or select START NOW.
|
||||
Use the left d-pad to navigate/change choices, A to accept, B to go back.
|
||||
|
||||
Online hosting is PRIVATE by default. For server-browser discovery, set
|
||||
LISTING to PUBLIC in Server Setup. PUBLIC lets anyone find and join.
|
||||
For invite-only play, keep PRIVATE, select the INVITE LINK row and press A
|
||||
to copy its halo://join/ link. Share it yourself. The friend copies it on
|
||||
their Frame, selects JOIN INVITE LINK, then PASTE LINK and the host's game.
|
||||
A PC clipboard is separate from the Frame's. The invite expires when its
|
||||
hosting game quits. Each player uses their own headset or computer.
|
||||
|
||||
Internet play uses direct UDP with signaling brokers, not a gameplay relay.
|
||||
Strict NAT/CGNAT can prevent joining even when a listing is visible. UPnP,
|
||||
hosting from the other network, or forwarding a fixed network.tunnel_port
|
||||
on an accessible router may help; distant-peer VR co-op remains unverified.
|
||||
The experimental game/saves stay separate; older checkpoints cannot load.
|
||||
Extra co-op enemies are disabled by default.
|
||||
|
||||
"""
|
||||
+ A4_NOTE_CONTENT[A4_NOTE_CONTENT.index("[h2]Sources and setup help[/h2]"):])
|
||||
|
||||
# Public 1.4.0 uses the existing native shortcut and preserves older checkpoints.
|
||||
NOTE_CONTENT = (NOTE_CONTENT
|
||||
.replace("Halo: Combat Evolved VR (Experimental)", "Halo: Combat Evolved VR (Native)")
|
||||
.replace("with an experimental native ARM64", "with a native ARM64")
|
||||
.replace("the same experimental version", "the same 1.4.0 release")
|
||||
.replace("This build uses protocol 17; stable", "1.4.0 uses protocol 17; 1.3.5")
|
||||
.replace("The experimental game/saves stay separate; older checkpoints cannot load.",
|
||||
"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.")
|
||||
.replace("Stable installer project and sources", "Installer project and sources"))
|
||||
|
||||
MAX_DOCUMENT_BYTES = 1024 * 1024
|
||||
MAX_NOTES = 512
|
||||
|
||||
@@ -60,6 +158,10 @@ NOTES = r"""(async () => {
|
||||
!user || typeof user.accountName !== 'string' || !user.accountName ||
|
||||
typeof TextEncoder !== 'function')
|
||||
return manual('Steam Game Notes is unavailable. Add the supplied text as a note manually.');
|
||||
if (!Array.isArray(S.previousContents) || S.previousContents.length > 4 ||
|
||||
S.previousContents.some(content => typeof content !== 'string' ||
|
||||
new TextEncoder().encode(content).length > 16384))
|
||||
return manual('The previous installer note list is invalid. Existing notes were preserved.');
|
||||
const accountName = user.accountName;
|
||||
async function bounded(value, limit=3500) {
|
||||
let timer;
|
||||
@@ -136,18 +238,23 @@ NOTES = r"""(async () => {
|
||||
const doc=parse(first,true);
|
||||
const matches=doc.notes.filter(note => note.id === S.noteId);
|
||||
if (matches.length > 1) throw new Error('The Halo notes identifier is ambiguous');
|
||||
let previous;
|
||||
if (matches.length) {
|
||||
await identity();
|
||||
if (matches[0].title !== S.title || matches[0].content !== S.content ||
|
||||
matches[0].shortcut_name !== S.name)
|
||||
if (matches[0].title !== S.title || matches[0].shortcut_name !== S.name ||
|
||||
(matches[0].content !== S.content && !S.previousContents.includes(matches[0].content)))
|
||||
return {ok:true,status:'custom',message:'Your edited Halo note was kept unchanged.'};
|
||||
return {ok:true,status:'existing',message:'The Halo About and controls note is already present.'};
|
||||
if (matches[0].content === S.content)
|
||||
return {ok:true,status:'existing',message:'The Halo About and controls note is already present.'};
|
||||
previous=matches[0];
|
||||
}
|
||||
if (doc.notes.length >= S.maxNotes) throw new Error('Steam notes exceed supported bounds');
|
||||
if (!previous && doc.notes.length >= S.maxNotes) throw new Error('Steam notes exceed supported bounds');
|
||||
const timestamp=Math.floor(Date.now()/1000);
|
||||
const note={id:S.noteId,shortcut_name:S.name,ordinal:0,time_created:timestamp,
|
||||
const note=previous ? {...previous,content:S.content,time_modified:timestamp} :
|
||||
{id:S.noteId,shortcut_name:S.name,ordinal:0,time_created:timestamp,
|
||||
time_modified:timestamp,title:S.title,content:S.content};
|
||||
const updated={...doc,notes:[...doc.notes,note]};
|
||||
const updated={...doc,notes:previous ? doc.notes.map(entry => entry === previous ? note : entry) :
|
||||
[...doc.notes,note]};
|
||||
const text=encoded(updated);
|
||||
// Do not overwrite edits made while the installer was preparing the note.
|
||||
const reread=await bounded(notes.GetNotes(filename,images));
|
||||
@@ -164,31 +271,53 @@ NOTES = r"""(async () => {
|
||||
if (encoded(readback) !== text)
|
||||
throw new Error('The Halo note could not be verified after saving');
|
||||
await identity();
|
||||
return {ok:true,status:'added',message:'Added the Halo About and controls note to Steam Game Notes.'};
|
||||
return previous ? {ok:true,status:'updated',message:'Updated the unchanged Halo note with online co-op instructions.'} :
|
||||
{ok:true,status:'added',message:'Added the Halo About and controls note to Steam Game Notes.'};
|
||||
} catch(error) {
|
||||
return manual('Steam could not verify the Halo note. Your other notes were preserved; add the supplied text manually.');
|
||||
}
|
||||
})()"""
|
||||
|
||||
|
||||
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})
|
||||
"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")
|
||||
if status in ("added", "existing", "custom") and value.get("ok") is True:
|
||||
if status in ("added", "updated", "existing", "custom") and value.get("ok") is True:
|
||||
result.update({"ok": True, "status": status,
|
||||
"message": {"added": "Added the Halo About and controls note to Steam Game Notes.",
|
||||
"updated": "Updated the unchanged Halo note with online co-op instructions.",
|
||||
"existing": "The Halo About and controls note is already present.",
|
||||
"custom": "Your edited Halo note was kept unchanged."}[status]})
|
||||
elif status == "manual":
|
||||
|
||||
+54
-25
@@ -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"),
|
||||
@@ -26,7 +27,7 @@ ARTWORK = (("p", ".jpg", "halo-ce-cover.jpg", "84fee9349f8f00d8ade44c840330ac54e
|
||||
("_logo", ".png", "halo-ce-logo.png", "7df3336e9988291552270764e80196b1892ab9d11b79d7d289e6243746d42dc8"))
|
||||
ICON_NAME = "halo-ce-icon.png"
|
||||
ICON_SHA256 = "184620356407e42a33877528c4f14b702df8b026415a99ed16976565fcfbce36"
|
||||
ICON_SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
|
||||
ICON_SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
|
||||
ARTWORK_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
||||
# Verified against the installed Steam client's library.js asset enum.
|
||||
# Shortcut icons have their own path setter and are not sent through this API.
|
||||
@@ -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:
|
||||
@@ -312,7 +325,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
|
||||
|
||||
|
||||
def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
|
||||
"""Remove only exact native title/path matches; keep all foreign fields."""
|
||||
"""Remove only entries that launch the native executable; keep all foreign fields."""
|
||||
root = loads(data)
|
||||
shortcuts = root.get("shortcuts")
|
||||
if shortcuts is None or shortcuts.kind != 0:
|
||||
@@ -322,10 +335,15 @@ def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
|
||||
if entry.kind != 0:
|
||||
raise ValueError("Unexpected Steam shortcut record; no changes were made.")
|
||||
fields = entry.value
|
||||
title = fields.get("AppName", fields.get("appname"))
|
||||
path = fields.get("Exe", fields.get("exe"))
|
||||
if (title is not None and title.kind == 1 and title.value == NAME
|
||||
and path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"')):
|
||||
# Ownership is the native executable, not the display title: Steam
|
||||
# lets users rename library entries, and the live client path treats
|
||||
# an entry pointing at the native executable as owned as well. Match
|
||||
# the path exactly, bare or quoted once, like the live client does.
|
||||
# A malformed exact-native record blocks the transaction: treating it
|
||||
# as absent would let uninstall delete the game while leaving its
|
||||
# shortcut behind. Validate every account before publishing changes.
|
||||
if path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"'):
|
||||
appid = fields.get("appid")
|
||||
if appid is None or appid.kind != 2 or not 2 ** 31 <= appid.value < 2 ** 32:
|
||||
raise ValueError("The native Halo shortcut has an invalid app ID; no changes were made.")
|
||||
@@ -377,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:
|
||||
@@ -395,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
|
||||
@@ -648,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"
|
||||
@@ -664,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():
|
||||
@@ -674,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")
|
||||
@@ -694,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):
|
||||
|
||||
@@ -9,7 +9,7 @@ import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
VERSION = "1.3.4"
|
||||
VERSION = "1.4.3"
|
||||
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
|
||||
|
||||
def allowed_source_files():
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
"""Offline checks of the pinned experimental engine and shipped local patch.
|
||||
|
||||
Reads a local vanilla/patched checkout. Applies the patch only to a temporary
|
||||
projection and generates the real ARM64 Ninja graph without fetching tools,
|
||||
compiling a game, using retail data, or connecting a headset.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
|
||||
PATCH_FILES = frozenset((
|
||||
"port/linux/src/vr.c", "port/linux/src/vr.h", "port/linux/src/vr_locomotion.h",
|
||||
"port/linux/src/port_config.c", "source/game/player_control.c", "tools/test_vr_locomotion.py",
|
||||
"port/linux/game/menu_functions.c", "port/linux/game/vr_render.c",
|
||||
"port/linux/src/d3d8_gl.c", "port/linux/src/nv2a_psh.c", "port/linux/src/xgpu.h",
|
||||
"source/rasterizer/xbox/rasterizer_xbox_lights.c",
|
||||
"tools/test_online_coop_menu.py", "tools/test_vr_sun_glow.py",
|
||||
"source/items/weapons.c", "source/items/weapons.h",
|
||||
"source/game/aim_assist.c", "source/game/aim_assist.h",
|
||||
"tools/test_vr_projectile_reticle.py",
|
||||
"port/assets/menus/ce/main_menu.multiplayer_type_select.coop.xml", "tools/port_settings.py",
|
||||
"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", "tools/test_vr_menu.py",
|
||||
"tools/android_build.py", "tools/test_build_sources.py",
|
||||
"port/android/guest/runtime/guest_host.h", "port/android/host/host_gl.c",
|
||||
"port/linux/arm64/host_imports.list", "port/linux/src/vr_vehicle.h",
|
||||
"source/interface/ui_widget.c", "source/interface/motion_sensor.c",
|
||||
"source/rasterizer/rasterizer_transparent_geometry.c",
|
||||
"port/linux/game/menu_tags.c", "tools/ce_menus.py",
|
||||
"port/assets/menus/ce/bitmaps.xml", "port/assets/menus/menus.json",
|
||||
"port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.vr_display.xml",
|
||||
"port/assets/menus/ce/port/pause/pausebox_left__2.png",
|
||||
"port/assets/menus/ce/port/pause/pausebox_center__2.png",
|
||||
"port/assets/menus/ce/port/pause/pausebox_right__2.png",
|
||||
"port/assets/menus/port_svg/pause/pausebox_left__2.svg",
|
||||
"port/assets/menus/port_svg/pause/pausebox_center__2.svg",
|
||||
"port/assets/menus/port_svg/pause/pausebox_right__2.svg",
|
||||
"tools/test_vr_pause_menu.py", "tools/test_vr_pointer_radar.py",
|
||||
"tools/test_persistent_stream_buffers.py", "tools/test_vr_vehicle.py",
|
||||
"tools/test_vr_cinematic_hands.py",
|
||||
"port/linux/src/vr_host.h", "source/render/render.c",
|
||||
"tools/test_vr_zoom_aim.py", "tools/test_vr_stock_zoom_hud.py",
|
||||
"port/linux/src/gl.h", "tools/test_vr_renderer_link.py",
|
||||
"port/linux/src/vr_driving.h", "tools/test_vr_driving.py", "tools/test_vr_driving_game.py",
|
||||
"port/linux/game/vr_hands.c", "port/linux/game/vr_hands.h",
|
||||
"tools/test_vr_framebuffer.py", "tools/test_vr_vehicle_pose.py",
|
||||
"source/interface/first_person_weapons.c", "source/interface/hud_weapon.c",
|
||||
))
|
||||
|
||||
|
||||
class EngineCheckError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise EngineCheckError(message)
|
||||
|
||||
|
||||
def git(source, *arguments, check=True):
|
||||
result = subprocess.run(["git", "-C", str(source), *arguments], capture_output=True, timeout=30)
|
||||
if check:
|
||||
require(result.returncode == 0, "A local Git source check failed; no engine files were changed.")
|
||||
return result
|
||||
|
||||
|
||||
def patch_files(text):
|
||||
files = re.findall(r"^diff --git a/(\S+) b/(\S+)$", text, re.M)
|
||||
require(bool(files), "The local engine patch has no ordinary file diffs.")
|
||||
for before, after in files:
|
||||
require(before == after and after in PATCH_FILES,
|
||||
"The engine patch changes a path outside the reviewed integration files.")
|
||||
return frozenset(after for _, after in files)
|
||||
|
||||
|
||||
def c_function(text, name):
|
||||
# Keep offsets while hiding comments and string contents from brace counting.
|
||||
comments = re.compile(r"/\*.*?\*/|//[^\n]*", re.S)
|
||||
clean = comments.sub(lambda match: " " * len(match.group()), text)
|
||||
masked = re.sub(r'"(?:\\.|[^"\\])*"', lambda match: " " * len(match.group()), clean)
|
||||
start = re.search(r"\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", masked)
|
||||
require(start is not None, "Missing engine function: " + name)
|
||||
depth = 1
|
||||
for offset in range(start.end(), len(masked)):
|
||||
depth += (masked[offset] == "{") - (masked[offset] == "}")
|
||||
if not depth:
|
||||
return clean[start.end():offset]
|
||||
raise EngineCheckError("Unterminated engine function: " + name)
|
||||
|
||||
|
||||
def check_texture_binding(text):
|
||||
body = c_function(text, "bind_textures")
|
||||
loops = list(re.finditer(r"for\s*\(stage\s*=\s*0;\s*stage\s*<\s*D3DTSS_MAXSTAGES;\s*stage\+\+\)", body))
|
||||
bindings = list(re.finditer(r"\bstate_texture\s*\(", body))
|
||||
require(len(loops) == 2 and len(bindings) == 1 and bindings[0].start() > loops[1].start(),
|
||||
"Texture binding is not delayed until the second complete stage pass.")
|
||||
resolve = body.find("xgpu_texture_get(")
|
||||
require(loops[0].start() < resolve < loops[1].start(),
|
||||
"Texture uploads must finish before the final stage-binding pass.")
|
||||
require("state_texture(stage, gl_targets[stage], gl_textures[stage])" in body,
|
||||
"Final texture bindings do not use the resolved per-stage arrays.")
|
||||
|
||||
|
||||
def check_upstream(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
limits = read("port/linux/include/halo_port_limits.h")
|
||||
require(re.search(r"^#define HALO_PORT_NETWORK_VERSION 17\s*$", limits, re.M),
|
||||
"The candidate must use native peer protocol 17.")
|
||||
check_texture_binding(read("port/linux/src/d3d8_gl.c"))
|
||||
shader = read("port/linux/src/nv2a_vsh.c")
|
||||
require("clip_position = oPos" in shader and "clip_captured = true" in shader
|
||||
and "if (!(abs(position.w) > 0.0))" in shader
|
||||
and "position = vec4(0.0, 0.0, 0.0, -1.0)" in shader,
|
||||
"The pinned vertex clip-position safeguards are missing.")
|
||||
distributed = read("port/linux/game/network_distributed.c")
|
||||
coop = read("port/linux/game/network_coop.c")
|
||||
for hook in ("network_coop_host_tick", "network_coop_client_tick", "network_coop_handle_presentation", "network_coop_handle_events"):
|
||||
require(hook + "(" in distributed and hook in coop, "Missing integrated co-op hook: " + hook)
|
||||
config = read("port/linux/src/port_config.c")
|
||||
require('"network.coop_enemies_mode"' in config and '"network.coop_enemies"' in config
|
||||
and '"network.brokers_file"' in config, "Co-op/broker configuration is missing.")
|
||||
require((source / "port/assets/network/brokers.txt").is_file(), "The candidate broker list is missing.")
|
||||
require("saved games of builds before it no longer load" in read("port/linux/include/halo_port_capacity.h"),
|
||||
"The candidate saved-game layout change needs explicit compatibility handling.")
|
||||
|
||||
|
||||
def check_controls(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
vr = read("port/linux/src/vr.c")
|
||||
config = read("port/linux/src/port_config.c")
|
||||
require(re.search(r'"vr\.movement",\s*_config_string,\s*"\\"head\\"",\s*"HALO_VR_MOVEMENT"', config),
|
||||
"Head-directed walking is not the configurable default.")
|
||||
movement = c_function(vr, "halo_vr_locomotion")
|
||||
for guard in ("!vr.initialized", "!settings.head_movement", "!settings.controller_aim", "settings.gamepad_aim"):
|
||||
require(guard in movement, "Missing unchanged-input mode guard: " + guard)
|
||||
for guard in ("vr.views.focused", "vr.views.head.valid", "vr.origin_valid", "vr.aim_synced", "!vr.menus", "halo_vr_locomotion_orientation"):
|
||||
require(guard in movement, "Missing valid-tracking locomotion guard: " + guard)
|
||||
require("halo_vr_locomotion_project(" in movement, "The tested production movement math is not wired into VR input.")
|
||||
player = read("source/game/player_control.c")
|
||||
handling = c_function(player, "handle_one_player_input")
|
||||
call = handling.find("halo_vr_locomotion(player->desired_angles.yaw")
|
||||
require(call >= 0 and handling.find("player->desired_angles.yaw = vr_yaw") < call
|
||||
< handling.find("player->throttle = input.throttle"),
|
||||
"Movement must be transformed after aiming and before the player/action throttle is stored.")
|
||||
guard = handling[max(0, call - 300):call]
|
||||
for condition in ("local_player_index == 0", "unit->object.parent_object_index == NONE", "!director_inhibited_facing", "!cinematic_in_progress"):
|
||||
require(condition in guard, "Missing local on-foot movement gate: " + condition)
|
||||
gamepad = c_function(vr, "halo_vr_gamepad")
|
||||
require("XINPUT_GAMEPAD_WHITE, buttons & VR_BUTTON_RIGHT_BUMPER" in gamepad
|
||||
and "XINPUT_GAMEPAD_BLACK, buttons & VR_BUTTON_LEFT_BUMPER" in gamepad,
|
||||
"The agreed Xbox bumper layout was not preserved.")
|
||||
require("halo_vr_tutorial_look(" in c_function(player, "player_control_action_test_note"),
|
||||
"The physical-head tutorial hook was not ported to the new co-op action test.")
|
||||
c_function(vr, "halo_vr_tutorial_look")
|
||||
require((source / "port/linux/src/vr_locomotion.h").is_file(), "The production locomotion header is missing.")
|
||||
|
||||
|
||||
def check_stereo_glow_and_online_menu(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
lights = read("source/rasterizer/xbox/rasterizer_xbox_lights.c")
|
||||
draw = c_function(lights, "rasterizer_sun_glow_draw")
|
||||
require("vr_render_sun_glow_begin(" in draw and "vr_render_sun_glow_end(" in draw,
|
||||
"The per-eye sun-glow wrapper is not integrated into the actual renderer.")
|
||||
require('config_real("vr.sun_glow_strength")' in draw
|
||||
and 'rasterizer_sun_glow_draw_internal(flare, 1.0f)' in draw,
|
||||
"The VR sun-strength option must preserve the original flat-screen composite.")
|
||||
strength = c_function(lights, "rasterizer_vr_sun_glow_strength")
|
||||
require("isfinite(configured)" in strength and "configured < 0.0" in strength
|
||||
and "configured > 1.0" in strength and "return 0.5f" in strength,
|
||||
"Sun-glow intensity must reject non-finite values and stay within 0–1.")
|
||||
require('"vr.sun_glow_strength", _config_real, "0.5"' in read("port/linux/src/port_config.c"),
|
||||
"The bounded half-strength sun-glow default is missing.")
|
||||
renderer = read("port/linux/game/vr_render.c")
|
||||
begin = c_function(renderer, "vr_render_sun_glow_begin")
|
||||
for integration in ("vr_render.eye_cameras[eye]", "vr_render.eyes[eye]", "halo_vr_screen_space(1)", "halo_vr_draw_eye(eye)"):
|
||||
require(integration in begin, "Missing per-eye glow state: " + integration)
|
||||
require("halo_vr_screen_space(0)" in c_function(renderer, "vr_render_sun_glow_end"),
|
||||
"Glow screen-space state is not restored.")
|
||||
backend = c_function(read("port/linux/src/d3d8_gl.c"), "prepare_draw")
|
||||
require(0 <= backend.find("key.sample_eye") < backend.find("fragment_shader_get(&key)"),
|
||||
"Source-eye selection must participate in the actual fragment cache key.")
|
||||
shader = read("port/linux/src/nv2a_psh.c")
|
||||
require("key->multiview == XGPU_MULTIVIEW_NONE && key->sample_eye" in shader,
|
||||
"A mono glow scratch target does not sample the selected stereo source eye.")
|
||||
menu = read("port/linux/game/menu_functions.c")
|
||||
route = c_function(menu, "cooperative_begin")
|
||||
routes = {
|
||||
"coop_host_lan": "cooperative_network_host(widget, event, controller, _multiplayer_mode_host_lan, widget_deleted)",
|
||||
"coop_host_online": "cooperative_network_host(widget, event, controller, _multiplayer_mode_host_internet, widget_deleted)",
|
||||
"coop_join_lan": "cooperative_network_join(widget, controller, _multiplayer_mode_lan, widget_deleted)",
|
||||
"coop_join_online": "cooperative_network_join(widget, controller, _multiplayer_mode_server_browser, widget_deleted)",
|
||||
"coop_join_invite": "cooperative_network_join(widget, controller, _multiplayer_mode_direct_link, widget_deleted)",
|
||||
}
|
||||
for name, call in routes.items():
|
||||
require(re.search(r'if \(!strcmp\(widget->name, "' + name + r'"\)\)\s*return ' + re.escape(call) + r';', route),
|
||||
"Missing LAN/Internet campaign dispatch: " + name)
|
||||
require("cooperative_session_cleanup();" in route and '"network_screen"' in route,
|
||||
"Leaving the campaign network chooser must clean up its pending session.")
|
||||
single_player = c_function(menu, "cooperative_single_player")
|
||||
for integration in ("player_spawn_count = 1", "player_ui_reset_single_player_local_player_controllers()",
|
||||
"lobby_join_reset()", "player_ui_local_player_left_multiplayer_game(controller)"):
|
||||
require(integration in single_player, "Missing one-player campaign reset: " + integration)
|
||||
host = c_function(menu, "cooperative_network_host")
|
||||
require('mode == _multiplayer_mode_host_internet && !config_boolean("network.online")' in host
|
||||
and "multiplayer_host_mode(widget, event, controller, mode, TRUE, widget_deleted)" in host
|
||||
and "cooperative_map_opening = TRUE" in host and "cooperative_map_opening = FALSE" in host,
|
||||
"Campaign hosts must distinguish offline LAN from Internet and open campaign maps.")
|
||||
join = c_function(menu, "cooperative_network_join")
|
||||
require('mode != _multiplayer_mode_lan && !config_boolean("network.online")' in join
|
||||
and "cooperative_network_game = TRUE" in join and "multiplayer.mode = mode" in join,
|
||||
"Campaign joins must distinguish LAN, Internet browser and invite modes.")
|
||||
handler = c_function(menu, "pc_menu_event_function_invoke")
|
||||
require(re.search(r'else if \(!strcmp\(name, "port coop begin"\)\)\s*\{\s*return cooperative_begin\(', handler),
|
||||
"Campaign dispatch must work in both flat and VR builds.")
|
||||
require(re.search(r'else if \(!strcmp\(name, "port coop player 2 list initialize"\)[^{]+\{\s*return campaign_fail\(\);', handler)
|
||||
and "player_spawn_count = 2;" not in menu and "vr_cooperative_begin" not in menu,
|
||||
"Stale split-screen campaign routes must be rejected.")
|
||||
for helper in ("lobby_add_player", "lobby_join_start", "lobby_player_choose", "preview_add"):
|
||||
body = c_function(menu, helper)
|
||||
require("lobby_campaign_single_player()" in body and "return campaign_local_player_block();" in body,
|
||||
"Campaign must block a second local player: " + helper)
|
||||
require("lobby_local_player_count() > 1" in c_function(menu, "map_list_choose")
|
||||
and "cooperative_single_player()" in c_function(menu, "multiplayer_game_player"),
|
||||
"Ordinary map/browser entry points must also use one local campaign player.")
|
||||
require('!strcmp(name, "gamespy back handler") && cooperative_network_game &&' in handler
|
||||
and "!global_network_game_server_get()" in handler,
|
||||
"Browser cancellation must preserve a host when returning from Server Setup.")
|
||||
asset = source / "port/assets/menus/ce/main_menu.multiplayer_type_select.coop.xml"
|
||||
nodes = {node.attrib["name"].rsplit("/", 1)[-1]: node for node in ET.parse(asset).getroot()}
|
||||
require("player_2_profile_screen" not in nodes and "player_2_profile_list" not in nodes,
|
||||
"The campaign menu must not open a second-player profile screen.")
|
||||
for event in ("a", "start"):
|
||||
item = nodes["multiplayer_type_coop_item"].find('on[@event="' + event + '"]')
|
||||
require(item is not None and item.get("run") == "port coop begin"
|
||||
and item.get("open", "").endswith("coop/network_screen"),
|
||||
"The campaign button must open the LAN/Internet chooser.")
|
||||
captions = {"coop_host_lan": "HOST LOCAL (LAN)", "coop_join_lan": "JOIN LOCAL (LAN)",
|
||||
"coop_host_online": "HOST ONLINE (INTERNET)", "coop_join_online": "JOIN ONLINE (INTERNET)",
|
||||
"coop_join_invite": "JOIN INVITE LINK"}
|
||||
for name, caption in captions.items():
|
||||
require(name in nodes and nodes[name].get("text") == caption, "Missing campaign menu choice: " + caption)
|
||||
for event in ("a", "start"):
|
||||
item = nodes[name].find('on[@event="' + event + '"]')
|
||||
require(item is not None and item.attrib == {"event": event, "run": "port coop begin"},
|
||||
"Campaign choices must dispatch their named route without a static widget fallback.")
|
||||
generator = read("tools/port_settings.py")
|
||||
generated_coop = generator.split("def _coop() -> list:", 1)[1].split("def _item_options_extras", 1)[0]
|
||||
require(all(name in generated_coop for name in captions) and "network_screen" in generated_coop
|
||||
and "player_2_profile_screen" not in generated_coop,
|
||||
"The menu generator must retain the network-only campaign choices.")
|
||||
for script in ("tools/test_vr_sun_glow.py", "tools/test_online_coop_menu.py"):
|
||||
require((source / script).is_file(), "Missing production C regression: " + script)
|
||||
|
||||
|
||||
def check_nominal_projectile_reticle(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
weapons = read("source/items/weapons.c")
|
||||
firing = c_function(weapons, "trigger_create_projectiles")
|
||||
require(re.search(r"trigger_adjust_projectile_ray\([^;]+&error,\s*FALSE,\s*NULL\)", firing),
|
||||
"Real shots must retain the shared firing calculation with target recording.")
|
||||
preview = c_function(weapons, "weapon_preview_projectile_ray")
|
||||
require(re.search(r"trigger_adjust_projectile_ray\([^;]+&error,\s*TRUE,\s*pose\)", preview),
|
||||
"The reticle must use the same pre-spread calculation in preview mode.")
|
||||
require(not re.search(r"\b(?:random|projectile_new|weapon_trigger_fire)\w*\s*\(", preview),
|
||||
"Reticle preview must not fire or consume shot randomness.")
|
||||
assist = read("source/game/aim_assist.c")
|
||||
require("player_aim_projectile_internal(player_index, position, direction, FALSE, pose)"
|
||||
in c_function(assist, "player_preview_projectile_aim"),
|
||||
"Reticle preview must suppress player targeting metadata writes.")
|
||||
require("player_aim_projectile_internal(player_index, position, direction, TRUE, NULL)"
|
||||
in c_function(assist, "player_aim_projectile"),
|
||||
"Actual shots must preserve player targeting metadata writes.")
|
||||
renderer = read("port/linux/game/vr_render.c")
|
||||
crosshairs = c_function(renderer, "vr_render_crosshairs")
|
||||
point = c_function(renderer, "vr_render_projectile_point")
|
||||
require("vr_render_projectile_point(" in crosshairs and "vr_render_projectile_ray(" in point
|
||||
and "_collision_test_for_projectiles_flags" in point,
|
||||
"The rendered reticle must trace the game's nominal projectile ray.")
|
||||
require("MAXIMUM_COLLISION_USER_STACK_DEPTH - 2" in point,
|
||||
"The reticle must reserve capacity for UI, aim-assist and nested line-of-sight contexts.")
|
||||
require("weapon_preview_projectile_ray(weapon_index, 0, player_index"
|
||||
in c_function(renderer, "vr_render_projectile_ray"),
|
||||
"The reticle must query the equipped primary weapon's actual firing ray.")
|
||||
require("crosshair_inverse_distance" not in crosshairs and "CROSSHAIR_SMOOTHING" not in crosshairs,
|
||||
"Reticle depth must not lag behind its current collision hit.")
|
||||
require((source / "tools/test_vr_projectile_reticle.py").is_file(),
|
||||
"The production projectile-reticle regression is missing.")
|
||||
pose = c_function(renderer, "vr_render_pointing_pose")
|
||||
for guard in ("local_player_get_player_index(0)", "_object_type_biped", "parent_object_index != NONE",
|
||||
"gunner_object_index != NONE", "_director_perspective_first_person", "director_inhibited_facing(0)",
|
||||
"aiming_velocity_maximum != 0.0f", "aiming_acceleration_maximum != 0.0f"):
|
||||
require(guard in pose, "Missing guarded render-rate preview state: " + guard)
|
||||
for integration in ("view->hand_forward", "player_control_get_facing_direction(0",
|
||||
"DEGREES_TO_RADIANS(85.0f)", "unit_clip_to_aiming_bounds(",
|
||||
"render_interpolation_camera(0, observer_get_camera(0))"):
|
||||
require(integration in pose, "Missing current-frame preview input: " + integration)
|
||||
require("hand_position" not in pose and "camera_effects" not in pose,
|
||||
"Preview origin must not add hand translation or apply camera effects again.")
|
||||
require("vr_render_pointing_pose(player_index, unit, &vr_render.view, vr_render.base_yaw, pose)"
|
||||
in c_function(renderer, "vr_render_projectile_pose"),
|
||||
"The equipped reticle must retain the current render pose and yaw.")
|
||||
unarmed = c_function(renderer, "vr_render_unarmed_ray")
|
||||
require("unit_get_weapon_count(unit_index) != 0" in unarmed
|
||||
and "_object_dead_bit" in unarmed and "unit->unit.player_index != player_index" in unarmed
|
||||
and "unit_inventory_get_weapon" in unarmed and "vr_render_pointing_pose(" in unarmed,
|
||||
"An unarmed reticle must be limited to a living owned player without an equipped weapon.")
|
||||
ray = c_function(renderer, "vr_render_projectile_ray")
|
||||
require("vr_render_projectile_pose(player_index, unit, &pose) ? &pose : NULL" in ray,
|
||||
"The current-frame pose is not integrated into the primary reticle preview.")
|
||||
units = read("source/units/units.c")
|
||||
require("adjust_origin, use_aiming_vector, NULL, NULL" in c_function(units, "unit_adjust_projectile_ray"),
|
||||
"Actual unit shots must not use presentation overrides.")
|
||||
require("unit_adjust_projectile_ray_internal(" in c_function(units, "unit_preview_projectile_ray"),
|
||||
"Unit preview must share the original camera/muzzle projection math.")
|
||||
|
||||
|
||||
def check_unarmed_flashlight(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
renderer = read("port/linux/game/vr_render.c")
|
||||
pose = c_function(renderer, "vr_render_player_flashlight_pose")
|
||||
for guard in ("!vr_render.active", "!vr_render.weapon_camera_set",
|
||||
"!vr_render.weapon_camera_valid", "render.local_player_index != 0",
|
||||
"director_camera_scripted && *director_camera_scripted",
|
||||
"!halo_vr_aiming()", "!halo_vr_gaze_tracking_valid()",
|
||||
"vr_render_unarmed_ray("):
|
||||
require(guard in pose, "Missing unarmed flashlight render-pose guard: " + guard)
|
||||
require("vr_render.weapon_camera.position" in pose and "vr_render.weapon_camera.up" in pose
|
||||
and "dot_product3d(" in pose and "normalize3d(" in pose
|
||||
and "halo_vr_view(" not in pose,
|
||||
"The flashlight must use the cached rendered hand pose with a perpendicular up vector.")
|
||||
lights = read("source/objects/object_lights.c")
|
||||
override = c_function(lights, "light_vr_unarmed_flashlight_pose")
|
||||
for guard in ("#ifdef HALO_VR", "light->parent_light_index == NONE",
|
||||
"light->object_index != NONE", "_point_light_dynamic_bit",
|
||||
"_light_definition_is_first_person_flashlight_bit",
|
||||
"vr_render_player_flashlight_pose("):
|
||||
require(guard in override, "Missing local flashlight light-definition guard: " + guard)
|
||||
bounds = c_function(lights, "light_compute_render_bounding_sphere")
|
||||
require("struct light_datum effective = *light_get(light_index)" in bounds
|
||||
and "&effective.position" in bounds and "&effective.forward" in bounds
|
||||
and "light_compute_bounding_sphere_from_basis(&effective" in bounds,
|
||||
"Render flashlight bounds must use a copy of the simulation light.")
|
||||
simulation = c_function(lights, "light_compute_bounding_sphere")
|
||||
reconnect = c_function(lights, "light_reconnect_to_map")
|
||||
require("light_compute_bounding_sphere_from_basis(light_get(light_index)" in simulation
|
||||
and "light_compute_render_bounding_sphere(" not in reconnect
|
||||
and "light_vr_unarmed_flashlight_pose(" not in reconnect,
|
||||
"Flashlight presentation must not move the simulation light's BSP partition.")
|
||||
include = c_function(lights, "lights_vr_include_unarmed_flashlights")
|
||||
require("light_unmarked(light_index)" in include and "object_get_function_value(" in include
|
||||
and "intensity <= 0.0f" in include and "slot < MAXIMUM_RENDERED_LIGHTS" in include
|
||||
and "MAXIMUM_RENDERED_LIGHTS - 1" in include,
|
||||
"Adding a local flashlight outside stale visibility must respect scene-list capacity.")
|
||||
preprocess = c_function(lights, "lights_preprocess_scene")
|
||||
require(0 <= preprocess.find("structure_visibility_find_objects(")
|
||||
< preprocess.find("lights_vr_include_unarmed_flashlights()")
|
||||
< preprocess.find("light_marker_end()"),
|
||||
"The current flashlight must be included before finishing scene-light visibility.")
|
||||
require("light_vr_unarmed_flashlight_pose(light, &light_parameters.position" in preprocess
|
||||
and "first_person_weapon_center_flashlight(" in preprocess,
|
||||
"The local unarmed override must be integrated without replacing armed flashlight markers.")
|
||||
for name in ("light_get_bounding_sphere", "lights_render_diffuse", "lights_render_specular"):
|
||||
require("light_compute_render_bounding_sphere(" in c_function(lights, name),
|
||||
"Flashlight visibility and drawing must share rendered bounds: " + name)
|
||||
for name in ("lights_render_diffuse", "lights_render_specular"):
|
||||
require("light_render_skips_clusters(light)" in c_function(lights, name),
|
||||
"The unarmed beam must not use stale tick cluster restrictions: " + name)
|
||||
require((source / "tools/test_vr_flashlight.py").is_file(),
|
||||
"Missing production C flashlight regression.")
|
||||
|
||||
|
||||
def check_tutorial(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
player = read("source/game/player_control.c")
|
||||
require("halo_vr_turn_control(" in c_function(player, "handle_one_player_input")
|
||||
and "halo_vr_tutorial_reset(" in c_function(player, "player_control_action_test_reset")
|
||||
and "halo_vr_tutorial_turn(" in c_function(player, "player_control_action_test_note"),
|
||||
"The script action tests must share actual VR turns and reset their head anchor.")
|
||||
buttons = c_function(player, "player_control_action_test_check_reset_input_blob")
|
||||
require("halo_vr_running()" in buttons and "halo_vr_active" not in buttons
|
||||
and "vr_controls" in buttons
|
||||
and "_unit_control_use_equipment_bit, FALSE" in c_function(player, "player_control_action_test_check_reset_input_blob"),
|
||||
"A consumed VR tutorial Back button must suppress its mapped melee action.")
|
||||
vr = read("port/linux/src/vr.c")
|
||||
c_function(vr, "halo_vr_running")
|
||||
require("int halo_vr_running(void);" in read("port/linux/src/vr.h"),
|
||||
"The tutorial VR-running query must use the real engine API.")
|
||||
units = c_function(read("source/units/units.c"), "unit_can_see_point")
|
||||
require("vr_render_player_gaze(" in units and "local_player_index == 0" in units
|
||||
and "_object_dead_bit" in units,
|
||||
"Headset gaze must be limited to the living local primary player.")
|
||||
hs = read("source/hs/hs_library_external.c")
|
||||
panel = c_function(hs, "hs_vr_calibration_panel")
|
||||
require("degrees != 5.f" in panel and "levels\\\\a10\\\\a10" in panel
|
||||
and "_object_type_scenery" in panel,
|
||||
"Hand-reticle acceptance must retain the original named a10 panel gate.")
|
||||
require("hs_vr_calibration_panel(" in c_function(hs, "hs_unit_can_see_object")
|
||||
and "vr_render_player_pointing_ray(" in c_function(hs, "hs_unit_can_see_object"),
|
||||
"The unarmed presentation ray must be integrated into the panel check.")
|
||||
locate = c_function(read("port/linux/arm64/host_vr.c"), "host_vr_locate")
|
||||
require(0 <= locate.find("views->head.valid = 0") < locate.find("if (!vr.frame_begun)")
|
||||
and "XR_VIEW_STATE_POSITION_VALID_BIT" in locate
|
||||
and "XR_SPACE_LOCATION_POSITION_VALID_BIT" in locate,
|
||||
"Fresh full-pose queries must invalidate stale data before any failure return.")
|
||||
for script in ("tools/test_vr_tutorial.py", "tools/test_vr_tracking.py", "tools/test_vr_tutorial_buttons.py"):
|
||||
require((source / script).is_file(), "Missing tutorial production C regression: " + script)
|
||||
|
||||
|
||||
def check_private_menu_and_vehicle_integration(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
config = read("port/linux/src/port_config.c")
|
||||
for key, default in (("vr.menu_pointer", "true"), ("vr.radar_head_heading", "true"),
|
||||
("vr.vehicle_seat_calibration", "true"), ("vr.vehicle_clear_windshield", "true"),
|
||||
("display.persistent_stream_buffers", "false")):
|
||||
require(re.search(re.escape('"' + key + '"') + r',\s*_config_boolean,\s*"' + default + r'"', config),
|
||||
"Missing reviewed private option/default: " + key)
|
||||
tags = read("port/linux/game/menu_tags.c")
|
||||
loaded = c_function(tags, "menu_tags_loaded")
|
||||
require("halo_vr_running() && tag_loaded(UI_WIDGET_DEFINITION_TAG, SOLO_PAUSE_SCREEN) != NONE" in loaded
|
||||
and "build.multiplayer_map = game_map && multiplayer_map" in loaded,
|
||||
"Pause VR Setup must use the exact campaign root and distinguish network-map pause flags.")
|
||||
require("if (build.multiplayer_map)" in c_function(tags, "widget_build")
|
||||
and "pause_vr_widget()" in c_function(tags, "pause_patch")
|
||||
and '"VR SETUP", NONE' in c_function(tags, "pause_list_patch"),
|
||||
"Pause VR Setup must reuse existing widgets without a profile-edit or network function.")
|
||||
ui = read("source/interface/ui_widget.c")
|
||||
require("!network_coop_active()" in c_function(ui, "widget_instance_initialize")
|
||||
and "vr_render_menu_pointer_draw()" in c_function(ui, "render_ui_widgets"),
|
||||
"Pause settings must retain co-op clock policy and render the native UI pointer.")
|
||||
vr = read("port/linux/src/vr.c")
|
||||
pointer = c_function(vr, "halo_vr_menu_pointer")
|
||||
require("menu_pointer_intersection(" in pointer and "menu_pointer_button(" in pointer
|
||||
and "!vr.pending_layers.hud_head_locked" in pointer,
|
||||
"The tracked pointer must intersect the fixed rendered menu panel.")
|
||||
backend = read("port/linux/src/d3d8_gl.c")
|
||||
desktop_pointer = c_function(backend[backend.rfind("int halo_ui_pointer_update"):], "halo_ui_pointer_update")
|
||||
require("halo_vr_menu_pointer(menus_active, pointer)" in desktop_pointer,
|
||||
"VR pointing must feed the existing native widget input path.")
|
||||
renderer = read("port/linux/game/vr_render.c")
|
||||
require("vr_render_player_gaze(" in c_function(renderer, "vr_render_motion_sensor_yaw"),
|
||||
"Optional head-heading radar must use the current rendered HMD gaze.")
|
||||
radar = c_function(read("source/interface/motion_sensor.c"), "render_motion_sensor")
|
||||
require("vr_render_motion_sensor_yaw(" in radar and "draw_sensor = *sensor" in radar
|
||||
and "render_sensor = &draw_sensor" in radar,
|
||||
"Radar head heading must alter a draw copy, preserving tick/history data.")
|
||||
display = ET.parse(source / "port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.vr_display.xml")
|
||||
require(any(node.get("setting") == "display.persistent_stream_buffers"
|
||||
and node.get("values") == "true|false" for node in display.getroot()),
|
||||
"The existing VR Display screen must expose the optional buffer-streaming setting.")
|
||||
require("halo_vr_vehicle_reference(" in c_function(renderer, "vr_render_vehicle_reference")
|
||||
and "vr_render_vehicle_reference(" in c_function(renderer, "vr_render_camera")
|
||||
and "vr_render_vehicle_reference(" in c_function(read("source/game/player_control.c"), "handle_one_player_input"),
|
||||
"Seated reference calibration must be integrated into both current view and player input.")
|
||||
require("halo_vr_vehicle_update(" in c_function(vr, "halo_vr_vehicle_reference")
|
||||
and "vehicle_capture()" in c_function(vr, "recentre")
|
||||
and (source / "port/linux/src/vr_vehicle.h").is_file(),
|
||||
"Vehicle entry/recenter must use its separate production tracking reference.")
|
||||
require("menu_level_orientation(" in c_function(vr, "menu_place"),
|
||||
"Menu placement must remove head roll while preserving the gaze direction.")
|
||||
seat_camera = c_function(renderer, "vr_render_vehicle_camera_position")
|
||||
require("object_get_marker_by_name(" in seat_camera and "isfinite(" in seat_camera
|
||||
and "vr_render_vehicle_camera_position(" in c_function(renderer, "vr_render_player_gaze")
|
||||
and "vr_render_vehicle_camera_position(" in c_function(renderer, "vr_render_camera"),
|
||||
"The local vehicle view and gaze must share a checked seat anchor.")
|
||||
presentation = c_function(renderer, "vr_render_controller_presentation")
|
||||
require("halo_vr_presentation_tracking_valid()" in presentation
|
||||
and "_director_perspective_first_person" in presentation
|
||||
and "_object_dead_bit" in presentation
|
||||
and "vr_render_controller_presentation()" in c_function(renderer, "vr_render_weapon_camera"),
|
||||
"Controller presentation must remain available in a visible first-person arrival rig.")
|
||||
tracking = c_function(vr, "halo_vr_presentation_tracking_valid")
|
||||
require("vr.views.focused" in tracking and "vr.views.head.valid" in tracking
|
||||
and "isfinite(" in tracking and "vr.aim_synced" not in tracking,
|
||||
"Presentation must require current finite tracking without enabling gameplay aim.")
|
||||
glass = c_function(renderer, "vr_render_hides_vehicle_glass")
|
||||
require('"vehicles\\\\warthog\\\\warthog"' in glass and "seated_unit()" in glass
|
||||
and "object_index != unit->object.parent_object_index" in glass,
|
||||
"The windshield override must be limited to the occupied stock Warthog.")
|
||||
transparent = c_function(read("source/rasterizer/rasterizer_transparent_geometry.c"),
|
||||
"rasterizer_transparent_geometry_draw")
|
||||
require("_shader_type_transparent_glass" in transparent and "vr_render_hides_vehicle_glass(" in transparent
|
||||
and re.search(r"transparent_geometry_group_index\+\+;\s*continue;", transparent),
|
||||
"The local glass override must advance the existing draw queue without changing retail tags.")
|
||||
for script in ("tools/test_vr_pause_menu.py", "tools/test_vr_pointer_radar.py", "tools/test_vr_vehicle.py",
|
||||
"tools/test_vr_cinematic_hands.py"):
|
||||
require((source / script).is_file(), "Missing private production C regression: " + script)
|
||||
|
||||
|
||||
def check_menu_controller_visibility(source):
|
||||
"""Verify the shipped menu/tracking fix reaches the actual draw paths."""
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
vr = read("port/linux/src/vr.c")
|
||||
view = c_function(vr, "halo_vr_view")
|
||||
require("int controller_aim_requested;" in read("port/linux/src/vr.h")
|
||||
and "view->controller_aim_requested = settings.controller_aim" in view,
|
||||
"Controller aim mode must remain distinct from current pose validity.")
|
||||
require(view.find("view->controller_aim_requested = settings.controller_aim") < view.find("host_vr_locate("),
|
||||
"Late tracking failure must retain the selected controller aim mode.")
|
||||
renderer = read("port/linux/game/vr_render.c")
|
||||
visible = c_function(renderer, "vr_render_first_person_visible")
|
||||
crosshairs = c_function(renderer, "vr_render_hud_crosshairs_visible")
|
||||
for body in (visible, crosshairs):
|
||||
require("halo_vr_frame_active()" in body and "controller_aim_requested" in body,
|
||||
"Controller visibility must distinguish native VR mode from flat/gamepad/head paths.")
|
||||
require("ui_widgets_active_for_local_player(" in body or "vr_render_gameplay_hud_visible(" in body,
|
||||
"Controller visibility must read current menu state before a stale input flag.")
|
||||
update = c_function(read("source/interface/first_person_weapons.c"), "first_person_weapon_render_update")
|
||||
require("vr_render_first_person_visible(render.local_player_index)" in update
|
||||
and update.find("vr_render_first_person_visible(") < update.find("first_person_weapon_set_visibility("),
|
||||
"The first-person rig must apply visibility before building or drawing head-camera nodes.")
|
||||
hud = c_function(read("source/interface/hud_weapon.c"), "crosshairs_draw")
|
||||
require("vr_render_hud_crosshairs_visible(" in hud
|
||||
and hud.find("vr_render_hud_crosshairs_visible(") < hud.find("TEST_FLAG(weapon_hud_globals->script_flags"),
|
||||
"The ordinary HUD crosshair fallback must share the controller-mode visibility guard.")
|
||||
|
||||
|
||||
def check_optional_native_buffer_streaming(source):
|
||||
read = lambda path: (source / path).read_text(encoding="utf-8")
|
||||
backend = read("port/linux/src/d3d8_gl.c")
|
||||
require("#if defined(HALO_ARM64_GUEST) && !defined(HALO_GLES)\n#define XGPU_PERSISTENT_STREAMS" in backend,
|
||||
"Persistent streaming must remain limited to the native desktop-GL guest.")
|
||||
initialize = c_function(backend, "stream_buffers_initialize")
|
||||
require('config_boolean("display.persistent_stream_buffers")' in initialize
|
||||
and "host_gl_buffer_create_persistent(" in initialize
|
||||
and "host_gl_buffer_destroy_persistent(" in initialize,
|
||||
"Optional streaming must initialize transactionally and retain the ordinary-buffer fallback.")
|
||||
frame = c_function(backend, "frame_end_buffers")
|
||||
require("host_gl_wait_frame_checked(" in frame and "stream_ring_reset(ring_ready)" in frame,
|
||||
"Mapped ring reuse must be gated by a completed host fence.")
|
||||
reset = c_function(backend, "stream_ring_reset")
|
||||
require("if (!ready)" in reset and "device.persistent_ring[ring] = FALSE" in reset
|
||||
and "glGenBuffers(" in reset,
|
||||
"An unfinished mapped slot must retire its mappings before mutable reuse.")
|
||||
host = read("port/android/host/host_gl.c")
|
||||
functions = ("host_gl_buffer_create_persistent", "host_gl_buffer_destroy_persistent",
|
||||
"host_gl_buffer_write_persistent", "host_gl_wait_frame_checked")
|
||||
imports = set(read("port/linux/arm64/host_imports.list").splitlines())
|
||||
declarations = read("port/android/guest/runtime/guest_host.h") + read("port/linux/src/xgpu.h")
|
||||
for function in functions:
|
||||
c_function(host, function)
|
||||
require(function in imports and function + "(" in declarations,
|
||||
"Missing real host definition/import/declaration: " + function)
|
||||
create = c_function(host, "host_gl_buffer_create_persistent")
|
||||
write = c_function(host, "host_gl_buffer_write_persistent")
|
||||
require("#ifdef HALO_DESKTOP_GL" in create and "GL_ARB_buffer_storage" in create
|
||||
and "glBufferStorage" in create and "glDeleteBuffers(" in create,
|
||||
"Persistent allocation must check desktop capability and delete a failed immutable allocation.")
|
||||
require("size > persistent_buffers[slot].size - offset" in write and "size && !data" in write,
|
||||
"Host-owned mapped writes must enforce the buffer's recorded range and non-null data.")
|
||||
require((source / "tools/test_persistent_stream_buffers.py").is_file(),
|
||||
"Missing production host/guest buffer-streaming regression.")
|
||||
|
||||
|
||||
GRAPH_SMOKE = r'''
|
||||
import io
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
import re
|
||||
sys.path.insert(0, str(Path.cwd()))
|
||||
from tools import linux_arm64_build as build
|
||||
from tools.ninja_syntax import Writer
|
||||
headers = Path(".offline-check-headers")
|
||||
(headers / "GLES3").mkdir(parents=True)
|
||||
(headers / "GLES3/gl32.h").write_text("/* graph check only */")
|
||||
build.GL_HEADERS = headers
|
||||
build.is_linux_arm64 = lambda: True
|
||||
build.fetch_third_party = lambda directory: None
|
||||
sln = SimpleNamespace(port_release=True, port_vr=True, linux_arm64_cc="clang", port_pgo="off", port_lto="off")
|
||||
output = io.StringIO()
|
||||
build.generate_linux_arm64_build(Writer(output), sln)
|
||||
graph = output.getvalue()
|
||||
# pathlib follows the checker host even though this is a Linux build graph.
|
||||
# Normalize only the inspected text; retain the actual generated graph below.
|
||||
inspected_graph = re.sub(r"\$\r?\n[ \t]*", "", graph).replace("\\", "/")
|
||||
for required in ("port/linux/game/network_coop.c", "port/linux/game/coop_scripts.c", "port/linux/game/coop_enemies.c", "port/linux/src/vr.c", "port/linux/arm64/host_vr.c", "port/linux/game/menu_tags.c", "source/interface/ui_widget.c", "source/interface/motion_sensor.c", "source/rasterizer/rasterizer_transparent_geometry.c", "port/android/host/host_gl.c", "-DHALO_VR=1", "-iquote port/linux/game", "build/linux_arm64/brokers.txt", "port/assets/network/brokers.txt"):
|
||||
if required not in inspected_graph:
|
||||
raise RuntimeError("The ARM64 graph omitted " + required)
|
||||
if "-DHALO_GLES=1" in graph:
|
||||
raise RuntimeError("The VR guest graph selected GLES instead of desktop OpenGL")
|
||||
Path(".offline-check-build.ninja").write_text(graph, encoding="utf-8")
|
||||
print("ARM64/OpenXR guest, host, co-op and broker graph generated without downloads or compilation.")
|
||||
'''
|
||||
|
||||
|
||||
def project_source(source, destination):
|
||||
archive = git(source, "archive", "--format=tar", SOURCE_COMMIT).stdout
|
||||
require(len(archive) <= 256 * 1024 * 1024, "The source archive exceeds the offline check limit.")
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as entries:
|
||||
for entry in entries:
|
||||
name = PurePosixPath(entry.name)
|
||||
require(not name.is_absolute() and ".." not in name.parts and (entry.isdir() or entry.isfile()),
|
||||
"The source projection contains an unexpected path or link.")
|
||||
target = destination.joinpath(*name.parts)
|
||||
if entry.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with entries.extractfile(entry) as incoming, target.open("wb") as outgoing:
|
||||
outgoing.write(incoming.read())
|
||||
|
||||
|
||||
def build_recipe_cli_options(candidate: Path, visited=None):
|
||||
"""Read declared options, including a local test's delegated main parser."""
|
||||
visited = set() if visited is None else visited
|
||||
if candidate in visited:
|
||||
return set()
|
||||
require(len(visited) < 32, "Native test CLI delegation exceeds the source check limit.")
|
||||
visited.add(candidate)
|
||||
try:
|
||||
tree = ast.parse(candidate.read_text(encoding="utf-8"), filename=str(candidate))
|
||||
except SyntaxError as error:
|
||||
raise EngineCheckError("A native build test has invalid Python syntax: " + candidate.name) from error
|
||||
options = {argument.value for node in ast.walk(tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"
|
||||
for argument in node.args if isinstance(argument, ast.Constant)
|
||||
and isinstance(argument.value, str) and argument.value.startswith("--")}
|
||||
called_main = {node.func.value.id for node in ast.walk(tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute) and node.func.attr == "main"
|
||||
and isinstance(node.func.value, ast.Name)}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Import):
|
||||
continue
|
||||
for imported in node.names:
|
||||
if (imported.asname or imported.name) not in called_main:
|
||||
continue
|
||||
delegate = candidate.parent / (imported.name + ".py")
|
||||
if delegate.is_file() and not delegate.is_symlink():
|
||||
options.update(build_recipe_cli_options(delegate, visited))
|
||||
return options
|
||||
|
||||
|
||||
def check_build_recipe(source: Path, recipe: str | None = None):
|
||||
"""Check the actual container commands against the canonical patched tree."""
|
||||
recipe = recipe if recipe is not None else (ROOT / "resources/build-native.sh").read_text(encoding="utf-8")
|
||||
require("set -euo pipefail" in recipe, "The native build recipe must stop on failed checks.")
|
||||
commands = []
|
||||
for line in recipe.splitlines():
|
||||
if re.match(r"^\s*python3\s", line):
|
||||
try:
|
||||
argv = shlex.split(line, comments=True)
|
||||
except ValueError as error:
|
||||
raise EngineCheckError("An invalid Python command appears in the native build recipe.") from error
|
||||
require(len(argv) >= 2 and all(token not in (";", "||", "&&", "|") for token in argv),
|
||||
"The native build recipe contains an unsupported Python command.")
|
||||
commands.append(argv)
|
||||
tests = [argv for argv in commands if argv[1].startswith("tools/test_")]
|
||||
expected = {path for path in PATCH_FILES if path.startswith("tools/test_") and path.endswith(".py")}
|
||||
actual = [argv[1] for argv in tests]
|
||||
require(len(actual) == len(set(actual)), "The native build recipe repeats a regression test.")
|
||||
require(set(actual) == expected,
|
||||
"The native build recipe does not match the shipped engine tests: " +
|
||||
", ".join(sorted(set(actual) ^ expected)))
|
||||
for argv in commands:
|
||||
path = PurePosixPath(argv[1])
|
||||
require(not path.is_absolute() and ".." not in path.parts and path.suffix == ".py",
|
||||
"The native build recipe references an unsafe script path.")
|
||||
candidate = source.joinpath(*path.parts)
|
||||
require(candidate.is_file() and not candidate.is_symlink() and candidate.resolve().is_relative_to(source.resolve()),
|
||||
"The native build recipe references a missing script: " + argv[1])
|
||||
if argv in tests:
|
||||
options = build_recipe_cli_options(candidate)
|
||||
require(all(argument in options for argument in argv[2:] if argument.startswith("--")),
|
||||
"The native build recipe passes an unsupported option to " + argv[1])
|
||||
if argv[1] != "tools/test_build_sources.py":
|
||||
require("--cc" in argv and argv[argv.index("--cc") + 1:argv.index("--cc") + 2] == ["clang"],
|
||||
"The native build recipe must use the installed compiler for " + argv[1])
|
||||
configure = [argv for argv in commands if argv[1] == "configure.py"]
|
||||
require(configure == [["python3", "configure.py", "--release", "--vr", "--linux-arm64-cc", "clang"]],
|
||||
"The native build recipe must configure the release ARM64 VR target exactly once.")
|
||||
require(commands[-1] == configure[0], "Native regression checks must complete before configuration.")
|
||||
require(re.search(r"^ninja\s+-j4\s+linux_arm64\s*$", recipe, re.M),
|
||||
"The native build recipe must build the configured ARM64 target.")
|
||||
return actual
|
||||
|
||||
|
||||
def check_source(source: Path):
|
||||
source = source.resolve()
|
||||
require(git(source, "rev-parse", "HEAD").stdout.decode().strip() == SOURCE_COMMIT,
|
||||
"Use the exact pinned experimental engine commit; a moving branch is not accepted.")
|
||||
patch = ROOT / "resources/frame-controls.patch"
|
||||
changed = patch_files(patch.read_text(encoding="utf-8"))
|
||||
dirty = git(source, "diff", "--name-only", "HEAD").stdout.decode().splitlines()
|
||||
untracked = git(source, "ls-files", "--others", "--exclude-standard").stdout.decode().splitlines()
|
||||
require(set(dirty + untracked) <= changed, "The engine checkout has changes outside the reviewed local patch.")
|
||||
vanilla = git(source, "apply", "--check", str(patch), check=False).returncode == 0
|
||||
patched = git(source, "apply", "--reverse", "--check", str(patch), check=False).returncode == 0
|
||||
require(vanilla or patched, "The local patch neither applies cleanly nor exactly reverses in this checkout.")
|
||||
require(not vanilla or not dirty + untracked, "A vanilla source input must have no local engine changes.")
|
||||
# Always test a clean canonical projection, so a patched input is not trusted
|
||||
# merely because its HEAD still names the selected revision.
|
||||
(ROOT / "build").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="experimental-engine-check-", dir=ROOT / "build") as temporary:
|
||||
projection = Path(temporary)
|
||||
project_source(source, projection)
|
||||
check_upstream(projection)
|
||||
subprocess.run(["git", "init", "--quiet"], cwd=projection, check=True, capture_output=True, timeout=30)
|
||||
for arguments in (("--check",), ()):
|
||||
result = subprocess.run(["git", "apply", *arguments, str(patch)], cwd=projection,
|
||||
capture_output=True, timeout=30)
|
||||
require(result.returncode == 0, "The local patch does not apply to the canonical pinned engine source.")
|
||||
if patched:
|
||||
for path in changed:
|
||||
actual, expected = (source / path).read_bytes(), (projection / path).read_bytes()
|
||||
if not path.endswith(".png"):
|
||||
actual, expected = actual.replace(b"\r\n", b"\n"), expected.replace(b"\r\n", b"\n")
|
||||
require(actual == expected,
|
||||
"The patched input contains an extra change beyond the shipped local patch: " + path)
|
||||
check_controls(projection)
|
||||
check_stereo_glow_and_online_menu(projection)
|
||||
check_nominal_projectile_reticle(projection)
|
||||
check_tutorial(projection)
|
||||
check_unarmed_flashlight(projection)
|
||||
check_private_menu_and_vehicle_integration(projection)
|
||||
check_menu_controller_visibility(projection)
|
||||
check_optional_native_buffer_streaming(projection)
|
||||
check_build_recipe(projection)
|
||||
result = subprocess.run([sys.executable, "-I", "-c", GRAPH_SMOKE], cwd=projection,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
require(result.returncode == 0, "Offline ARM64 configuration generation failed: " + result.stderr[-2000:])
|
||||
return {"ok": True, "sourceCommit": SOURCE_COMMIT, "workingTree": "vanilla" if vanilla else "patched",
|
||||
"protocolVersion": 17, "rendererTwoPassBinding": True, "campaignCoopIntegration": True,
|
||||
"headDirectedMovementIntegration": True, "xboxControlsPreserved": True,
|
||||
"stereoSunGlowIntegration": True, "boundedSunGlowStrength": True, "vrInternetCampaignMenu": True,
|
||||
"nominalProjectileReticleIntegration": True, "renderRateUnarmedFlashlightIntegration": True,
|
||||
"renderRateProjectilePreviewIntegration": True,
|
||||
"networkCampaignOnlyIntegration": True,
|
||||
"vrTutorialIntegration": True,
|
||||
"freshFullPoseTrackingIntegration": True,
|
||||
"pauseVRSetupIntegration": True, "trackedVRMenuPointerIntegration": True,
|
||||
"menuControllerRigVisibility": True, "controllerReticleFallbackSuppressed": True,
|
||||
"renderOnlyHeadRadarIntegration": True, "seatedVehicleReferenceIntegration": True,
|
||||
"localWarthogGlassIntegration": True, "optionalNativePersistentBuffersIntegration": True,
|
||||
"buildRecipeIntegration": True,
|
||||
"offlineArm64NinjaGraph": True, "arm64LinkedBuild": False, "hardwareValidated": False}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path, help="Local exact-pinned vanilla or fully patched engine checkout")
|
||||
arguments = parser.parse_args()
|
||||
try:
|
||||
result = check_source(arguments.source)
|
||||
except (EngineCheckError, OSError, subprocess.SubprocessError) as error:
|
||||
print(json.dumps({"ok": False, "error": str(error)}))
|
||||
return 1
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Fully link the shipped native ARM64 VR build without retail game data.
|
||||
|
||||
Run as a regular aarch64 Linux user with rootless Podman. The source pin,
|
||||
container arguments, local patch and build script come from the installer.
|
||||
Only a JSON build proof is exported; the temporary game binary is discarded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAX_BUILD_SECONDS = 45 * 60
|
||||
MAX_EXECUTABLE_BYTES = 1024 ** 3
|
||||
|
||||
|
||||
class NativeBuildError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def load_helper():
|
||||
spec = importlib.util.spec_from_file_location("native_check_remote_helper", ROOT / "resources/remote_install.py")
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = helper
|
||||
spec.loader.exec_module(helper)
|
||||
if (not re.fullmatch(r"[a-f0-9]{40}", helper.SOURCE_COMMIT)
|
||||
or helper.SOURCE_URL != "https://github.com/startupfoundry/halo-ce-universal.git"):
|
||||
raise NativeBuildError("The installer source URL or exact revision is unsupported by this check.")
|
||||
return helper
|
||||
|
||||
|
||||
def remaining(deadline, maximum=None):
|
||||
seconds = deadline - time.monotonic()
|
||||
if seconds <= 0:
|
||||
raise NativeBuildError("The native build check exceeded its time limit.")
|
||||
return min(seconds, maximum) if maximum is not None else seconds
|
||||
|
||||
|
||||
def command(arguments, deadline, maximum=300):
|
||||
return subprocess.run(arguments, check=True, text=True, stdout=subprocess.PIPE,
|
||||
timeout=remaining(deadline, maximum)).stdout.strip()
|
||||
|
||||
|
||||
def regular_file(path, parent):
|
||||
"""Refuse links or files outside this check's private build workspace."""
|
||||
path = Path(path)
|
||||
relative = path.relative_to(parent)
|
||||
if ".." in relative.parts:
|
||||
raise NativeBuildError("The native output path escapes the private workspace.")
|
||||
cursor = parent
|
||||
if parent.is_symlink() or parent.resolve() != parent:
|
||||
raise NativeBuildError("The native build workspace is not an ordinary private directory.")
|
||||
for component in relative.parts:
|
||||
cursor /= component
|
||||
if cursor.is_symlink():
|
||||
raise NativeBuildError("A native build output path contains a symbolic link.")
|
||||
details = path.stat()
|
||||
if (not stat.S_ISREG(details.st_mode) or details.st_nlink != 1
|
||||
or details.st_uid != os.getuid() or not 64 <= details.st_size <= MAX_EXECUTABLE_BYTES):
|
||||
raise NativeBuildError("The native build did not produce a private ordinary output file.")
|
||||
return details
|
||||
|
||||
|
||||
def native_elf_proof(path, parent):
|
||||
details = regular_file(path, parent)
|
||||
checksum = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(64)
|
||||
if (len(header) != 64 or header[:6] != b"\x7fELF\x02\x01"
|
||||
or struct.unpack_from("<H", header, 16)[0] not in (2, 3)
|
||||
or struct.unpack_from("<H", header, 18)[0] != 183):
|
||||
raise NativeBuildError("The output is not a little-endian ELF64 AArch64 executable/library.")
|
||||
checksum.update(header)
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
checksum.update(block)
|
||||
if path.stat().st_size != details.st_size:
|
||||
raise NativeBuildError("The native output changed during verification.")
|
||||
return {"bytes": details.st_size, "sha256": checksum.hexdigest(),
|
||||
"elfMachine": 183, "elfClass": "ELF64", "byteOrder": "little", "architecture": "aarch64"}
|
||||
|
||||
|
||||
def stop_owned_container(helper, identifier):
|
||||
"""Stop only this invocation's labeled container, never shared containers."""
|
||||
name = "halo-frame-installer-" + identifier
|
||||
inspected = subprocess.run(["podman", "container", "inspect", name], text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
|
||||
if inspected.returncode:
|
||||
# --rm may already have removed it, or the image pull never created it.
|
||||
return
|
||||
records = json.loads(inspected.stdout)
|
||||
if not isinstance(records, list) or len(records) != 1 or not isinstance(records[0], dict):
|
||||
raise NativeBuildError("Cannot verify the native check container before cleanup.")
|
||||
record = records[0]
|
||||
config = record.get("Config")
|
||||
labels = config.get("Labels") if isinstance(config, dict) else None
|
||||
container_id = record.get("Id")
|
||||
if (not isinstance(labels, dict) or labels.get("org.halo-frame-installer.owner") != helper.OWNER
|
||||
or labels.get("org.halo-frame-installer.run") != identifier
|
||||
or not isinstance(container_id, str) or not re.fullmatch(r"[a-f0-9]{64}", container_id)):
|
||||
raise NativeBuildError("Refusing to stop a container without this check's exact ownership labels.")
|
||||
# The immutable inspected ID avoids a name being reassigned during cleanup.
|
||||
subprocess.run(["podman", "stop", "--time", "10", container_id],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
|
||||
subprocess.run(["podman", "rm", "--force", container_id],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
|
||||
|
||||
def build_container(helper, identifier, directory, deadline):
|
||||
args = helper.build_container_args(identifier, directory)
|
||||
process = subprocess.Popen(args, start_new_session=True)
|
||||
try:
|
||||
status = process.wait(timeout=remaining(deadline))
|
||||
if status:
|
||||
raise NativeBuildError(f"The shipped native ARM64 VR build failed (exit {status}).")
|
||||
except BaseException:
|
||||
try:
|
||||
stop_owned_container(helper, identifier)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
print("Owned container cleanup: " + str(error), file=sys.stderr, flush=True)
|
||||
finally:
|
||||
# Includes a still-running image pull before a container exists.
|
||||
helper.stop_build_process(process)
|
||||
raise
|
||||
|
||||
|
||||
def write_proof(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as stream:
|
||||
json.dump(value, stream, indent=2)
|
||||
stream.write("\n")
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--report", type=Path, default=ROOT / "native-build-proof.json")
|
||||
parser.add_argument("--timeout-seconds", type=int, default=MAX_BUILD_SECONDS)
|
||||
options = parser.parse_args(argv)
|
||||
if not 1 <= options.timeout_seconds <= MAX_BUILD_SECONDS:
|
||||
parser.error("--timeout-seconds must be between 1 and 2700 (45 minutes)")
|
||||
if sys.platform != "linux" or os.getuid() == 0:
|
||||
raise NativeBuildError("Run this check as a regular Linux user with rootless Podman.")
|
||||
if platform.machine().lower() not in ("aarch64", "arm64"):
|
||||
raise NativeBuildError("This full native build check requires an AArch64 Linux runner.")
|
||||
if any(shutil.which(tool) is None for tool in ("git", "podman")):
|
||||
raise NativeBuildError("The native build check requires Git and rootless Podman.")
|
||||
report = options.report.absolute()
|
||||
if report.suffix.lower() != ".json" or report.exists() or report.is_symlink():
|
||||
raise NativeBuildError("Choose a new JSON proof path so a stale report cannot imply build success.")
|
||||
started = time.monotonic()
|
||||
deadline = started + options.timeout_seconds
|
||||
helper = load_helper()
|
||||
info = json.loads(command(["podman", "info", "--format", "json"], deadline, 60))
|
||||
if not info.get("host", {}).get("security", {}).get("rootless", False):
|
||||
raise NativeBuildError("This check requires rootless Podman.")
|
||||
patch = ROOT / "resources/frame-controls.patch"
|
||||
script = ROOT / "resources/build-native.sh"
|
||||
patch_bytes, script_bytes = patch.read_bytes(), script.read_bytes()
|
||||
helper_sha = hashlib.sha256((ROOT / "resources/remote_install.py").read_bytes()).hexdigest()
|
||||
identifier = uuid.uuid4().hex
|
||||
print("Full native ARM64 VR link check: " + helper.SOURCE_COMMIT, flush=True)
|
||||
with tempfile.TemporaryDirectory(prefix="halo-native-link-check-") as scratch:
|
||||
directory = Path(scratch).resolve()
|
||||
source = directory / "src"
|
||||
command(["git", "init", str(source)], deadline, 30)
|
||||
command(["git", "-C", str(source), "remote", "add", "origin", helper.SOURCE_URL], deadline, 30)
|
||||
command(["git", "-C", str(source), "fetch", "--depth", "1", "origin", helper.SOURCE_COMMIT], deadline)
|
||||
command(["git", "-C", str(source), "checkout", "--detach", "FETCH_HEAD"], deadline, 30)
|
||||
if command(["git", "-C", str(source), "rev-parse", "HEAD"], deadline, 30) != helper.SOURCE_COMMIT:
|
||||
raise NativeBuildError("The fetched native engine does not match the shipped source pin.")
|
||||
staged_patch = directory / "frame-controls.patch"
|
||||
staged_patch.write_bytes(patch_bytes)
|
||||
command(["git", "-C", str(source), "apply", "--check", str(staged_patch)], deadline, 30)
|
||||
command(["git", "-C", str(source), "apply", str(staged_patch)], deadline, 30)
|
||||
(directory / "build-native.sh").write_bytes(script_bytes)
|
||||
build_container(helper, identifier, directory, deadline)
|
||||
binary = native_elf_proof(source / "build/linux_arm64/halo", directory)
|
||||
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,
|
||||
"releaseBuild": True, "fullNativeLink": True, "rootless": True,
|
||||
"hostArchitecture": platform.machine(), "nativeExecutable": binary,
|
||||
"sdlLibrary": library, "elapsedSeconds": round(time.monotonic() - started, 1),
|
||||
"hardwareValidated": False, "retailGameDataUsed": False}
|
||||
write_proof(report, proof)
|
||||
print(json.dumps(proof, separators=(",", ":")), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def interrupted(signum, frame):
|
||||
raise NativeBuildError("The native build check was interrupted; stopping its owned container.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (NativeBuildError, OSError, ValueError, subprocess.SubprocessError, KeyboardInterrupt) as error:
|
||||
print("Native build check failed: " + str(error), file=sys.stderr, flush=True)
|
||||
raise SystemExit(1)
|
||||
+11
-13
@@ -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,2 +1,2 @@
|
||||
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
|
||||
__version__ = "1.3.4"
|
||||
__version__ = "1.4.3"
|
||||
+295
-70
@@ -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,
|
||||
@@ -25,7 +25,12 @@ from .ui import (BG, SIDEBAR, SURFACE, TEXT, MUTED, BORDER,
|
||||
SETUP_URL = "https://partner.steamgames.com/doc/steamhardware/steamframe/setup"
|
||||
PLATFORM_TOOLS_URL = "https://developer.android.com/tools/releases/platform-tools"
|
||||
SOURCE_URL = "https://github.com/OpenCommunityEdition/OpenCE/pull/85"
|
||||
APP_TITLE = "Halo • Community Steam Frame VR Mod Installer"
|
||||
APP_TITLE = "Halo • Steam Frame VR Mod Installer"
|
||||
EXPERIMENTAL_NOTICE = (
|
||||
"LAN/online campaign, head-directed walking, and render-rate reticle.\n"
|
||||
"Upgrades the native game; old saves are kept. Older checkpoints cannot be loaded.\n"
|
||||
"Campaign uses one player per machine; co-op partners need matching builds."
|
||||
)
|
||||
CONTENT_DISCLOSURE = "No retail game executable, ISO, maps, product keys, or soundtrack is provided."
|
||||
ARTWORK_CREDIT = "Halo artwork is included. Halo artwork and trademarks belong to Microsoft."
|
||||
XBOX_REVISION_NOTE = "Original Xbox USA Rev 2 validated; other retail revisions are checked automatically."
|
||||
@@ -39,7 +44,7 @@ STEAM_REMOVE_NOTE = ("Use Steam's Remove Non-Steam Game option for Halo: Combat
|
||||
CONTROLS = (
|
||||
"A Jump / accept B Melee / back X Reload / use Y Change weapon\n"
|
||||
"RT Fire LT Grenade LB Change grenade RB Flashlight\n"
|
||||
"Left stick Move / click to crouch Right stick Turn / click to zoom\n"
|
||||
"Left stick Move by head direction / click to crouch Right stick Turn / click to zoom\n"
|
||||
"Menu Pause View Scoreboard Both grips Recenter"
|
||||
)
|
||||
|
||||
@@ -77,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
|
||||
@@ -111,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)
|
||||
@@ -135,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,
|
||||
@@ -316,8 +340,8 @@ class App(tk.Tk):
|
||||
choices.columnconfigure((0, 1, 2), weight=1, uniform="operations")
|
||||
self.mode_choices = []
|
||||
for index, title, subtitle, value in (
|
||||
(0, "Install", "New native game", "install"),
|
||||
(1, "Repair", "Keep saves", "repair"),
|
||||
(0, "Install", "Install or upgrade", "install"),
|
||||
(1, "Repair", "Refresh files", "repair"),
|
||||
(2, "Uninstall", "Remove game", "uninstall")):
|
||||
choice = Choice(choices, title, subtitle, self.mode, value, self._mode_changed)
|
||||
choice.grid(row=0, column=index, sticky="ew",
|
||||
@@ -376,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")
|
||||
@@ -463,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)
|
||||
@@ -481,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")
|
||||
@@ -505,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))
|
||||
|
||||
@@ -570,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:
|
||||
@@ -583,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()
|
||||
@@ -596,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):
|
||||
@@ -609,7 +728,7 @@ class App(tk.Tk):
|
||||
titles = ("UNINSTALL HALO VR" if uninstall else "GAME DATA", "CONNECT FRAME",
|
||||
"UNINSTALL HALO VR" if uninstall else "INSTALL HALO VR")
|
||||
subtitles = ("Choose your save preference before removing the native game." if uninstall else
|
||||
"Install, repair, or uninstall the community VR mod.",
|
||||
"Upgrades the native game; older checkpoints cannot load.\nCo-op uses one player per machine and matching builds.",
|
||||
"Prepare SSH, then connect to your Steam Frame.",
|
||||
"Review the native game and saves before removing them." if uninstall else
|
||||
"Community VR mod. Xbox controls. Your Steam library.")
|
||||
@@ -714,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()
|
||||
@@ -831,10 +951,11 @@ 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.",
|
||||
"• Online play uses the native port's own multiplayer protocol.\n\n" + EXPERIMENTAL_NOTICE,
|
||||
10, background=SURFACE, wraplength=530).pack(anchor="w")
|
||||
links = self._card(body, "Project references")
|
||||
Button(links, "Steam Frame SSH guide", lambda:webbrowser.open(SETUP_URL),
|
||||
@@ -844,7 +965,7 @@ class App(tk.Tk):
|
||||
Button(links, "Native VR source", lambda:webbrowser.open(SOURCE_URL),
|
||||
subtle=True, background=SURFACE).pack(anchor="w")
|
||||
Button(links, "Installer project & documentation",
|
||||
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloCE-SteamFrame"),
|
||||
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloSteamFrameMod"),
|
||||
subtle=True, background=SURFACE).pack(anchor="w")
|
||||
|
||||
def _redact(self, text):
|
||||
@@ -917,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
|
||||
@@ -929,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()
|
||||
@@ -957,10 +1084,13 @@ 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"
|
||||
or steam.get("artwork", {}).get("icon", {}).get("status") == "manual"
|
||||
or steam.get("notes", {}).get("status") == "manual")
|
||||
allow_steam_retry = self.last_result is not None and pending_steam and self.mode.get() != "uninstall"
|
||||
self.retry_button.configure(state="normal" if not value and allow_steam_retry else "disabled")
|
||||
@@ -981,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)
|
||||
@@ -1037,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"
|
||||
@@ -1076,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",))
|
||||
@@ -1090,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
|
||||
@@ -1109,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",))
|
||||
@@ -1129,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):
|
||||
@@ -1152,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",))
|
||||
@@ -1173,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":
|
||||
@@ -1184,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:
|
||||
@@ -1197,25 +1369,33 @@ 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}")
|
||||
elif kind == "complete":
|
||||
result = event[1]
|
||||
self.last_result = result
|
||||
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
|
||||
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:
|
||||
@@ -1227,7 +1407,16 @@ class App(tk.Tk):
|
||||
artwork = result.steam.get("artwork", {})
|
||||
artwork_pending = artwork.get("status") == "manual"
|
||||
icon = artwork.get("icon", {})
|
||||
icon_pending = icon.get("status") == "manual"
|
||||
# The shortcut icon is cosmetic. Keep its actual result in
|
||||
# activity without making a successful game operation pending.
|
||||
if icon:
|
||||
self._append("Steam shortcut icon (optional): " + str(icon.get("status", "unknown")))
|
||||
if icon.get("reason"):
|
||||
self._append("Steam shortcut icon details: " + icon["reason"])
|
||||
if icon.get("instructions"):
|
||||
self._append("Steam shortcut icon instructions: " + icon["instructions"])
|
||||
elif icon.get("status") == "manual":
|
||||
self._append("Steam shortcut icon instructions: Click Add to Steam again to retry the library icon.")
|
||||
notes = result.steam.get("notes", {})
|
||||
if notes:
|
||||
# The copyable fallback remains safe after the worker clears
|
||||
@@ -1236,17 +1425,14 @@ class App(tk.Tk):
|
||||
for key, value in notes.items()}
|
||||
result.steam["notes"] = notes
|
||||
notes_pending = notes.get("status") == "manual"
|
||||
info_pending = artwork_pending or icon_pending or notes_pending
|
||||
info_pending = artwork_pending or notes_pending
|
||||
if library_update and info_pending and not result.requires_manual_steam_step:
|
||||
done = "Native Halo VR was kept; some Steam library info needs one more step. Your game and saves are unchanged."
|
||||
self.progress_state.finish(pending=result.requires_manual_steam_step or info_pending)
|
||||
self.progress_state.finish(pending=result.requires_manual_steam_step or info_pending or cleanup_pending)
|
||||
self._display_progress()
|
||||
if artwork_pending:
|
||||
done += "\n\nHalo library art needs one more step.\n" + artwork.get("reason", "")
|
||||
done += "\n" + artwork.get("instructions", STEAM_ART_NOTE)
|
||||
if icon_pending:
|
||||
done += "\n\nHalo's library icon needs one more step.\n" + icon.get("reason", "")
|
||||
done += "\n" + icon.get("instructions", "Click Add to Steam again to retry the library icon.")
|
||||
if notes_pending:
|
||||
done += "\n\nThe game description in Steam Notes needs one more step.\n"
|
||||
done += notes.get("message", STEAM_NOTES_NOTE)
|
||||
@@ -1254,34 +1440,53 @@ class App(tk.Tk):
|
||||
done += "\nClick View game description to copy the note."
|
||||
elif notes.get("status") == "custom":
|
||||
done += "\n\nYour existing custom game note was kept."
|
||||
if info_pending:
|
||||
if cleanup_pending:
|
||||
done += "\n\nThe game operation finished; connection cleanup needs attention.\n" + cleanup_warning
|
||||
if info_pending or result.requires_manual_steam_step:
|
||||
self.retry_button.pack(anchor="w", pady=(11, 0))
|
||||
else:
|
||||
self.retry_button.pack_forget()
|
||||
done = self._redact(done)
|
||||
self.phase.set("One more step in Steam" if result.requires_manual_steam_step
|
||||
else "Ready to play · Steam info pending" if info_pending
|
||||
else "Connection cleanup needs attention" if cleanup_pending
|
||||
else "Steam info updated" if library_update
|
||||
else "You're ready to play")
|
||||
else "Complete")
|
||||
self.status.set(done)
|
||||
self._append(done)
|
||||
extra = "" if library_update else "\n\nXbox controls and motion aiming are enabled. The native port uses its own multiplayer protocol; legacy PC servers and PC saves are incompatible."
|
||||
messagebox.showinfo("Steam info updated" if library_update and not info_pending and not result.requires_manual_steam_step
|
||||
messagebox.showinfo("Connection cleanup needs attention" if cleanup_pending
|
||||
else "Steam info updated" if library_update and not info_pending and not result.requires_manual_steam_step
|
||||
else "Steam info needs attention" if library_update else "Setup complete", done + extra, parent=self)
|
||||
elif kind == "uninstall_complete":
|
||||
result = event[1]
|
||||
self.last_result = None
|
||||
self.last_uninstall_result = result
|
||||
pending = bool(getattr(result, "requires_manual_steam_step", False))
|
||||
steam_pending = bool(getattr(result, "requires_manual_steam_step", False))
|
||||
removal_pending = bool(getattr(result, "removal_pending", False))
|
||||
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
|
||||
pending = steam_pending or removal_pending or bool(cleanup_warning)
|
||||
self.progress_state.finish(pending=pending)
|
||||
self._display_progress()
|
||||
if pending:
|
||||
if steam_pending:
|
||||
done = "Uninstall needs one more step in Steam.\n" + result.steam.get("reason", "")
|
||||
done += "\n" + result.steam.get("instructions", STEAM_REMOVE_NOTE)
|
||||
elif removal_pending:
|
||||
done = "Some files from an earlier uninstall were kept for manual review. Removal is not complete."
|
||||
elif result.already_absent:
|
||||
done = "Native Halo VR was already absent. Uninstall checks are complete."
|
||||
else:
|
||||
done = "Native Halo VR has been uninstalled from your Frame."
|
||||
if result.saved_backup_path:
|
||||
done += "\n\nCampaign save backup: " + result.saved_backup_path
|
||||
if getattr(result, "removal_warning", None):
|
||||
done += "\n\n" + result.removal_warning
|
||||
for backup in getattr(result, "recovered_save_backup_paths", ()):
|
||||
done += "\nEarlier campaign save backup: " + backup
|
||||
for retained in getattr(result, "retained_quarantine_paths", ()):
|
||||
done += "\nPreserved uninstall folder: " + retained
|
||||
if cleanup_warning:
|
||||
done += "\n\nConnection cleanup needs attention.\n" + cleanup_warning
|
||||
done = self._redact(done)
|
||||
self.phase.set("Uninstall needs attention" if pending else "Halo VR uninstalled")
|
||||
self.status.set(done)
|
||||
@@ -1289,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":
|
||||
@@ -1302,10 +1507,30 @@ 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)
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
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
|
||||
remote_unverified = "may still be running" in text.lower()
|
||||
if "an installer build is still running" in text.lower():
|
||||
self.status.set("An earlier build is still running. Wake the Frame and finish or cancel that build first.")
|
||||
self.phase.set("Earlier build active")
|
||||
elif remote_unverified:
|
||||
self.status.set("Setup status is unverified. Wake the Frame and check setup activity before retrying.")
|
||||
self.phase.set("Connection needs attention")
|
||||
else:
|
||||
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":
|
||||
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
|
||||
elif getattr(self, "operation", None) == "data":
|
||||
|
||||
@@ -8,14 +8,15 @@ import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable
|
||||
|
||||
from .ssh import CancelledError, Settings, SSHConnection, SSHError
|
||||
from .ssh import CancelledError, RemoteTimeoutError, Settings, SSHConnection, SSHError
|
||||
|
||||
SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
|
||||
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
|
||||
EXPECTED_MAPS = frozenset(name + ".map" for name in (
|
||||
"a10", "a30", "a50", "b30", "b40", "c10", "c20", "c40", "d20", "d40",
|
||||
"beavercreek", "bloodgulch", "boardingaction", "carousel", "chillout", "damnation",
|
||||
@@ -29,6 +30,108 @@ 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 run_remote_source(connection, source: str, arguments=(), **kwargs) -> str:
|
||||
"""Send bundled Python through stdin, keeping the SSH command bounded.
|
||||
|
||||
Linux limits each argument, including SSH's shell command, to roughly
|
||||
128 KiB. The combined installer helper can exceed that limit. Check the
|
||||
complete payload before executing it so a dropped connection cannot run
|
||||
a syntactically valid but truncated source file.
|
||||
"""
|
||||
payload = source.encode("utf-8")
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
bootstrap = (
|
||||
"import sys,hashlib\n"
|
||||
f"_hfi_source=sys.stdin.buffer.read({len(payload) + 1})\n"
|
||||
f"if len(_hfi_source)!={len(payload)} or hashlib.sha256(_hfi_source).hexdigest()!={digest!r}:\n"
|
||||
" raise SystemExit('HFI_ERROR Setup helper transfer was incomplete. Reconnect and retry.')\n"
|
||||
"exec(compile(_hfi_source,'<bundled setup helper>','exec'),globals())\n")
|
||||
return connection.run(["python3", "-c", bootstrap, *arguments],
|
||||
input_data=payload, **kwargs)
|
||||
|
||||
|
||||
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 +192,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
|
||||
@@ -98,6 +209,12 @@ class InstallResult:
|
||||
source_commit: str = SOURCE_COMMIT
|
||||
repaired: bool = False
|
||||
backup_path: str | None = None
|
||||
cleanup_warning: str | None = None
|
||||
storage_id: str = "internal"
|
||||
|
||||
@property
|
||||
def connection_cleanup_pending(self) -> bool:
|
||||
return bool(self.cleanup_warning)
|
||||
|
||||
@property
|
||||
def steam_appid(self) -> int | None:
|
||||
@@ -116,17 +233,192 @@ class UninstallResult:
|
||||
saved_backup_path: str | None
|
||||
steam: dict
|
||||
host_fingerprint: str | None
|
||||
cleanup_warning: str | None = None
|
||||
removal_pending: bool = False
|
||||
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:
|
||||
return bool(self.cleanup_warning)
|
||||
|
||||
@property
|
||||
def requires_manual_steam_step(self) -> bool:
|
||||
return self.steam.get("status") not in ("removed", "already-absent")
|
||||
|
||||
|
||||
def cleanup_activity(progress: ProgressCallback, message: str) -> None:
|
||||
try:
|
||||
progress("detail", message, None)
|
||||
except Exception:
|
||||
pass # A display callback cannot replace the operation outcome.
|
||||
|
||||
|
||||
def close_connection(connection, progress: ProgressCallback) -> str | None:
|
||||
"""Preserve the operation result, while keeping required cleanup visible."""
|
||||
try:
|
||||
connection.close()
|
||||
except Exception as error:
|
||||
settings = getattr(connection, "settings", None)
|
||||
warning = (str(error)[:4000] if isinstance(error, SSHError) else
|
||||
"Setup could not verify that its connection finished closing.")
|
||||
password = getattr(settings, "password", "")
|
||||
if password:
|
||||
warning = warning.replace(password, "[redacted]")
|
||||
action = ("Unplug the USB cable to end setup's temporary connection before retrying."
|
||||
if getattr(settings, "transport", None) == "usb" else
|
||||
"Close this installer before retrying the connection.")
|
||||
warning = warning + "\n" + action
|
||||
cleanup_activity(progress, warning)
|
||||
return warning
|
||||
return 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 = str(PurePosixPath(game_path).parent) + "/"
|
||||
backups, retained = [], []
|
||||
for key, target in (("recoveredSaveBackupPaths", backups),
|
||||
("retainedQuarantinePaths", retained)):
|
||||
values = data.get(key, [])
|
||||
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) > 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":
|
||||
valid = re.fullmatch(re.escape(root) + r"HaloCENativeVR-saves-[a-f0-9]{32}", value)
|
||||
else:
|
||||
prefix = root + ".HaloCENativeVR-uninstall-"
|
||||
suffix = value[len(prefix):] if value.startswith(prefix) else ""
|
||||
valid = suffix and not any(c in "/\\" or ord(c) < 32 or ord(c) == 127 for c in suffix)
|
||||
if not valid:
|
||||
raise SSHError("The Frame returned an invalid uninstall recovery history.")
|
||||
target.append(value)
|
||||
warning = data.get("warning")
|
||||
if warning is not None and (not isinstance(warning, str) or not 0 < len(warning) <= 4096):
|
||||
raise SSHError("The Frame returned an invalid uninstall warning.")
|
||||
return tuple(backups), tuple(retained), warning
|
||||
|
||||
|
||||
class Installer:
|
||||
def __init__(self, connection_factory=SSHConnection, resource_dir: Path | None = None):
|
||||
self.connection_factory = connection_factory
|
||||
self.resource_dir = resource_dir or resources_path()
|
||||
|
||||
def remote_source(self) -> str:
|
||||
"""Bootstrap the bundled storage validator before the installer 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")
|
||||
arguments = 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(run_remote_source(
|
||||
connection, inline, arguments, 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(run_remote_source(connection, 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:
|
||||
@@ -137,7 +429,10 @@ class Installer:
|
||||
run_identifier = uuid.uuid4().hex
|
||||
remote_helper: str | None = None
|
||||
prepared = False
|
||||
disconnected = False
|
||||
cleanup_attempted = False
|
||||
cancel_attempted = False
|
||||
cancellation_warning = None
|
||||
cancellation_unconfirmed = False
|
||||
|
||||
def check_cancel():
|
||||
if cancel_event.is_set():
|
||||
@@ -149,8 +444,55 @@ class Installer:
|
||||
progress(*activity)
|
||||
|
||||
def cancel_remote():
|
||||
if remote_helper is not None and prepared:
|
||||
connection.run(["python3", remote_helper, "cancel", "--run-id", run_identifier], timeout=45)
|
||||
nonlocal cancel_attempted, cancellation_warning, cancellation_unconfirmed
|
||||
if remote_helper is None or not prepared or cancel_attempted:
|
||||
return
|
||||
cancel_attempted = True
|
||||
argv = ["python3", remote_helper, "cancel", "--run-id", run_identifier] + storage_arguments(settings)
|
||||
|
||||
def request(target):
|
||||
response = parse_result(target.run(argv, timeout=45))
|
||||
if response.get("cancelled") is not True:
|
||||
raise SSHError("The Frame did not confirm this installer run's cancellation.")
|
||||
|
||||
try:
|
||||
active = getattr(connection, "is_active", lambda: True)()
|
||||
if active:
|
||||
request(connection)
|
||||
cleanup_activity(progress, "Remote build cancellation confirmed.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
recovery = recovery_settings = None
|
||||
try:
|
||||
approved = (connection.host_fingerprint or settings.known_host_fingerprint
|
||||
or settings.known_host_fingerprints.get(settings.host_identity))
|
||||
if not isinstance(approved, str) or not approved.startswith("SHA256:"):
|
||||
raise SSHError("No approved host fingerprint is available for recovery.")
|
||||
recovery_settings = replace(settings, known_host_fingerprint=approved,
|
||||
known_host_fingerprints={}, accept_host_key=None)
|
||||
recovery = self.connection_factory(recovery_settings)
|
||||
if recovery is connection:
|
||||
recovery = None
|
||||
raise SSHError("A fresh recovery connection could not be created.")
|
||||
cleanup_activity(progress, "Reconnecting once to stop this installer run, using the approved SSH host key...")
|
||||
recovery.connect(timeout=10)
|
||||
request(recovery)
|
||||
cleanup_activity(progress, "Remote build cancellation confirmed.")
|
||||
except Exception:
|
||||
cancellation_unconfirmed = True
|
||||
cancellation_warning = (
|
||||
"Remote stopping could not be confirmed. The installer build may still be running on the Frame. "
|
||||
"Restore the connection and stop this run before retrying installation or uninstalling.\n"
|
||||
"In the Frame's terminal, run: " + " ".join(argv))
|
||||
cleanup_activity(progress, cancellation_warning)
|
||||
finally:
|
||||
if recovery is not None:
|
||||
warning = close_connection(recovery, progress)
|
||||
if warning:
|
||||
cancellation_warning = (cancellation_warning + "\n\n" if cancellation_warning else "") + warning
|
||||
if recovery_settings is not None:
|
||||
recovery_settings.password = ""
|
||||
|
||||
repair = settings.reinstall_existing and not registration_only
|
||||
|
||||
@@ -163,6 +505,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))
|
||||
@@ -174,33 +517,40 @@ 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")
|
||||
preflight_args = ["python3", "-c", source, "preflight-library" if registration_only else "preflight"]
|
||||
source = self.remote_source()
|
||||
preflight_args = ["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")
|
||||
info = parse_result(connection.run(preflight_args,
|
||||
preflight_args += storage_arguments(settings)
|
||||
info = parse_result(run_remote_source(connection, source, 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"):
|
||||
if registration_only:
|
||||
raise SSHError("Use Install to upgrade the existing native game to version 1.4 before updating its Steam information.")
|
||||
repair = True
|
||||
progress("upgrade", "Upgrading the existing native game to version 1.4. Previous checkpoints stay in place; new saves use save-v1.4...", None)
|
||||
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:
|
||||
@@ -225,7 +575,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)
|
||||
@@ -248,36 +598,52 @@ 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)
|
||||
# Keep the result's public fingerprint before releasing the client.
|
||||
host_fingerprint = connection.host_fingerprint
|
||||
progress("disconnect", "Closing the setup SSH connection...", None)
|
||||
connection.close()
|
||||
disconnected = True
|
||||
progress("disconnected", "Setup has disconnected from your Frame.", None)
|
||||
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)
|
||||
cleanup_warning = close_connection(connection, progress)
|
||||
cleanup_attempted = True
|
||||
if cleanup_warning:
|
||||
progress("cleanup-pending", "Native Halo VR is installed; setup's connection cleanup needs attention.", 99)
|
||||
else:
|
||||
progress("disconnected", "Setup has disconnected from your Frame.", None)
|
||||
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"))
|
||||
except CancelledError:
|
||||
try:
|
||||
cancel_remote()
|
||||
except (SSHError, OSError):
|
||||
pass
|
||||
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")
|
||||
message = first + " Remote stopping could not be confirmed; the build may still be running on the Frame." + (separator + rest if separator else "")
|
||||
error.args = (message + "\n\n" + cancellation_warning,)
|
||||
raise
|
||||
finally:
|
||||
if not disconnected:
|
||||
progress("detail", "Closing the setup SSH connection...", None)
|
||||
connection.close()
|
||||
progress("detail", "Setup has disconnected from your Frame.", None)
|
||||
if not cleanup_attempted:
|
||||
cleanup_activity(progress, "Closing the setup SSH connection...")
|
||||
warning = close_connection(connection, progress)
|
||||
if warning is None:
|
||||
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,
|
||||
cancel_event: threading.Event | None = None) -> InstallResult:
|
||||
@@ -291,7 +657,7 @@ class Installer:
|
||||
cancel_event = cancel_event or threading.Event()
|
||||
connection = self.connection_factory(settings)
|
||||
run_identifier = uuid.uuid4().hex
|
||||
disconnected = False
|
||||
cleanup_attempted = False
|
||||
|
||||
def check_cancel():
|
||||
if cancel_event.is_set():
|
||||
@@ -308,21 +674,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(run_remote_source(connection, 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
|
||||
@@ -332,31 +698,56 @@ 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, 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":
|
||||
raise SSHError(steam.get("reason", "Steam could not close safely; the game was kept.")
|
||||
+ "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated."))
|
||||
diagnostic = steam.get("reason", "Steam could not close safely; the game was kept.") + "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated.")
|
||||
if warning:
|
||||
diagnostic += "\n\n" + warning
|
||||
if recovered:
|
||||
diagnostic += "\n\nRecovered campaign save backups:\n" + "\n".join(recovered)
|
||||
if retained:
|
||||
diagnostic += "\n\nPreserved uninstall folders:\n" + "\n".join(retained)
|
||||
raise SSHError(diagnostic)
|
||||
uninstalled, absent = data.get("uninstalled"), data.get("alreadyAbsent", False)
|
||||
pending = data.get("removalPending", False)
|
||||
saved = data.get("savedBackupPath")
|
||||
if (data.get("gamePath") != info["gamePath"] or type(uninstalled) is not bool
|
||||
or type(absent) is not bool or not (uninstalled or absent)
|
||||
or type(absent) is not bool or type(pending) is not bool
|
||||
or (pending and (uninstalled or absent))
|
||||
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.")
|
||||
warning = data.get("warning")
|
||||
if isinstance(warning, str) and 0 < len(warning) <= 4096:
|
||||
progress("detail", warning, None)
|
||||
host_fingerprint = connection.host_fingerprint
|
||||
progress("disconnect", "Closing the setup SSH connection...", None)
|
||||
connection.close()
|
||||
disconnected = True
|
||||
progress("disconnected", "Setup has disconnected from your Frame.", None)
|
||||
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
|
||||
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint)
|
||||
cleanup_warning = close_connection(connection, progress)
|
||||
cleanup_attempted = True
|
||||
if cleanup_warning:
|
||||
progress("cleanup-pending", "Uninstall results were saved; setup's connection cleanup needs attention.", 99)
|
||||
else:
|
||||
progress("disconnected", "Setup has disconnected from your Frame.", None)
|
||||
if pending:
|
||||
progress("removal-pending", "Uninstall preserved folders that need attention. See the setup activity and recovery paths.", 99)
|
||||
elif not cleanup_warning:
|
||||
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
|
||||
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, storage_id=settings.storage_id)
|
||||
finally:
|
||||
if not disconnected:
|
||||
progress("detail", "Closing the setup SSH connection...", None)
|
||||
connection.close()
|
||||
progress("detail", "Setup has disconnected from your Frame.", None)
|
||||
if not cleanup_attempted:
|
||||
cleanup_activity(progress, "Closing the setup SSH connection...")
|
||||
warning = close_connection(connection, progress)
|
||||
if warning is None:
|
||||
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 run(settings: Settings, maps_dir: Path | None, progress_callback=None,
|
||||
|
||||
@@ -30,6 +30,8 @@ PHASES = {
|
||||
"steam": (96, 99, "Updating Halo's Steam library information"),
|
||||
"disconnect": (99, 99, "Closing the setup connection"),
|
||||
"disconnected": (99, 99, "Disconnected from your Frame"),
|
||||
"cleanup-pending": (99, 99, "Connection cleanup needs attention"),
|
||||
"removal-pending": (99, 99, "Uninstall needs attention"),
|
||||
"complete": (99, 99, "Finishing setup"),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import subprocess
|
||||
def smoke_test(resources: Path) -> dict:
|
||||
from . import __version__
|
||||
from .gui import App, APP_TITLE, CONTENT_DISCLOSURE
|
||||
from .install import SOURCE_COMMIT
|
||||
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',
|
||||
@@ -72,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'):
|
||||
@@ -80,6 +101,29 @@ def smoke_test(resources: Path) -> dict:
|
||||
if ('Steam artwork and game Notes are added automatically' not in guidance
|
||||
or hasattr(app, 'library_card')):
|
||||
raise RuntimeError('Packaged install/repair lost automatic Steam info setup')
|
||||
# Exercise the reported icon-readback case in the packaged GUI,
|
||||
# without opening a dialog or connecting to a device.
|
||||
dialogs = []
|
||||
showinfo = gui.messagebox.showinfo
|
||||
gui.messagebox.showinfo = lambda title, message, **kwargs: dialogs.append((title, message))
|
||||
try:
|
||||
for repaired in (False, True):
|
||||
app.operation = 'install'
|
||||
app.progress_state.reset()
|
||||
app.events.put(('complete', InstallResult(
|
||||
'/home/steamos/Games/HaloCENativeVR', True,
|
||||
{'status': 'added', 'artwork': {'status': 'unchanged',
|
||||
'icon': {'status': 'manual',
|
||||
'reason': 'Steam shortcut icon has not loaded yet.'}},
|
||||
'notes': {'status': 'added'}}, None, repaired=repaired)))
|
||||
app._drain()
|
||||
if (float(app.progress['value']) != 100 or app.phase.get() != 'Complete'
|
||||
or app.retry_button.winfo_manager()
|
||||
or dialogs[-1][0] != 'Setup complete'
|
||||
or 'icon' in dialogs[-1][1].lower()):
|
||||
raise RuntimeError('Packaged successful setup was blocked by its optional Steam icon')
|
||||
finally:
|
||||
gui.messagebox.showinfo = showinfo
|
||||
finally:
|
||||
for callback in app.tk.call('after', 'info'):
|
||||
app.after_cancel(callback)
|
||||
@@ -95,5 +139,8 @@ def smoke_test(resources: Path) -> dict:
|
||||
'usbTransferAvailable': True,
|
||||
'steamInfoAutomaticOnInstall': True,
|
||||
'steamDescriptionUsesNotes': True,
|
||||
'optionalSteamIconNonBlocking': True,
|
||||
'sdCardInstallAvailable': True,
|
||||
'storageDiscoveryRequiredForSD': True,
|
||||
'usbToolsBundled': usb_bundled,
|
||||
'passwordReprRedacted': True, 'networkConnections': 0}
|
||||
+261
-36
@@ -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
|
||||
@@ -21,8 +22,18 @@ class SSHError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionLostError(SSHError):
|
||||
"""A started command lost its connection before an exit status was verified."""
|
||||
|
||||
|
||||
class RemoteTimeoutError(SSHError):
|
||||
"""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:
|
||||
@@ -63,8 +74,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"
|
||||
@@ -116,7 +131,7 @@ class SSHConnection:
|
||||
def host_fingerprint(self) -> str | None:
|
||||
return self.policy.accepted_fingerprint
|
||||
|
||||
def connect(self) -> None:
|
||||
def connect(self, *, timeout: float = 15) -> None:
|
||||
from .usb import USBError, USBForward
|
||||
try:
|
||||
host, port = self.settings.host, self.settings.port
|
||||
@@ -127,7 +142,11 @@ class SSHConnection:
|
||||
self.client.connect(host, port=port,
|
||||
username="steamos", password=self.settings.password,
|
||||
allow_agent=False, look_for_keys=False,
|
||||
timeout=15, auth_timeout=20, banner_timeout=20)
|
||||
timeout=timeout, auth_timeout=min(20, timeout + 5),
|
||||
banner_timeout=min(20, timeout + 5))
|
||||
transport = self.client.get_transport()
|
||||
if transport is not None:
|
||||
transport.set_keepalive(15)
|
||||
except (paramiko.AuthenticationException, paramiko.PasswordRequiredException) as exc:
|
||||
self._close_after_failure()
|
||||
raise SSHError("SSH authentication failed. Check the Frame's steamos password.") from None
|
||||
@@ -137,16 +156,17 @@ class SSHConnection:
|
||||
except SSHError:
|
||||
self._close_after_failure()
|
||||
raise
|
||||
except (paramiko.SSHException, OSError, socket.timeout) as exc:
|
||||
except (paramiko.SSHException, OSError, EOFError) as exc:
|
||||
self._close_after_failure()
|
||||
# Avoid displaying a library error that might include submitted credentials.
|
||||
message = ("Could not connect to the Frame over USB SSH. Check Developer Mode, the USB cable and the Frame's SSH service."
|
||||
if self.settings.transport == "usb" else
|
||||
"Could not connect to the Frame over SSH. Check its address, Wi-Fi / Ethernet connection, and SSH service.")
|
||||
raise SSHError(message) from None
|
||||
|
||||
def is_active(self) -> bool:
|
||||
transport = self.client.get_transport()
|
||||
if transport is not None:
|
||||
transport.set_keepalive(15)
|
||||
return bool(not self._closed and transport is not None and transport.is_active())
|
||||
|
||||
def _close_after_failure(self) -> None:
|
||||
try:
|
||||
@@ -168,23 +188,43 @@ class SSHConnection:
|
||||
pass
|
||||
self._sftp_clients.clear()
|
||||
self._command_channels.clear()
|
||||
transport = self.client.get_transport()
|
||||
failures = []
|
||||
try:
|
||||
transport = self.client.get_transport()
|
||||
except Exception:
|
||||
transport = None
|
||||
failures.append("The SSH transport could not be checked during disconnection.")
|
||||
try:
|
||||
if transport is not None:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception:
|
||||
failures.append("The SSH transport could not finish closing.")
|
||||
finally:
|
||||
# Transport.close() may return early after a failed or
|
||||
# already inactive session. Release its socket as well.
|
||||
sock = getattr(transport, "sock", None)
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
failures.append("The setup connection's socket could not finish closing.")
|
||||
finally:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
failures.append("The SSH client could not finish closing.")
|
||||
finally:
|
||||
self._closed = True
|
||||
self._close_usb_forward()
|
||||
try:
|
||||
self._close_usb_forward()
|
||||
except SSHError as exc:
|
||||
failures.append(str(exc))
|
||||
if failures:
|
||||
message = "\n".join(failures)
|
||||
if self.settings.password:
|
||||
message = message.replace(self.settings.password, "[redacted]")
|
||||
raise SSHError(message) from None
|
||||
|
||||
def _close_usb_forward(self) -> None:
|
||||
if self._usb_forward is not None:
|
||||
@@ -195,34 +235,155 @@ 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,
|
||||
timeout_message: str | None = None) -> str:
|
||||
"""Run an argument vector; no secrets are put into a remote command."""
|
||||
timeout_message: str | None = None,
|
||||
input_data: bytes | None = None) -> str:
|
||||
"""Run arguments, optionally streaming bytes through the SSH stdin channel."""
|
||||
if input_data is not None and not isinstance(input_data, bytes):
|
||||
raise TypeError("SSH command input must be bytes.")
|
||||
def notify_cancelled():
|
||||
if on_cancel is not None:
|
||||
try:
|
||||
on_cancel()
|
||||
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])
|
||||
_, stdout, _ = self.client.exec_command(command, timeout=30)
|
||||
started = time.monotonic()
|
||||
expired = None
|
||||
try:
|
||||
with self._handshake_watchdog(min(30, timeout) + self.handshake_grace, cancel_event) as expired:
|
||||
# Keep stdin alive until the command finishes. Paramiko closes
|
||||
# its write side when ChannelStdinFile is garbage-collected;
|
||||
# discarding this wrapper would send EOF before our payload.
|
||||
stdin, 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)
|
||||
channel.set_combine_stderr(True)
|
||||
if input_data is None:
|
||||
# Preserve the noninteractive command behavior: callers that did
|
||||
# not supply stdin still send EOF immediately after the handshake.
|
||||
try:
|
||||
stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Build output is delivered live to the activity callback. Keep only a
|
||||
# bounded response tail for the final result/error, rather than storing
|
||||
# the entire compilation a second time in memory.
|
||||
chunks: deque[bytes] = deque()
|
||||
retained_bytes = 0
|
||||
tail_starts_at_line = True
|
||||
pending = bytearray()
|
||||
started = time.monotonic()
|
||||
discarding_line = False
|
||||
sent_bytes = 0
|
||||
input_finished = input_data is None
|
||||
try:
|
||||
channel.set_combine_stderr(True)
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
if on_cancel is not None:
|
||||
on_cancel()
|
||||
notify_cancelled()
|
||||
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
|
||||
if time.monotonic() - started > timeout:
|
||||
if on_cancel is not None:
|
||||
on_cancel()
|
||||
raise SSHError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
|
||||
notify_cancelled()
|
||||
raise RemoteTimeoutError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
|
||||
if not input_finished:
|
||||
# A remote helper waits for EOF before it verifies and runs
|
||||
# this source. Send bounded chunks only when SSH's window
|
||||
# permits them, while continuing to drain output and check
|
||||
# cancellation/deadlines if the peer stops reading stdin.
|
||||
if (sent_bytes < len(input_data) and not channel.exit_status_ready()
|
||||
and channel.send_ready()):
|
||||
try:
|
||||
count = channel.send(input_data[sent_bytes:sent_bytes + 16384])
|
||||
except (paramiko.SSHException, OSError, EOFError):
|
||||
if not channel.exit_status_ready():
|
||||
raise
|
||||
else:
|
||||
if count <= 0:
|
||||
if not channel.exit_status_ready():
|
||||
raise EOFError("SSH stdin closed during helper transfer.")
|
||||
else:
|
||||
sent_bytes += count
|
||||
if sent_bytes == len(input_data) and not channel.exit_status_ready():
|
||||
channel.shutdown_write()
|
||||
input_finished = True
|
||||
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
|
||||
@@ -232,23 +393,51 @@ class SSHConnection:
|
||||
overflow = retained_bytes - 256 * 1024
|
||||
first = chunks.popleft()
|
||||
if len(first) > overflow:
|
||||
tail_starts_at_line = first[overflow - 1:overflow] == b"\n"
|
||||
chunks.appendleft(first[overflow:])
|
||||
retained_bytes -= overflow
|
||||
else:
|
||||
tail_starts_at_line = first[-1:] == b"\n"
|
||||
retained_bytes -= len(first)
|
||||
if discarding_line:
|
||||
_, separator, data = data.partition(b"\n")
|
||||
if not separator:
|
||||
continue
|
||||
discarding_line = False
|
||||
pending.extend(data)
|
||||
while b"\n" in pending:
|
||||
line, _, rest = pending.partition(b"\n")
|
||||
pending[:] = rest
|
||||
if progress is not None:
|
||||
if len(line) <= 1024 * 1024 and progress is not None:
|
||||
progress(line.decode("utf-8", "replace"))
|
||||
if len(pending) > 1024 * 1024:
|
||||
# Drop an oversized record through its newline. Emitting
|
||||
# a suffix could turn truncated JSON or split UTF-8 into
|
||||
# a false activity/error update. Resume at the next line.
|
||||
pending.clear()
|
||||
discarding_line = True
|
||||
if channel.exit_status_ready() and not channel.recv_ready():
|
||||
break
|
||||
time.sleep(0.08)
|
||||
time.sleep(0.01 if not input_finished else 0.08)
|
||||
status = channel.recv_exit_status()
|
||||
if status == -1:
|
||||
# Paramiko also reports readiness when a channel closes without
|
||||
# an exit-status message. This cannot verify success or failure;
|
||||
# do not turn stale build output or a partial final record into
|
||||
# a compiler diagnostic.
|
||||
raise ConnectionLostError("The remote connection closed without reporting an exit status, so this step could not be verified. "
|
||||
"Keep the Frame awake and reconnect; the operation may still be running on it. Check its status before retrying.") from None
|
||||
if status == 0 and not input_finished:
|
||||
raise ConnectionLostError("The remote command ended before the setup helper transfer was complete. Reconnect and retry.") from None
|
||||
if pending and progress is not None:
|
||||
progress(pending.decode("utf-8", "replace"))
|
||||
result = b"".join(chunks).decode("utf-8", "replace")
|
||||
status = channel.recv_exit_status()
|
||||
response = b"".join(chunks)
|
||||
if not tail_starts_at_line:
|
||||
# The bounded byte tail may begin in the middle of HFI_LOG or
|
||||
# HFI_PROGRESS. Remove that first incomplete record before
|
||||
# returning it or filtering protocol lines from a failure.
|
||||
response = response.partition(b"\n")[2]
|
||||
result = response.decode("utf-8", "replace")
|
||||
if status:
|
||||
# Remote helper emits bounded, user-readable diagnostics. Redact defensively.
|
||||
lines = [line for line in result.splitlines()
|
||||
@@ -257,14 +446,38 @@ class SSHConnection:
|
||||
marker = safe.find("HFI_ERROR ")
|
||||
if marker >= 0:
|
||||
safe = safe[marker + len("HFI_ERROR "):]
|
||||
safe = safe.replace(self.settings.password, "[redacted]")
|
||||
raise SSHError(safe[-12000:].strip() or f"Remote step failed (exit {status}).")
|
||||
if self.settings.password:
|
||||
safe = safe.replace(self.settings.password, "[redacted]")
|
||||
safe = safe.strip()
|
||||
if len(safe) > 12000:
|
||||
# Keep the helper's summary as well as the final compiler
|
||||
# diagnostics. A tail-only slice hid "Native build failed"
|
||||
# and made the popup begin in an unrelated object filename.
|
||||
summary = safe.splitlines()[0][:500]
|
||||
separator = "\n... [earlier details omitted]\n"
|
||||
remaining = 12000 - len(summary) - len(separator)
|
||||
safe = summary + separator + safe[-remaining:]
|
||||
raise SSHError(safe or f"Remote step failed (exit {status}).")
|
||||
return result
|
||||
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
|
||||
raise ConnectionLostError("The remote connection stopped before this step could be verified. "
|
||||
"Keep the Frame awake and reconnect; the operation may still be running on it. Check its status before retrying.") from None
|
||||
finally:
|
||||
try:
|
||||
channel.close()
|
||||
finally:
|
||||
except Exception:
|
||||
# Final connection teardown will retry this resource and close
|
||||
# its transport. A channel-close error cannot erase the result.
|
||||
pass
|
||||
else:
|
||||
self._command_channels.discard(channel)
|
||||
try:
|
||||
stdin.close()
|
||||
except Exception:
|
||||
pass # Closing this wrapper cannot erase the command outcome.
|
||||
|
||||
def put(self, local: Path, remote: str, *, callback=None,
|
||||
cancel_event: threading.Event | None = None) -> None:
|
||||
@@ -273,14 +486,26 @@ class SSHConnection:
|
||||
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
|
||||
if callback:
|
||||
callback(done, total)
|
||||
with self.client.open_sftp() as sftp:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
|
||||
sftp = None
|
||||
try:
|
||||
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)
|
||||
try:
|
||||
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():
|
||||
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
|
||||
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
|
||||
finally:
|
||||
self._sftp_clients.discard(sftp)
|
||||
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():
|
||||
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
|
||||
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
|
||||
finally:
|
||||
if sftp is not None:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass # Connection teardown still owns and retries it.
|
||||
else:
|
||||
self._sftp_clients.discard(sftp)
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
+78
-1
@@ -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
|
||||
@@ -215,11 +219,84 @@ def owned_game(tmp_path):
|
||||
program[18:20] = (183).to_bytes(2, "little")
|
||||
(game / "halo").write_bytes(program)
|
||||
(game / ".halo-frame-installer.json").write_text(json.dumps({
|
||||
"owner": "halo-frame-installer", "sourceCommit": "88142798513ebd99fc7c6224023e8b44c05d0106",
|
||||
"owner": "halo-frame-installer", "sourceCommit": "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4",
|
||||
"files": {"halo": hashlib.sha256(program).hexdigest()}}))
|
||||
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))
|
||||
|
||||
@@ -52,8 +52,13 @@ def test_build_launches_package_manager_as_container_root_with_host_isolation(tm
|
||||
manifest = {"files": files, "totalBytes": len(header) * len(files)}
|
||||
(upload / "xbox-data-manifest.json").write_text(json.dumps(manifest))
|
||||
monkeypatch.setattr(remote, "existing_install", lambda *args: None)
|
||||
original_which = remote.shutil.which
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: (
|
||||
"podman" if name == "podman" else None if name == "systemd-inhibit" else original_which(name)))
|
||||
|
||||
def git_command(argv, **kwargs):
|
||||
if argv[:2] == ["podman", "ps"]:
|
||||
return "[]"
|
||||
assert argv[0] == "git"
|
||||
if argv[1] == "init":
|
||||
Path(argv[2]).mkdir()
|
||||
@@ -85,7 +90,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 (
|
||||
|
||||
@@ -197,8 +197,13 @@ def setup_build(remote, monkeypatch):
|
||||
monkeypatch.setattr(remote, "existing_install", lambda *args: None)
|
||||
monkeypatch.setattr(remote, "verify_maps", lambda *args, **kwargs: 0)
|
||||
monkeypatch.setattr(remote.time, "sleep", lambda seconds: None)
|
||||
original_which = remote.shutil.which
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: (
|
||||
"podman" if name == "podman" else None if name == "systemd-inhibit" else original_which(name)))
|
||||
|
||||
def git(argv, **kwargs):
|
||||
if argv[:2] == ["podman", "ps"]:
|
||||
return "[]"
|
||||
assert argv[0] == "git"
|
||||
if argv[1] == "init":
|
||||
Path(argv[2]).mkdir()
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Execute the shipped configure recipe's command flow without package installs.
|
||||
|
||||
The canonical engine integration check validates real source files and build
|
||||
dependencies. This harness checks the shell command list, missing-file failure,
|
||||
and fail-fast ordering using only temporary files and an offline receiver.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RECIPE = ROOT / "resources/build-native.sh"
|
||||
PATCH = ROOT / "resources/frame-controls.patch"
|
||||
|
||||
|
||||
def shipped_tests():
|
||||
return set(re.findall(r"^diff --git a/(tools/test_[^ ]+\.py) b/\1$",
|
||||
PATCH.read_text(encoding="utf-8"), re.M))
|
||||
|
||||
|
||||
def recipe_commands(text):
|
||||
return [shlex.split(line, comments=True) for line in text.splitlines()
|
||||
if line.startswith("python3 ")]
|
||||
|
||||
|
||||
def audit_recipe(text):
|
||||
commands = recipe_commands(text)
|
||||
assert commands and commands[-1] == ["python3", "configure.py", "--release", "--vr", "--linux-arm64-cc", "clang"]
|
||||
tests = commands[:-1]
|
||||
assert tests[0] == ["python3", "tools/test_build_sources.py"]
|
||||
names = [command[1] for command in tests]
|
||||
assert len(names) == len(set(names)), "A native regression was duplicated"
|
||||
assert set(names) == shipped_tests(), "Recipe checks do not match the tests shipped in the engine patch"
|
||||
for command in tests[1:]:
|
||||
if command[1] == "tools/test_vr_renderer_link.py":
|
||||
assert command[2:] == ["--cc", "clang", "--ld", "ld.lld", "--sdk", "/usr/include"]
|
||||
else:
|
||||
assert command[2:] == ["--cc", "clang"]
|
||||
assert "set -euo pipefail" in text
|
||||
return commands
|
||||
|
||||
|
||||
def test_shipped_recipe_runs_every_current_engine_regression_once_before_configuration():
|
||||
commands = audit_recipe(RECIPE.read_text(encoding="utf-8"))
|
||||
assert len(commands) > 20 # An accidental empty/partial patch cannot weaken this check.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("removed", ["tools/test_vr_scope.py", "tools/test_vr_scope_render.py",
|
||||
"tools/test_vr_scope_backend.py", "tools/test_vr_scope_hud.py",
|
||||
"tools/test_vr_scope_link.py"])
|
||||
def test_recipe_audit_rejects_removed_scope_tests_even_when_other_checks_remain(removed):
|
||||
text = RECIPE.read_text(encoding="utf-8").replace(
|
||||
"python3 tools/test_vr_stock_zoom_hud.py --cc clang\n",
|
||||
"python3 " + removed + " --cc clang\n")
|
||||
with pytest.raises(AssertionError, match="match the tests shipped"):
|
||||
audit_recipe(text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shell_recipe(tmp_path):
|
||||
bash = shutil.which("bash")
|
||||
if not bash:
|
||||
candidate = Path("C:/Program Files/Git/bin/bash.exe")
|
||||
bash = str(candidate) if candidate.is_file() else None
|
||||
if not bash:
|
||||
pytest.skip("Bash is required for the offline native recipe command-flow check")
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
# Only scripts from the actual patch are available. A deleted scope script
|
||||
# is therefore a real missing path in this shell command-flow fixture.
|
||||
for name in [*shipped_tests(), "configure.py"]:
|
||||
path = source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("# Offline command-flow fixture; engine execution is checked separately.\n")
|
||||
receiver = tmp_path / "receive-command.py"
|
||||
receiver.write_text(
|
||||
"import json,os,sys\nfrom pathlib import Path\n"
|
||||
"arguments=sys.argv[1:]\n"
|
||||
"print('HFI_RECIPE_COMMAND '+json.dumps(arguments),flush=True)\n"
|
||||
"if not Path(arguments[0]).is_file():\n"
|
||||
" print('Missing native regression: '+arguments[0],file=sys.stderr,flush=True)\n"
|
||||
" raise SystemExit(2)\n"
|
||||
"if arguments[0]==os.environ.get('HFI_FAIL_SCRIPT'):\n"
|
||||
" print('Synthetic regression failure',file=sys.stderr,flush=True)\n"
|
||||
" raise SystemExit(41)\n", encoding="utf-8")
|
||||
|
||||
def run(text, fail=None):
|
||||
# Execute the exact configure commands between the source check and
|
||||
# package inventory. No apt/curl/compiler/Frame command is invoked.
|
||||
start = text.index("phase configure 'Verifying")
|
||||
end = text.index("# Record signed-package versions.")
|
||||
command = ("set -euo pipefail\n"
|
||||
"cd \"$HFI_RECIPE_SOURCE\"\n"
|
||||
"phase() { printf 'HFI_BUILD_PHASE %s %s\\n' \"$1\" \"$2\"; }\n"
|
||||
"python3() { \"$HFI_RECIPE_PYTHON\" \"$HFI_RECIPE_RECEIVER\" \"$@\"; }\n"
|
||||
+ text[start:end])
|
||||
env = dict(os.environ, HFI_RECIPE_SOURCE=source.as_posix(),
|
||||
HFI_RECIPE_PYTHON=Path(sys.executable).as_posix(),
|
||||
HFI_RECIPE_RECEIVER=receiver.as_posix(), HFI_FAIL_SCRIPT=fail or "",
|
||||
MSYS2_ARG_CONV_EXCL="*")
|
||||
result = subprocess.run([bash, "--noprofile", "--norc", "-c", command],
|
||||
env=env, text=True, capture_output=True, timeout=15)
|
||||
calls = [json.loads(line.removeprefix("HFI_RECIPE_COMMAND "))
|
||||
for line in result.stdout.splitlines() if line.startswith("HFI_RECIPE_COMMAND ")]
|
||||
return result, calls
|
||||
|
||||
def precheck(text, missing=None):
|
||||
if missing is not None:
|
||||
(source / missing).unlink()
|
||||
staged = tmp_path / "build-native.sh"
|
||||
# Rebind only the two absolute build paths used before apt. The complete
|
||||
# script remains on disk, and BASH_SOURCE reads its actual command list.
|
||||
staged_text = text.replace("cd /build\n", "cd \"$HFI_RECIPE_BUILD\"\n", 1).replace(
|
||||
'"/build/src/$_hfi_test_script"', '"$HFI_RECIPE_SOURCE/$_hfi_test_script"', 1)
|
||||
staged.write_text(staged_text, encoding="utf-8", newline="\n")
|
||||
command = ("apt-get() { printf 'HFI_PRECHECK_ACTIVITY apt-get %s\\n' \"$*\"; exit 87; }\n"
|
||||
"curl() { printf 'HFI_PRECHECK_ACTIVITY curl %s\\n' \"$*\"; exit 88; }\n"
|
||||
"source \"$HFI_RECIPE_SCRIPT\"\n")
|
||||
env = dict(os.environ, HFI_RECIPE_BUILD=tmp_path.as_posix(),
|
||||
HFI_RECIPE_SOURCE=source.as_posix(), HFI_RECIPE_SCRIPT=staged.as_posix(),
|
||||
MSYS2_ARG_CONV_EXCL="*")
|
||||
return subprocess.run([bash, "--noprofile", "--norc", "-c", command],
|
||||
env=env, text=True, capture_output=True, timeout=15)
|
||||
|
||||
run.precheck = precheck
|
||||
return run
|
||||
|
||||
|
||||
def test_actual_bash_precheck_reaches_first_package_step_with_all_shipped_tests(shell_recipe):
|
||||
result = shell_recipe.precheck(RECIPE.read_text(encoding="utf-8"))
|
||||
# The intercepted package command exits immediately; no apt/curl operation runs.
|
||||
assert result.returncode == 87, result.stdout + result.stderr
|
||||
assert result.stdout.splitlines()[-1] == "HFI_PRECHECK_ACTIVITY apt-get update"
|
||||
assert "Missing native build test" not in result.stderr
|
||||
phases = [line for line in result.stdout.splitlines() if line.startswith("HFI_BUILD_PHASE ")]
|
||||
assert len(phases) == 2 and all(line.startswith("HFI_BUILD_PHASE dependencies ") for line in phases)
|
||||
assert "Checking bundled native build tests" in phases[0]
|
||||
assert "Installing isolated ARM64 build dependencies" in phases[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cause", ["missing-current-test", "stale-scope-command"])
|
||||
def test_actual_bash_precheck_rejects_missing_test_before_any_package_or_download_step(shell_recipe, cause):
|
||||
text = RECIPE.read_text(encoding="utf-8")
|
||||
if cause == "missing-current-test":
|
||||
missing = "tools/test_vr_framebuffer.py"
|
||||
result = shell_recipe.precheck(text, missing=missing)
|
||||
else:
|
||||
missing = "tools/test_vr_scope.py"
|
||||
result = shell_recipe.precheck(text.replace("tools/test_vr_stock_zoom_hud.py", missing))
|
||||
assert result.returncode == 1
|
||||
assert result.stderr.strip() == "Missing native build test: " + missing
|
||||
assert "HFI_PRECHECK_ACTIVITY" not in result.stdout
|
||||
assert "Installing isolated ARM64 build dependencies" not in result.stdout
|
||||
assert "HFI_BUILD_PHASE configure" not in result.stdout
|
||||
|
||||
|
||||
def test_actual_bash_configure_recipe_reaches_native_configuration_after_every_check(shell_recipe):
|
||||
text = RECIPE.read_text(encoding="utf-8")
|
||||
result, calls = shell_recipe(text)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert calls == [command[1:] for command in audit_recipe(text)]
|
||||
assert calls[-1] == ["configure.py", "--release", "--vr", "--linux-arm64-cc", "clang"]
|
||||
assert "HFI_BUILD_PHASE configure Configuring the native ARM64 OpenXR game" in result.stdout
|
||||
|
||||
|
||||
def test_actual_bash_recipe_stops_on_missing_test_before_native_configuration(shell_recipe):
|
||||
text = RECIPE.read_text(encoding="utf-8").replace(
|
||||
"tools/test_vr_stock_zoom_hud.py", "tools/test_vr_scope.py")
|
||||
result, calls = shell_recipe(text)
|
||||
assert result.returncode == 2 and "Missing native regression: tools/test_vr_scope.py" in result.stderr
|
||||
assert calls[-1] == ["tools/test_vr_scope.py", "--cc", "clang"]
|
||||
assert not any(command[0] == "configure.py" for command in calls)
|
||||
assert not any(command[0] == "tools/test_vr_driving.py" for command in calls)
|
||||
|
||||
|
||||
def test_actual_bash_recipe_preserves_real_check_failure_before_native_configuration(shell_recipe):
|
||||
result, calls = shell_recipe(RECIPE.read_text(encoding="utf-8"), "tools/test_vr_driving.py")
|
||||
assert result.returncode == 41 and "Synthetic regression failure" in result.stderr
|
||||
assert calls[-1] == ["tools/test_vr_driving.py", "--cc", "clang"]
|
||||
assert not any(command[0] == "configure.py" for command in calls)
|
||||
assert not any(command[0] == "tools/test_vr_driving_game.py" for command in calls)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Offline source-integration regressions; no game data or headset connection."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("experimental_engine_check", ROOT / "scripts/check-experimental-engine.py")
|
||||
engine = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(engine)
|
||||
|
||||
|
||||
TWO_PASS = """
|
||||
static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale[4][4])
|
||||
{
|
||||
GLenum gl_targets[D3DTSS_MAXSTAGES];
|
||||
GLuint gl_textures[D3DTSS_MAXSTAGES];
|
||||
int stage;
|
||||
for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)
|
||||
{
|
||||
gl_textures[stage] = xgpu_texture_get(texture, palette, &gl_targets[stage], &description);
|
||||
}
|
||||
for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)
|
||||
state_texture(stage, gl_targets[stage], gl_textures[stage]);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def literal_assignment(path, name):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("Missing " + name + " in " + str(path))
|
||||
|
||||
|
||||
def test_installer_and_remote_metadata_share_the_exact_experimental_engine_pin():
|
||||
for path, name in ((ROOT / "src/halo_frame_installer/install.py", "SOURCE_COMMIT"),
|
||||
(ROOT / "resources/remote_install.py", "SOURCE_COMMIT"),
|
||||
(ROOT / "resources/steam_shortcut.py", "ICON_SOURCE_COMMIT")):
|
||||
assert literal_assignment(path, name) == engine.SOURCE_COMMIT
|
||||
|
||||
|
||||
def test_two_pass_texture_binding_is_accepted():
|
||||
engine.check_texture_binding(TWO_PASS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken", [
|
||||
TWO_PASS.replace("gl_textures[stage] = xgpu_texture_get", "state_texture(stage, gl_targets[stage], gl_textures[stage]); gl_textures[stage] = xgpu_texture_get"),
|
||||
TWO_PASS.replace("for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)\n state_texture", "state_texture"),
|
||||
TWO_PASS.replace("state_texture(stage, gl_targets[stage], gl_textures[stage])", "state_texture(stage, GL_TEXTURE_2D, 0)"),
|
||||
])
|
||||
def test_shader_upload_binding_regressions_are_rejected(broken):
|
||||
with pytest.raises(engine.EngineCheckError, match="Texture binding|Final texture bindings"):
|
||||
engine.check_texture_binding(broken)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["../outside.c", "/outside.c", "source/network/network_messages.c", "resources/remote_install.py"])
|
||||
def test_engine_patch_cannot_hide_unreviewed_paths(path):
|
||||
with pytest.raises(engine.EngineCheckError, match="outside the reviewed"):
|
||||
engine.patch_files("diff --git a/" + path + " b/" + path + "\n")
|
||||
|
||||
|
||||
def test_patch_path_allowlist_contains_both_new_header_and_existing_input_hooks():
|
||||
text = (ROOT / "resources/frame-controls.patch").read_text(encoding="utf-8")
|
||||
paths = engine.patch_files(text)
|
||||
assert {"port/linux/src/vr_locomotion.h", "port/linux/src/vr.c", "port/linux/src/port_config.c", "source/game/player_control.c"} <= paths
|
||||
|
||||
|
||||
def test_protocol_11_engine_cannot_be_mislabeled_as_matching_experimental_coop(tmp_path):
|
||||
path = tmp_path / "port/linux/include/halo_port_limits.h"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("#define HALO_PORT_NETWORK_VERSION 11\n")
|
||||
with pytest.raises(engine.EngineCheckError, match="protocol 17"):
|
||||
engine.check_upstream(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recipe_projection(tmp_path):
|
||||
declaration = "import argparse\np = argparse.ArgumentParser()\n"
|
||||
declaration += "\n".join("p.add_argument('" + option + "')" for option in ("--cc", "--ld", "--sdk"))
|
||||
for path in engine.PATCH_FILES:
|
||||
if path.startswith("tools/test_") and path.endswith(".py"):
|
||||
candidate = tmp_path / path
|
||||
candidate.parent.mkdir(exist_ok=True)
|
||||
candidate.write_text(declaration, encoding="utf-8")
|
||||
(tmp_path / "configure.py").write_text("# configure boundary\n", encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_native_recipe_checks_every_shipped_test_in_the_projection(recipe_projection):
|
||||
actual = engine.check_build_recipe(recipe_projection)
|
||||
expected = {path for path in engine.PATCH_FILES if path.startswith("tools/test_") and path.endswith(".py")}
|
||||
assert set(actual) == expected and len(actual) == len(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["test_vr_scope.py", "test_vr_scope_render.py", "test_vr_scope_backend.py",
|
||||
"test_vr_scope_hud.py", "test_vr_scope_link.py"])
|
||||
def test_recipe_rejects_each_removed_scope_command(recipe_projection, name):
|
||||
text = (ROOT / "resources/build-native.sh").read_text(encoding="utf-8")
|
||||
text = text.replace("python3 configure.py", "python3 tools/" + name + " --cc clang\npython3 configure.py")
|
||||
with pytest.raises(engine.EngineCheckError, match="does not match the shipped"):
|
||||
engine.check_build_recipe(recipe_projection, text)
|
||||
|
||||
|
||||
def test_recipe_rejects_missing_actual_test_file(recipe_projection):
|
||||
(recipe_projection / "tools/test_vr_framebuffer.py").unlink()
|
||||
with pytest.raises(engine.EngineCheckError, match="missing script: tools/test_vr_framebuffer.py"):
|
||||
engine.check_build_recipe(recipe_projection)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("before,after,diagnostic", [
|
||||
("--sdk /usr/include", "--missing-sdk /usr/include", "unsupported option"),
|
||||
("python3 tools/test_vr_tracking.py --cc clang", "python3 tools/test_vr_tracking.py --cc clang\npython3 tools/test_vr_tracking.py --cc clang", "repeats a regression"),
|
||||
("--release --vr --linux-arm64-cc clang", "--release --linux-arm64-cc clang", "release ARM64 VR target"),
|
||||
("ninja -j4 linux_arm64", "ninja -j4 linux", "configured ARM64 target"),
|
||||
("set -euo pipefail", "set -u", "stop on failed checks"),
|
||||
])
|
||||
def test_recipe_rejects_invalid_commands_and_failure_masking(recipe_projection, before, after, diagnostic):
|
||||
text = (ROOT / "resources/build-native.sh").read_text(encoding="utf-8")
|
||||
assert before in text
|
||||
with pytest.raises(engine.EngineCheckError, match=diagnostic):
|
||||
engine.check_build_recipe(recipe_projection, text.replace(before, after))
|
||||
|
||||
|
||||
def test_recipe_accepts_and_checks_local_delegated_cli_options(recipe_projection):
|
||||
(recipe_projection / "tools/test_vr_flashlight.py").write_text(
|
||||
"import test_vr_projectile_reticle as reticle\nreticle.main()\n", encoding="utf-8")
|
||||
assert "tools/test_vr_flashlight.py" in engine.check_build_recipe(recipe_projection)
|
||||
(recipe_projection / "tools/test_vr_projectile_reticle.py").write_text("def main(): pass\n", encoding="utf-8")
|
||||
with pytest.raises(engine.EngineCheckError, match="unsupported option"):
|
||||
engine.check_build_recipe(recipe_projection)
|
||||
|
||||
|
||||
def test_public_release_documentation_names_the_single_installer_download():
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
download = re.search(
|
||||
r"https://github\.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/[^\s\"<>)]*",
|
||||
readme,
|
||||
)
|
||||
assert download is not None, "README is missing its primary installer download"
|
||||
identity = re.fullmatch(
|
||||
r"https://github\.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/"
|
||||
r"v(?P<version>\d+\.\d+\.\d+)/Halo-Steam-Frame-Mod-Setup-(?P=version)\.exe",
|
||||
download.group(),
|
||||
)
|
||||
assert identity is not None, "README installer tag and filename must share a stable version"
|
||||
version = identity["version"]
|
||||
filename = f"Halo-Steam-Frame-Mod-Setup-{version}.exe"
|
||||
package_version = literal_assignment(ROOT / "src/halo_frame_installer/__init__.py", "__version__")
|
||||
build_version = literal_assignment(ROOT / "scripts/build_release.py", "VERSION")
|
||||
assert package_version == build_version
|
||||
if re.fullmatch(r"\d+\.\d+\.\d+", package_version):
|
||||
assert version == package_version, "Stable builds must document their own release download"
|
||||
assert tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] == version
|
||||
# Private candidates retain the previous public download until publication.
|
||||
for name in ("README.md", "docs/SETUP.md", "docs/RELEASE_NOTES.md"):
|
||||
text = (ROOT / name).read_text(encoding="utf-8")
|
||||
assert version in text and filename in text and download.group() in text
|
||||
assert "has not been published" not in text and "local experimental preview" not in text
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_documentation_projection(tmp_path, monkeypatch):
|
||||
for relative in ("README.md", "docs/SETUP.md", "docs/RELEASE_NOTES.md", "docs/SD_CARD.md",
|
||||
"src/halo_frame_installer/__init__.py", "scripts/build_release.py", "pyproject.toml"):
|
||||
destination = tmp_path / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes((ROOT / relative).read_bytes())
|
||||
public_version = re.search(r"/releases/download/v(\d+\.\d+\.\d+)/", (tmp_path / "README.md").read_text(encoding="utf-8")).group(1)
|
||||
for relative, name in (("src/halo_frame_installer/__init__.py", "__version__"),
|
||||
("scripts/build_release.py", "VERSION")):
|
||||
path = tmp_path / relative
|
||||
old = literal_assignment(path, name)
|
||||
path.write_text(path.read_text(encoding="utf-8").replace('"' + old + '"', '"' + public_version + '"'), encoding="utf-8")
|
||||
project = tmp_path / "pyproject.toml"
|
||||
old = tomllib.loads(project.read_text(encoding="utf-8"))["project"]["version"]
|
||||
project.write_text(project.read_text(encoding="utf-8").replace('version = "' + old + '"',
|
||||
'version = "' + public_version + '"', 1), encoding="utf-8")
|
||||
monkeypatch.setitem(globals(), "ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_public_release_documentation_rejects_a_stale_download_for_a_stable_build(release_documentation_projection):
|
||||
readme = release_documentation_projection / "README.md"
|
||||
version = literal_assignment(release_documentation_projection / "scripts/build_release.py", "VERSION")
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
readme.write_text(text.replace("/v" + version + "/Halo-Steam-Frame-Mod-Setup-" + version + ".exe",
|
||||
"/v0.0.1/Halo-Steam-Frame-Mod-Setup-0.0.1.exe"), encoding="utf-8")
|
||||
with pytest.raises(AssertionError, match="Stable builds must document their own release"):
|
||||
test_public_release_documentation_names_the_single_installer_download()
|
||||
|
||||
|
||||
def test_private_candidate_keeps_the_existing_public_download(release_documentation_projection):
|
||||
for relative, name in (("src/halo_frame_installer/__init__.py", "__version__"),
|
||||
("scripts/build_release.py", "VERSION")):
|
||||
path = release_documentation_projection / relative
|
||||
version = literal_assignment(path, name)
|
||||
path.write_text(path.read_text(encoding="utf-8").replace('"' + version + '"',
|
||||
'"' + version + '-Test"'), encoding="utf-8")
|
||||
test_public_release_documentation_names_the_single_installer_download()
|
||||
|
||||
|
||||
def test_actual_exact_pinned_engine_and_shipped_patch_when_checkout_is_supplied():
|
||||
source = os.environ.get("HALO_EXPERIMENTAL_ENGINE_SOURCE")
|
||||
if not source:
|
||||
pytest.skip("Set HALO_EXPERIMENTAL_ENGINE_SOURCE for the local offline engine integration check")
|
||||
result = engine.check_source(Path(source))
|
||||
assert result["ok"] and result["protocolVersion"] == 17
|
||||
assert result["offlineArm64NinjaGraph"] and result["headDirectedMovementIntegration"]
|
||||
assert result["buildRecipeIntegration"]
|
||||
assert result["menuControllerRigVisibility"] and result["controllerReticleFallbackSuppressed"]
|
||||
assert result["stereoSunGlowIntegration"] and result["vrInternetCampaignMenu"]
|
||||
assert result["boundedSunGlowStrength"]
|
||||
assert result["nominalProjectileReticleIntegration"]
|
||||
assert result["renderRateProjectilePreviewIntegration"]
|
||||
assert result["networkCampaignOnlyIntegration"]
|
||||
assert result["vrTutorialIntegration"] and result["freshFullPoseTrackingIntegration"]
|
||||
assert result["renderRateUnarmedFlashlightIntegration"]
|
||||
assert not result["arm64LinkedBuild"] and not result["hardwareValidated"]
|
||||
+576
-55
@@ -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')
|
||||
@@ -126,7 +318,7 @@ def test_control_guide_matches_expected_layout():
|
||||
def test_community_mod_title_and_content_disclosure_are_visible_and_accurate(app):
|
||||
import tkinter as tk
|
||||
from halo_frame_installer.gui import APP_TITLE, CONTENT_DISCLOSURE, ARTWORK_CREDIT
|
||||
assert app.title() == APP_TITLE == 'Halo • Community Steam Frame VR Mod Installer'
|
||||
assert app.title() == APP_TITLE == 'Halo • Steam Frame VR Mod Installer'
|
||||
visible = '\n'.join(_display_text(app))
|
||||
assert 'STEAM FRAME VR MOD' in visible and 'COMMUNITY MOD INSTALLER' in visible
|
||||
assert CONTENT_DISCLOSURE in app.data_disclosure.cget('text')
|
||||
@@ -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
|
||||
@@ -883,7 +1070,7 @@ def test_default_window_shows_all_operations_and_game_data_authorization(app):
|
||||
assert top <= control.winfo_rooty()
|
||||
assert control.winfo_rooty() + control.winfo_height() <= bottom
|
||||
assert len({control.winfo_rooty() for control in app.mode_choices}) == 1
|
||||
assert 'uninstall' in app.page_subtitle.cget('text').lower()
|
||||
assert 'older checkpoints cannot load' in app.page_subtitle.cget('text').lower()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@@ -1030,16 +1217,14 @@ def test_pending_steam_retry_does_not_read_game_data_or_build_and_clears_credent
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('pending', ['icon', 'notes', 'artwork'])
|
||||
@pytest.mark.parametrize('pending', ['notes', 'artwork'])
|
||||
def test_steam_info_partial_results_stay_actionable_without_claiming_complete_update(app, pending):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.operation = 'library'
|
||||
app.password_to_redact = 'offline-secret'
|
||||
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
|
||||
'notes': {'status': 'added', 'title': 'About Halo', 'content': 'Campaign and controls.'}}
|
||||
if pending == 'icon':
|
||||
steam['artwork']['icon'] = {'status': 'manual', 'reason': 'Icon not persisted. offline-secret'}
|
||||
elif pending == 'notes':
|
||||
if pending == 'notes':
|
||||
steam['notes'].update(status='manual', message='Open Steam Notes. offline-secret')
|
||||
else:
|
||||
steam['artwork']['status'] = 'manual'
|
||||
@@ -1058,6 +1243,93 @@ def test_steam_info_partial_results_stay_actionable_without_claiming_complete_up
|
||||
assert bool(app.notes_button.winfo_manager()) is (pending == 'notes')
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('operation', ['install', 'repair', 'library'])
|
||||
@pytest.mark.parametrize('reason', [
|
||||
"Steam's current shortcut icon has not loaded yet.",
|
||||
'Steam has not yet persisted the Halo icon selection.',
|
||||
"Steam's current shortcut icon could not be verified as empty.",
|
||||
])
|
||||
def test_optional_shortcut_icon_does_not_block_success_or_show_stale_retry(app, operation, reason):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.operation = operation
|
||||
app.mode.set('repair' if operation == 'repair' else 'install')
|
||||
app.password_to_redact = 'offline-secret'
|
||||
icon = {'status': 'manual', 'reason': reason + ' offline-secret',
|
||||
'instructions': "Open Halo's library page before retrying the icon. offline-secret"}
|
||||
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': icon},
|
||||
'notes': {'status': 'added'}}
|
||||
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False, steam, None,
|
||||
repaired=operation == 'repair')
|
||||
# A previous required Steam step may have left this action visible.
|
||||
app.retry_button.pack(anchor='w')
|
||||
app.events.put(('complete', result))
|
||||
_drain_events(app)
|
||||
assert float(app.progress['value']) == 100
|
||||
assert app.phase.get() == ('Steam info updated' if operation == 'library' else 'Complete')
|
||||
assert not app.retry_button.winfo_manager()
|
||||
expected_result = {'install': 'installed', 'repair': 'repaired', 'library': 'has been updated'}[operation]
|
||||
assert expected_result in app.status.get()
|
||||
popup_title, popup_message = app._test_dialogs[-1]
|
||||
assert popup_title == ('Steam info updated' if operation == 'library' else 'Setup complete')
|
||||
assert 'icon' not in app.status.get().lower() and 'icon' not in popup_message.lower()
|
||||
assert 'needs one more step' not in popup_message
|
||||
activity = '\n'.join(app.log_lines)
|
||||
assert 'Steam shortcut icon (optional): manual' in activity
|
||||
assert reason in activity and "Open Halo's library page before retrying the icon." in activity
|
||||
assert 'offline-secret' not in activity and '[redacted]' in activity
|
||||
assert result.steam['artwork']['icon'] == icon
|
||||
app.events.put(('clear_password',))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert not app.retry_button.winfo_manager() and _disabled(app.retry_button)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('operation', ['install', 'repair', 'library'])
|
||||
@pytest.mark.parametrize('pending', ['shortcut', 'artwork', 'notes', 'cleanup'])
|
||||
def test_optional_icon_does_not_hide_required_pending_work(app, operation, pending):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.operation = operation
|
||||
app.password_to_redact = 'offline-secret'
|
||||
steam = {'status': 'added',
|
||||
'artwork': {'status': 'added', 'icon': {'status': 'manual',
|
||||
'reason': 'Optional icon has not loaded. offline-secret'}},
|
||||
'notes': {'status': 'added'}}
|
||||
cleanup_warning = None
|
||||
if pending == 'shortcut':
|
||||
steam.update(status='manual', reason='Required Steam entry is missing. offline-secret')
|
||||
elif pending == 'artwork':
|
||||
steam['artwork'].update(status='manual', reason='Required banner is pending. offline-secret')
|
||||
elif pending == 'notes':
|
||||
steam['notes'].update(status='manual', message='Required Notes need a step. offline-secret')
|
||||
else:
|
||||
cleanup_warning = 'Connection cleanup is pending. offline-secret'
|
||||
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
||||
steam, None, repaired=operation == 'repair', cleanup_warning=cleanup_warning)))
|
||||
app.events.put(('clear_password',))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert float(app.progress['value']) == 99
|
||||
assert app.phase.get() == ('One more step in Steam' if pending == 'shortcut'
|
||||
else 'Connection cleanup needs attention' if pending == 'cleanup'
|
||||
else 'Ready to play · Steam info pending')
|
||||
assert bool(app.retry_button.winfo_manager()) is (pending != 'cleanup')
|
||||
assert 'Optional icon' not in app.status.get()
|
||||
assert 'Optional icon' not in app._test_dialogs[-1][1]
|
||||
expected_warning = {'shortcut': 'Required Steam entry is missing.',
|
||||
'artwork': 'Required banner is pending.',
|
||||
'notes': 'Required Notes need a step.',
|
||||
'cleanup': 'Connection cleanup is pending.'}[pending]
|
||||
assert expected_warning in app.status.get() and expected_warning in app._test_dialogs[-1][1]
|
||||
activity = '\n'.join(app.log_lines)
|
||||
assert 'Optional icon has not loaded.' in activity
|
||||
assert 'Click Add to Steam again to retry the library icon.' in activity
|
||||
assert 'offline-secret' not in app.status.get()
|
||||
assert 'offline-secret' not in app._test_dialogs[-1][1]
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_manual_steam_description_is_copyable_and_never_overrides_about_game(app):
|
||||
import tkinter as tk
|
||||
@@ -1110,6 +1382,7 @@ def test_preserved_steam_info_is_successful_without_manual_pending(app, icon_sta
|
||||
assert float(app.progress['value']) == 100
|
||||
assert app.phase.get() == 'Steam info updated'
|
||||
assert 'existing custom game note was kept' in app.status.get()
|
||||
assert 'Steam shortcut icon (optional): ' + icon_status in '\n'.join(app.log_lines)
|
||||
assert not app.notes_button.winfo_manager()
|
||||
|
||||
|
||||
@@ -1301,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:
|
||||
@@ -1319,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)
|
||||
@@ -1342,6 +1626,243 @@ 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)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('operation', ['install', 'library'])
|
||||
def test_completed_game_operation_reports_pending_connection_cleanup(app, operation):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.operation = operation
|
||||
app.password_to_redact = 'offline-secret'
|
||||
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
||||
{'status': 'added', 'notes': {'status': 'added'}}, None,
|
||||
cleanup_warning='Unplug the USB cable; cleanup is pending. offline-secret')
|
||||
app.events.put(('complete', result))
|
||||
app.events.put(('clear_password',))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert float(app.progress['value']) == 99
|
||||
assert app.phase.get() == 'Connection cleanup needs attention'
|
||||
assert 'game operation finished' in app.status.get()
|
||||
assert 'Unplug the USB cable' in app.status.get()
|
||||
assert not app.retry_button.winfo_manager()
|
||||
assert 'offline-secret' not in app.status.get()
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
assert app._test_dialogs[-1][0] == 'Connection cleanup needs attention'
|
||||
assert not app.busy and not app.installing
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('pending', [False, True])
|
||||
def test_uninstall_recovery_reports_retained_files_and_earlier_save_backups(app, pending):
|
||||
from halo_frame_installer.install import UninstallResult
|
||||
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a'*32
|
||||
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unverified'
|
||||
app.password_to_redact = 'offline-secret'
|
||||
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', not pending, False, None,
|
||||
{'status': 'removed'}, None, removal_pending=pending,
|
||||
removal_warning='Earlier uninstall files ' + ('were retained' if pending else 'were removed') + '. offline-secret',
|
||||
recovered_save_backup_paths=(backup,), retained_quarantine_paths=(retained,) if pending else ())
|
||||
app.events.put(('uninstall_complete', result))
|
||||
app.events.put(('clear_password',))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert float(app.progress['value']) == (99 if pending else 100)
|
||||
assert 'Earlier campaign save backup: ' + backup in app.status.get()
|
||||
assert 'already absent' not in app.status.get()
|
||||
assert 'offline-secret' not in app.status.get()
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
if pending:
|
||||
assert 'Preserved uninstall folder: ' + retained in app.status.get()
|
||||
assert 'Removal is not complete' in app.status.get()
|
||||
assert 'has been uninstalled' not in app.status.get()
|
||||
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
|
||||
else:
|
||||
assert 'has been uninstalled' in app.status.get()
|
||||
assert app._test_dialogs[-1][0] == 'Uninstall complete'
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_uninstalled_game_reports_pending_connection_cleanup(app):
|
||||
from halo_frame_installer.install import UninstallResult
|
||||
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', True, False, None,
|
||||
{'status': 'removed'}, None, cleanup_warning='Unplug the USB cable before retrying setup.')
|
||||
app.events.put(('uninstall_complete', result))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert float(app.progress['value']) == 99
|
||||
assert 'has been uninstalled' in app.status.get()
|
||||
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('message', [
|
||||
'The remote connection closed without reporting an exit status, so this step could not be verified. '
|
||||
'Keep the Frame awake and reconnect; the operation may still be running on it. Check its status before retrying.',
|
||||
'Remote stopping could not be confirmed; the build may still be running on the Frame.',
|
||||
])
|
||||
def test_unverified_remote_build_does_not_report_completion_or_encourage_immediate_retry(app, message):
|
||||
app._begin_setup_progress()
|
||||
app.operation = 'install'
|
||||
app.events.put(('error', message, False))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert app.phase.get() == 'Connection needs attention'
|
||||
assert 'status is unverified' in app.status.get()
|
||||
assert 'before retrying' in app.status.get()
|
||||
assert 'you can retry' not in app.status.get()
|
||||
assert float(app.progress['value']) < 100
|
||||
assert app._test_dialogs[-1] == ('Setup needs attention', message)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_active_previous_build_instructs_wait_or_cancel(app):
|
||||
app._begin_setup_progress()
|
||||
app.operation = 'install'
|
||||
app.events.put(('error', 'An installer build is still running on the Frame. No second build was started.', False))
|
||||
app.events.put(('idle',))
|
||||
_drain_events(app)
|
||||
assert app.phase.get() == 'Earlier build active'
|
||||
assert 'finish or cancel that build first' in app.status.get()
|
||||
assert 'you can retry' not in app.status.get()
|
||||
assert float(app.progress['value']) < 100
|
||||
|
||||
|
||||
@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)
|
||||
+910
-10
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
"""Large bundled helpers must survive SSH shell limits without remote temp files."""
|
||||
import io
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from halo_frame_installer.install import Installer, run_remote_source
|
||||
from halo_frame_installer.ssh import Settings, SSHError
|
||||
from test_storage_host import RESOURCES, SD_ID, StorageConnection, maps_fixture
|
||||
|
||||
|
||||
class PayloadConnection(StorageConnection):
|
||||
def __init__(self, settings, **kwargs):
|
||||
super().__init__(settings, **kwargs)
|
||||
self.inline_calls = []
|
||||
|
||||
def run(self, argv, **kwargs):
|
||||
if argv[1] == "-c":
|
||||
payload = kwargs["input_data"]
|
||||
assert isinstance(payload, bytes)
|
||||
# Check the actual SSH shell command as well as Python's -c argument.
|
||||
assert len(shlex.join(argv).encode("utf-8")) < 1024
|
||||
assert len(argv[2].encode("utf-8")) < 512
|
||||
assert self.settings.password not in shlex.join(argv)
|
||||
self.inline_calls.append((argv, payload, kwargs))
|
||||
else:
|
||||
assert "input_data" not in kwargs
|
||||
return super().run(argv, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["install", "repair", "library", "uninstall", "storage"])
|
||||
@pytest.mark.parametrize("storage_id", ["internal", SD_ID])
|
||||
def test_every_helper_delivery_path_uses_bounded_commands_and_complete_payload(tmp_path, operation, storage_id):
|
||||
settings = Settings("frame", "private", storage_id=storage_id,
|
||||
reinstall_existing=operation == "repair")
|
||||
connection = PayloadConnection(settings, existing=operation in ("repair", "library"))
|
||||
installer = Installer(lambda _: connection, RESOURCES)
|
||||
if operation in ("install", "repair"):
|
||||
installer.run(settings, maps_fixture(tmp_path) if operation == "install" else None)
|
||||
elif operation == "library":
|
||||
installer.add_to_steam(settings)
|
||||
elif operation == "uninstall":
|
||||
installer.uninstall(settings)
|
||||
else:
|
||||
installer.discover_storage(settings)
|
||||
assert connection.closed and connection.inline_calls
|
||||
assert all(isinstance(options["cancel_event"], threading.Event)
|
||||
and options["timeout"] == 90 for _, _, options in connection.inline_calls)
|
||||
if operation == "storage":
|
||||
assert connection.inline_calls[0][1] == (RESOURCES / "frame_storage.py").read_text(encoding="utf-8").encode()
|
||||
assert not connection.uploads
|
||||
else:
|
||||
source = installer.remote_source()
|
||||
# Reproduce the original per-string Linux failure with today's real resource.
|
||||
legacy_command = shlex.join(["python3", "-c", source, "preflight"])
|
||||
assert len(legacy_command.encode("utf-8")) >= 131072
|
||||
assert connection.inline_calls[0][1] == source.encode("utf-8")
|
||||
expected = "preflight-library" if operation == "library" else "preflight-uninstall" if operation == "uninstall" else "preflight"
|
||||
assert connection.inline_calls[0][0][3] == expected
|
||||
if operation == "repair":
|
||||
assert "--repair" in connection.inline_calls[0][0]
|
||||
if storage_id != "internal":
|
||||
assert len(connection.inline_calls) > 1
|
||||
assert all(argv[-2:] == ["--storage-id", SD_ID] for argv, _, _ in connection.inline_calls)
|
||||
assert all(b"resolve_storage" in payload for _, payload, _ in connection.inline_calls[1:])
|
||||
|
||||
|
||||
def test_real_bundled_helper_executes_intact_from_stdin_and_keeps_arguments(monkeypatch):
|
||||
source = Installer(resource_dir=RESOURCES).remote_source()
|
||||
arguments = ["preflight", "--repair", "--adopt-existing", "--storage-id", SD_ID]
|
||||
namespace = {"__name__": "remote_source_fixture"}
|
||||
if sys.platform == "win32":
|
||||
monkeypatch.setitem(sys.modules, "pwd", types.SimpleNamespace())
|
||||
monkeypatch.setitem(sys.modules, "frame_storage", types.ModuleType("prior_storage_fixture"))
|
||||
|
||||
class ExecuteConnection:
|
||||
def run(self, argv, **kwargs):
|
||||
monkeypatch.setattr(sys, "argv", ["-c", *argv[3:]])
|
||||
monkeypatch.setattr(sys, "stdin", types.SimpleNamespace(buffer=io.BytesIO(kwargs["input_data"])))
|
||||
exec(compile(argv[2], "<short SSH bootstrap>", "exec"), namespace)
|
||||
return "loaded"
|
||||
|
||||
assert run_remote_source(ExecuteConnection(), source, arguments) == "loaded"
|
||||
assert sys.argv == ["-c", *arguments]
|
||||
assert namespace["SOURCE_COMMIT"] == "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
|
||||
assert all(callable(namespace[name]) for name in ("preflight", "preflight_uninstall", "preflight_library", "main"))
|
||||
assert callable(namespace["storage_module"]().resolve_storage)
|
||||
assert namespace["_hfi_source"] == source.encode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alteration", ["truncated-valid-python", "different-same-length", "extra-bytes"])
|
||||
def test_helper_integrity_check_rejects_changed_or_truncated_source_before_execution(monkeypatch, alteration):
|
||||
source = "executed=True\n" + "# source padding\n" * 20000
|
||||
namespace = {"__name__": "integrity_fixture"}
|
||||
|
||||
class TamperedConnection:
|
||||
def run(self, argv, **kwargs):
|
||||
payload = kwargs["input_data"]
|
||||
if alteration == "truncated-valid-python":
|
||||
payload = b"executed=True\n"
|
||||
elif alteration == "different-same-length":
|
||||
payload = payload.replace(b"executed=True", b"executed=None", 1)
|
||||
else:
|
||||
payload += b"# excess input\n"
|
||||
monkeypatch.setattr(sys, "stdin", types.SimpleNamespace(buffer=io.BytesIO(payload)))
|
||||
exec(compile(argv[2], "<short SSH bootstrap>", "exec"), namespace)
|
||||
|
||||
with pytest.raises(SystemExit, match="HFI_ERROR Setup helper transfer was incomplete"):
|
||||
run_remote_source(TamperedConnection(), source)
|
||||
assert "executed" not in namespace
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["install", "repair", "uninstall"])
|
||||
def test_incomplete_preflight_transfer_stops_before_upload_or_mutation(tmp_path, operation):
|
||||
settings = Settings("frame", "private", reinstall_existing=operation == "repair")
|
||||
connection = PayloadConnection(settings, existing=operation == "repair")
|
||||
|
||||
def broken_transfer(argv, **kwargs):
|
||||
assert argv[1] == "-c" and len(kwargs["input_data"]) > 131072
|
||||
raise SSHError("Setup helper transfer was incomplete. Reconnect and retry.")
|
||||
|
||||
connection.run = broken_transfer
|
||||
installer = Installer(lambda _: connection, RESOURCES)
|
||||
with pytest.raises(SSHError, match="transfer was incomplete"):
|
||||
if operation == "uninstall":
|
||||
installer.uninstall(settings)
|
||||
else:
|
||||
installer.run(settings, maps_fixture(tmp_path) if operation == "install" else None)
|
||||
assert connection.closed and not connection.uploads
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Run the actual renderer entry point with real compilers and linker choices.
|
||||
|
||||
Opt-in local engine/tool paths keep this suite offline. No tool, SDL archive,
|
||||
retail game data, container, or headset is downloaded or accessed.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def renderer_runtime():
|
||||
location = os.environ.get("HFI_NATIVE_ENGINE_SOURCE")
|
||||
if not location:
|
||||
pytest.skip("Set HFI_NATIVE_ENGINE_SOURCE to the patched local engine for actual renderer CLI checks")
|
||||
source = Path(location).resolve(strict=True)
|
||||
script = source / "tools/test_vr_renderer_link.py"
|
||||
assert script.is_file(), "The supplied engine must contain the shipped renderer regression"
|
||||
cc = os.environ.get("HFI_RENDERER_CC") or shutil.which("clang")
|
||||
ld = os.environ.get("HFI_RENDERER_LD") or shutil.which("ld.lld")
|
||||
assert cc and ld, "Provide HFI_RENDERER_CC/HFI_RENDERER_LD with installed LLVM tools"
|
||||
cc, ld = Path(cc).resolve(strict=True), Path(ld).resolve(strict=True)
|
||||
sdl = os.environ.get("HFI_RENDERER_SDL")
|
||||
sdk = os.environ.get("HFI_RENDERER_SDK")
|
||||
if sdl:
|
||||
sdl = Path(sdl).resolve(strict=True)
|
||||
assert (sdl / "include/SDL3/SDL_opengl.h").is_file()
|
||||
elif sdk:
|
||||
sdk = Path(sdk).resolve(strict=True)
|
||||
assert (sdk / "GL/glcorearb.h").is_file()
|
||||
else:
|
||||
sdk = Path("/usr/include")
|
||||
assert (sdk / "GL/glcorearb.h").is_file(), "Provide existing local Khronos SDK or SDL source headers"
|
||||
return source, script, cc, ld, sdl, sdk
|
||||
|
||||
|
||||
def invoke(runtime, arguments, *, env=None):
|
||||
source, script, *_ = runtime
|
||||
return subprocess.run([sys.executable, str(script), *arguments], cwd=source,
|
||||
env=env, capture_output=True, text=True, timeout=90)
|
||||
|
||||
|
||||
def headers(runtime):
|
||||
return ["--sdl", str(runtime[4])] if runtime[4] else ["--sdk", str(runtime[5])]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("selection", ["path-names", "absolute-paths", "relative-paths", "linker-symlink", "cc-environment"])
|
||||
def test_actual_renderer_cli_resolves_tools_and_retains_real_arm_import_checks(renderer_runtime, tmp_path, selection):
|
||||
source, _, cc, ld, sdl, _ = renderer_runtime
|
||||
tool_dir = tmp_path / "tools"
|
||||
tool_dir.mkdir()
|
||||
linker_name = "ld.lld.exe" if os.name == "nt" else "ld.lld"
|
||||
alias = tool_dir / linker_name
|
||||
if selection == "linker-symlink":
|
||||
try:
|
||||
alias.symlink_to(ld)
|
||||
except OSError as error:
|
||||
pytest.skip("Creating a linker symlink is unavailable: " + str(error))
|
||||
else:
|
||||
os.link(ld, alias)
|
||||
# Keep clang in its original directory: LLVM derives its real resource
|
||||
# headers from the executable location. Only the linker alias is temporary.
|
||||
env = dict(os.environ, PATH=os.pathsep.join((str(tool_dir), str(cc.parent), os.environ.get("PATH", ""))))
|
||||
if selection in ("path-names", "cc-environment", "linker-symlink"):
|
||||
arguments = ["--cc", "clang", "--ld", "ld.lld"]
|
||||
elif selection == "absolute-paths":
|
||||
arguments = ["--cc", str(cc), "--ld", str(ld)]
|
||||
else:
|
||||
arguments = ["--cc", os.path.relpath(cc, source), "--ld", os.path.relpath(alias, source)]
|
||||
if selection == "cc-environment":
|
||||
env["CC"] = "clang"
|
||||
arguments = arguments[2:]
|
||||
arguments += headers(renderer_runtime)
|
||||
if selection == "relative-paths" and sdl:
|
||||
arguments[-1] = os.path.relpath(sdl, source)
|
||||
result = invoke(renderer_runtime, arguments, env=env)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
match = re.search(r"PASS: (\d+) renderer GL calls, real generated ARM guest imports/pointer loader, broken GL negative", result.stdout)
|
||||
assert match and int(match.group(1)) >= 90
|
||||
assert "PASS: removed unavailable weapon owner API and prototype-only negative" in result.stdout
|
||||
assert "Relocatable partial link only" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["missing-compiler", "missing-linker", "compiler-directory", "linker-directory",
|
||||
"missing-sdk", "missing-sdl", "incomplete-sdl"])
|
||||
def test_actual_renderer_cli_reports_tool_and_header_failures_without_traceback(renderer_runtime, tmp_path, failure):
|
||||
_, _, cc, ld, *_ = renderer_runtime
|
||||
arguments = ["--cc", str(cc), "--ld", str(ld), *headers(renderer_runtime)]
|
||||
if failure == "missing-compiler":
|
||||
arguments[1] = "halo-test-no-such-compiler"
|
||||
diagnostic = "C compiler was not found: halo-test-no-such-compiler"
|
||||
elif failure == "missing-linker":
|
||||
arguments[3] = "halo-test-no-such-linker"
|
||||
diagnostic = "GNU LLD linker was not found: halo-test-no-such-linker"
|
||||
elif failure in ("compiler-directory", "linker-directory"):
|
||||
arguments[1 if failure == "compiler-directory" else 3] = str(tmp_path)
|
||||
diagnostic = ("C compiler" if failure == "compiler-directory" else "GNU LLD linker") + " is not an executable file"
|
||||
else:
|
||||
arguments = arguments[:4] + ["--sdk", str(tmp_path / "no-sdk")]
|
||||
if failure == "missing-sdk":
|
||||
diagnostic = "Khronos GL headers were not found"
|
||||
else:
|
||||
arguments += ["--sdl", str(tmp_path / "no-sdl" if failure == "missing-sdl" else tmp_path)]
|
||||
diagnostic = "SDL GL declarations are unavailable"
|
||||
result = invoke(renderer_runtime, arguments)
|
||||
assert result.returncode == 2 and diagnostic in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert "PASS:" not in result.stdout
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Keep uninstall atomic when a native Steam record cannot be identified."""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
|
||||
from test_uninstall import RUN_ID, game_bytes, installation
|
||||
|
||||
|
||||
def config_bytes(configs):
|
||||
return {
|
||||
str(path): path.read_bytes()
|
||||
for config in configs
|
||||
for path in config.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["missing", "string", "zero", "below-shortcut-range"])
|
||||
@pytest.mark.parametrize("only_malformed", [False, True])
|
||||
@pytest.mark.parametrize("quoted", [False, True])
|
||||
def test_invalid_native_id_keeps_game_all_accounts_and_artwork(
|
||||
installation, invalid, only_malformed, quoted):
|
||||
remote, steam, configs, _, _ = installation
|
||||
executable = str(remote.GAME / "halo")
|
||||
if only_malformed:
|
||||
for config in configs:
|
||||
path = config / "shortcuts.vdf"
|
||||
root = steam.loads(path.read_bytes())
|
||||
del root["shortcuts"].value["1"]
|
||||
path.write_bytes(steam.dumps(root))
|
||||
|
||||
# Put the malformed record in the second account so the first account's
|
||||
# otherwise valid removal is planned before validation must refuse it.
|
||||
path = configs[1] / "shortcuts.vdf"
|
||||
root = steam.loads(path.read_bytes())
|
||||
fields = OrderedDict([
|
||||
("AppName", steam.Value(1, "Renamed native Halo")),
|
||||
("Exe", steam.Value(1, f'"{executable}"' if quoted else executable)),
|
||||
])
|
||||
if invalid != "missing":
|
||||
kind, value = {
|
||||
"string": (1, "not-a-number"),
|
||||
"zero": (2, 0),
|
||||
"below-shortcut-range": (2, 0x7FFFFFFF),
|
||||
}[invalid]
|
||||
fields["appid"] = steam.Value(kind, value)
|
||||
root["shortcuts"].value["3"] = steam.Value(0, fields)
|
||||
path.write_bytes(steam.dumps(root))
|
||||
before_game = game_bytes(remote)
|
||||
before_configs = config_bytes(configs)
|
||||
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
|
||||
assert not result["uninstalled"]
|
||||
assert result["steam"]["status"] == "manual"
|
||||
assert "invalid app ID" in result["steam"]["reason"]
|
||||
assert result["savedBackupPath"] is None
|
||||
assert game_bytes(remote) == before_game
|
||||
# This also proves no shortcut backups were published and no managed or
|
||||
# custom artwork was deleted before the second account failed validation.
|
||||
assert config_bytes(configs) == before_configs
|
||||
assert not list(remote.GAME.parent.glob("*HaloCENativeVR-saves-*"))
|
||||
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["leading-quote", "trailing-quote", "double-quotes", "suffix", "arguments"])
|
||||
def test_nonexact_executable_is_preserved_even_with_bad_id_and_native_title(installation, variant):
|
||||
remote, steam, configs, appids, _ = installation
|
||||
executable = str(remote.GAME / "halo")
|
||||
candidate = {
|
||||
"leading-quote": '"' + executable,
|
||||
"trailing-quote": executable + '"',
|
||||
"double-quotes": '""' + executable + '""',
|
||||
"suffix": executable + "-backup",
|
||||
"arguments": executable + " --custom",
|
||||
}[variant]
|
||||
path = configs[0] / "shortcuts.vdf"
|
||||
root = steam.loads(path.read_bytes())
|
||||
unrelated = OrderedDict([
|
||||
("appid", steam.Value(1, "not-a-number")),
|
||||
("AppName", steam.Value(1, steam.NAME)),
|
||||
("Exe", steam.Value(1, candidate)),
|
||||
("unknown", steam.Value(7, b"KEEPTHIS")),
|
||||
])
|
||||
root["shortcuts"].value["3"] = steam.Value(0, unrelated)
|
||||
path.write_bytes(steam.dumps(root))
|
||||
custom_art = (configs[1] / "grid" / f"{appids[1]}.png").read_bytes()
|
||||
foreign_art = (configs[0] / "grid/10p.jpg").read_bytes()
|
||||
|
||||
result = remote.uninstall(RUN_ID)
|
||||
|
||||
assert result["uninstalled"]
|
||||
assert result["steam"]["status"] == "removed"
|
||||
assert result["steam"]["accounts"] == ["10", "20"]
|
||||
remaining = steam.loads(path.read_bytes())["shortcuts"].value
|
||||
assert set(remaining) == {"0", "2", "3"}
|
||||
assert remaining["3"].value == unrelated
|
||||
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == custom_art
|
||||
assert (configs[0] / "grid/10p.jpg").read_bytes() == foreign_art
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Exercise the production sleep-lock lifecycle without invoking host services."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from test_build_progress import RUN_ID, events, remote, setup_build
|
||||
|
||||
|
||||
def fake_service(remote, monkeypatch, mode="ready", container=None):
|
||||
"""Run the actual Python lock holder behind a local fake service boundary."""
|
||||
original_popen = subprocess.Popen
|
||||
original_which = remote.shutil.which
|
||||
processes, launches = [], []
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: (
|
||||
"fake-systemd-inhibit" if name == "systemd-inhibit" else original_which(name)))
|
||||
|
||||
def launch(argv, **kwargs):
|
||||
if argv[0] != "fake-systemd-inhibit":
|
||||
assert container is not None
|
||||
return container(argv, **kwargs)
|
||||
launches.append((argv, kwargs.copy()))
|
||||
assert argv[1:7] == ["--what=idle:sleep", "--mode=block", "--who=Halo Frame Installer",
|
||||
"--why=Building the native Halo VR game", "--no-ask-password", sys.executable]
|
||||
assert kwargs == {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.DEVNULL, "start_new_session": True, "bufsize": 0}
|
||||
command = argv[6:]
|
||||
if mode == "denied":
|
||||
command = [sys.executable, "-u", "-c", "raise SystemExit(1)"]
|
||||
elif mode == "malformed":
|
||||
command = [sys.executable, "-u", "-c", "import sys; print('WRONG', flush=True); sys.stdin.buffer.read()"]
|
||||
elif mode == "timeout":
|
||||
command = [sys.executable, "-u", "-c", "import sys; sys.stdin.buffer.read()"]
|
||||
elif mode == "blocked":
|
||||
command = [sys.executable, "-u", "-c", "import time; time.sleep(30)"]
|
||||
process = original_popen(command, **kwargs)
|
||||
if mode == "blocked":
|
||||
process.real_wait = process.wait
|
||||
def failed_wait(timeout):
|
||||
raise subprocess.TimeoutExpired(command, timeout)
|
||||
process.wait = failed_wait
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
monkeypatch.setattr(remote.subprocess, "Popen", launch)
|
||||
return processes, launches
|
||||
|
||||
|
||||
def released(process):
|
||||
assert process.poll() is not None
|
||||
assert process.stdin.closed and process.stdout.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", ["success", "failure", "cancelled"])
|
||||
def test_real_holder_lives_inside_context_and_releases_for_every_exit(remote, monkeypatch, capsys, result):
|
||||
processes, launches = fake_service(remote, monkeypatch)
|
||||
try:
|
||||
with remote.build_sleep_inhibitor(lambda: None) as lock:
|
||||
assert lock is processes[0] and lock.poll() is None
|
||||
assert not lock.stdin.closed and not lock.stdout.closed
|
||||
if result != "success":
|
||||
raise RuntimeError(result)
|
||||
except RuntimeError as error:
|
||||
assert str(error) == result and result != "success"
|
||||
assert len(launches) == 1
|
||||
released(processes[0])
|
||||
updates = events(capsys)
|
||||
assert len(updates) == 1 and "temporarily blocked" in updates[0][1]["message"]
|
||||
assert "HFI_SLEEP_INHIBITOR_READY" not in updates[0][1]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["denied", "malformed", "timeout"])
|
||||
def test_denied_bad_or_unresponsive_service_releases_before_fallback(remote, monkeypatch, capsys, mode):
|
||||
processes, _ = fake_service(remote, monkeypatch, mode)
|
||||
if mode == "timeout":
|
||||
monkeypatch.setattr(remote, "SLEEP_INHIBITOR_START_SECONDS", 0.05)
|
||||
with remote.build_sleep_inhibitor(lambda: None) as lock:
|
||||
assert lock is None
|
||||
released(processes[0])
|
||||
assert "Keep the Frame awake" in events(capsys)[0][1]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unavailable", ["absent", "spawn-failed"])
|
||||
def test_unavailable_inhibitor_does_not_prevent_the_build(remote, monkeypatch, capsys, unavailable):
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: None if unavailable == "absent" else "missing-service")
|
||||
def unavailable_process(*args, **kwargs):
|
||||
raise OSError("service unavailable")
|
||||
monkeypatch.setattr(remote.subprocess, "Popen", unavailable_process)
|
||||
with remote.build_sleep_inhibitor(lambda: None) as lock:
|
||||
assert lock is None
|
||||
assert "sleep prevention is unavailable" in events(capsys)[0][1]["message"]
|
||||
|
||||
|
||||
def test_startup_cancellation_closes_holder_without_entering_build(remote, monkeypatch):
|
||||
processes, _ = fake_service(remote, monkeypatch, "timeout")
|
||||
def cancelled():
|
||||
raise RuntimeError("Build cancelled")
|
||||
entered = []
|
||||
with pytest.raises(RuntimeError, match="Build cancelled"):
|
||||
with remote.build_sleep_inhibitor(cancelled):
|
||||
entered.append(True)
|
||||
assert not entered
|
||||
released(processes[0])
|
||||
|
||||
|
||||
def test_failed_startup_cleanup_is_bounded_even_while_ready_reader_is_blocked(remote, monkeypatch):
|
||||
processes, _ = fake_service(remote, monkeypatch, "blocked")
|
||||
monkeypatch.setattr(remote, "SLEEP_INHIBITOR_START_SECONDS", 0.05)
|
||||
def foreign_group(process):
|
||||
raise ValueError("owned private process group changed")
|
||||
monkeypatch.setattr(remote, "stop_build_process", foreign_group)
|
||||
results = []
|
||||
def attempt():
|
||||
try:
|
||||
with remote.build_sleep_inhibitor(lambda: None):
|
||||
results.append("unexpectedly entered build")
|
||||
except BaseException as error:
|
||||
results.append(error)
|
||||
operation = threading.Thread(target=attempt, daemon=True)
|
||||
operation.start()
|
||||
try:
|
||||
operation.join(timeout=3)
|
||||
assert not operation.is_alive(), "closing stdout waited for the blocked READY reader"
|
||||
assert len(results) == 1 and isinstance(results[0], ValueError)
|
||||
assert "owned private process group" in str(results[0])
|
||||
assert processes[0].stdin.closed
|
||||
finally:
|
||||
# This local fake service deliberately ignored stdin and was declared
|
||||
# foreign by the mock. The test itself owns and reaps its real child.
|
||||
for process in processes:
|
||||
process.kill()
|
||||
process.real_wait(timeout=3)
|
||||
operation.join(timeout=3)
|
||||
# Once its read ends, the reader itself releases the remaining pipe.
|
||||
assert processes[0]._hfi_sleep_read_done.wait(timeout=3)
|
||||
|
||||
|
||||
def test_private_stdin_eof_releases_holder_on_parent_pipe_loss(remote, monkeypatch):
|
||||
processes, _ = fake_service(remote, monkeypatch)
|
||||
with remote.build_sleep_inhibitor(lambda: None):
|
||||
# Parent death closes its sole writer too; the holder needs no heartbeat
|
||||
# timer or persistent systemd/power configuration to release the lock.
|
||||
process = processes[0]
|
||||
process.stdin.close()
|
||||
assert process.wait(timeout=3) == 0
|
||||
released(process)
|
||||
|
||||
|
||||
def test_hung_release_uses_only_the_owned_process_group_cleanup(remote, monkeypatch):
|
||||
waits, stopped = [], []
|
||||
def wait(timeout):
|
||||
waits.append(timeout)
|
||||
raise subprocess.TimeoutExpired("fake-systemd-inhibit", timeout)
|
||||
process = types.SimpleNamespace(stdin=io.BytesIO(), stdout=io.BytesIO(), wait=wait)
|
||||
monkeypatch.setattr(remote, "stop_build_process", lambda child: stopped.append(child))
|
||||
remote.release_sleep_inhibitor(process)
|
||||
assert waits == [3] and stopped == [process]
|
||||
assert process.stdin.closed and process.stdout.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("foreign_group", [False, True])
|
||||
def test_actual_group_cleanup_rechecks_identity_before_forcing_release(remote, monkeypatch, foreign_group):
|
||||
waits, signals = [], []
|
||||
def wait(timeout):
|
||||
waits.append(timeout)
|
||||
if len(waits) <= 2:
|
||||
raise subprocess.TimeoutExpired("fake-systemd-inhibit", timeout)
|
||||
return -9
|
||||
process = types.SimpleNamespace(pid=12345, stdin=io.BytesIO(), stdout=io.BytesIO(),
|
||||
poll=lambda: None, wait=wait)
|
||||
monkeypatch.setattr(remote.os, "getpgid", lambda pid: pid + 1 if foreign_group else pid, raising=False)
|
||||
monkeypatch.setattr(remote.os, "killpg", lambda pid, sig: signals.append((pid, sig)), raising=False)
|
||||
monkeypatch.setattr(remote.signal, "SIGKILL", 9, raising=False)
|
||||
if foreign_group:
|
||||
with pytest.raises(ValueError, match="owned private process group"):
|
||||
remote.release_sleep_inhibitor(process)
|
||||
assert waits == [3] and not signals
|
||||
else:
|
||||
remote.release_sleep_inhibitor(process)
|
||||
assert waits == [3, 10, 10]
|
||||
assert signals == [(process.pid, remote.signal.SIGTERM), (process.pid, remote.signal.SIGKILL)]
|
||||
assert process.stdin.closed and process.stdout.closed
|
||||
|
||||
|
||||
def test_release_error_preserves_the_original_build_error(remote, monkeypatch):
|
||||
processes, _ = fake_service(remote, monkeypatch)
|
||||
release = remote.release_sleep_inhibitor
|
||||
def unconfirmed(process):
|
||||
release(process)
|
||||
raise RuntimeError("owned process group changed")
|
||||
monkeypatch.setattr(remote, "release_sleep_inhibitor", unconfirmed)
|
||||
with pytest.raises(RuntimeError, match="original build failure") as caught:
|
||||
with remote.build_sleep_inhibitor(lambda: None):
|
||||
raise RuntimeError("original build failure")
|
||||
assert "sleep lock" in caught.value.__notes__[0]
|
||||
released(processes[0])
|
||||
|
||||
|
||||
def write_binary(directory):
|
||||
build_files = directory / "src/build/linux_arm64"
|
||||
build_files.mkdir(parents=True)
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
struct.pack_into("<H", header, 18, 183)
|
||||
(build_files / "halo").write_bytes(header)
|
||||
(build_files / "libSDL3.so.0").write_bytes(b"synthetic SDL3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", ["success", "failure", "cancelled", "spawn-failed", "drain-failed"])
|
||||
def test_actual_build_holds_lock_through_log_drain_and_releases(remote, monkeypatch, capsys, result):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
stopped, cancelled, drained = [], [], []
|
||||
def container(argv, **kwargs):
|
||||
assert argv[:2] == ["podman", "run"]
|
||||
assert processes[0].poll() is None
|
||||
if result == "spawn-failed":
|
||||
raise OSError("podman spawn failure")
|
||||
kwargs["stdout"].write(b"HFI_BUILD_PHASE compile Compiling game\nlast build fragment")
|
||||
if result == "success":
|
||||
write_binary(directory)
|
||||
code = 100 if result == "failure" else 0
|
||||
def poll():
|
||||
if result == "cancelled":
|
||||
(directory / "cancelled").touch()
|
||||
return None
|
||||
return code
|
||||
return types.SimpleNamespace(returncode=code, poll=poll)
|
||||
processes, _ = fake_service(remote, monkeypatch, container=container)
|
||||
original_drain = remote.BuildLogMonitor.drain
|
||||
def drain(monitor):
|
||||
assert processes[0].poll() is None
|
||||
drained.append(True)
|
||||
original_drain(monitor)
|
||||
if result == "drain-failed":
|
||||
raise RuntimeError("log drain failure")
|
||||
monkeypatch.setattr(remote.BuildLogMonitor, "drain", drain)
|
||||
monkeypatch.setattr(remote, "cancel", lambda run: cancelled.append(run))
|
||||
monkeypatch.setattr(remote, "stop_build_process", lambda process: stopped.append(process))
|
||||
if result == "success":
|
||||
assert remote.build(RUN_ID)["built"]
|
||||
else:
|
||||
message = {"failure": "Native build failed", "cancelled": "Build cancelled",
|
||||
"spawn-failed": "podman spawn failure", "drain-failed": "log drain failure"}[result]
|
||||
with pytest.raises((RuntimeError, OSError), match=message):
|
||||
remote.build(RUN_ID)
|
||||
released(processes[0])
|
||||
assert bool(drained) == (result != "spawn-failed")
|
||||
assert bool(stopped) == bool(cancelled) == (result == "cancelled")
|
||||
if result != "spawn-failed":
|
||||
assert any(prefix == "HFI_LOG" and event["message"] == "last build fragment"
|
||||
for prefix, event in events(capsys))
|
||||
assert not remote.GAME.exists()
|
||||
|
||||
|
||||
def test_actual_build_warns_once_if_acquired_lock_ends_unexpectedly(remote, monkeypatch, capsys):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
polls = []
|
||||
def container(argv, **kwargs):
|
||||
assert processes[0].poll() is None
|
||||
processes[0].stdin.close()
|
||||
processes[0].wait(timeout=3)
|
||||
kwargs["stdout"].write(b"HFI_BUILD_PHASE compile Compiling game\n")
|
||||
write_binary(directory)
|
||||
def poll():
|
||||
polls.append(True)
|
||||
return 0 if len(polls) == 3 else None
|
||||
return types.SimpleNamespace(returncode=0, poll=poll)
|
||||
processes, _ = fake_service(remote, monkeypatch, container=container)
|
||||
assert remote.build(RUN_ID)["built"]
|
||||
updates = [event for prefix, event in events(capsys) if prefix == "HFI_PROGRESS"]
|
||||
assert sum("ended unexpectedly" in event["message"] for event in updates) == 1
|
||||
released(processes[0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists_code", [0, 1, 125, 2, -1])
|
||||
def test_stop_owned_container_distinguishes_absence_from_check_failure(remote, monkeypatch, exists_code):
|
||||
calls, stops = [], []
|
||||
labels = {"org.halo-frame-installer.owner": remote.OWNER, "org.halo-frame-installer.run": RUN_ID}
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
return types.SimpleNamespace(returncode=exists_code if argv[2] == "exists" else 0,
|
||||
stdout=json.dumps([{"Config": {"Labels": labels}}]))
|
||||
monkeypatch.setattr(remote.subprocess, "run", run)
|
||||
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: stops.append(argv))
|
||||
if exists_code in (0, 1):
|
||||
assert remote.stop_owned_container(RUN_ID) == {"cancelled": True}
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="presence could not be verified"):
|
||||
remote.stop_owned_container(RUN_ID)
|
||||
assert calls[0] == ["podman", "container", "exists", "halo-frame-installer-" + RUN_ID]
|
||||
assert len(calls) == (2 if exists_code == 0 else 1)
|
||||
assert stops == ([["podman", "stop", "--time", "10", "halo-frame-installer-" + RUN_ID]] if exists_code == 0 else [])
|
||||
|
||||
|
||||
def test_inspect_failure_after_confirmed_container_is_not_reported_as_stopped(remote, monkeypatch):
|
||||
stops = []
|
||||
monkeypatch.setattr(remote.subprocess, "run", lambda argv, **kwargs:
|
||||
types.SimpleNamespace(returncode=0 if argv[2] == "exists" else 125, stdout=""))
|
||||
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: stops.append(argv))
|
||||
with pytest.raises(RuntimeError, match="could not be inspected"):
|
||||
remote.stop_owned_container(RUN_ID)
|
||||
assert not stops
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", ["[]", '[{"Names": ["old installer"]}]', "{}", "null", "not json"])
|
||||
def test_actual_active_build_gate_blocks_before_source_or_container_creation(remote, monkeypatch, response):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
calls = []
|
||||
def command(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
assert argv[:2] == ["podman", "ps"]
|
||||
assert "label=org.halo-frame-installer.owner=" + remote.OWNER in argv
|
||||
return response
|
||||
monkeypatch.setattr(remote, "command", command)
|
||||
if response == "[]":
|
||||
remote.no_active_build()
|
||||
else:
|
||||
with pytest.raises(ValueError):
|
||||
remote.build(RUN_ID)
|
||||
assert not (directory / "src").exists()
|
||||
assert not (directory / "native-build.log").exists()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_active_build_query_failure_is_not_assumed_to_mean_no_build(remote, monkeypatch):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
def failed(argv, **kwargs):
|
||||
raise RuntimeError("podman storage unavailable")
|
||||
monkeypatch.setattr(remote, "command", failed)
|
||||
with pytest.raises(ValueError, match="safely check for an active installer build"):
|
||||
remote.build(RUN_ID)
|
||||
assert not (directory / "src").exists()
|
||||
|
||||
|
||||
def test_build_rechecks_prior_container_after_source_fetch_and_releases_lock(remote, monkeypatch):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
source_command = remote.command
|
||||
active, checked = [], []
|
||||
def command(argv, **kwargs):
|
||||
if argv[:2] == ["podman", "ps"]:
|
||||
checked.append(True)
|
||||
if active:
|
||||
assert processes[0].poll() is None
|
||||
return '[{"Names":["older installer run"]}]'
|
||||
return "[]"
|
||||
if "fetch" in argv:
|
||||
active.append(True)
|
||||
return source_command(argv, **kwargs)
|
||||
monkeypatch.setattr(remote, "command", command)
|
||||
def container(*args, **kwargs):
|
||||
pytest.fail("A second build container must not start")
|
||||
processes, _ = fake_service(remote, monkeypatch, container=container)
|
||||
with pytest.raises(ValueError, match="No second build was started"):
|
||||
remote.build(RUN_ID)
|
||||
assert len(checked) == 2 and (directory / "src").is_dir()
|
||||
assert not remote.GAME.exists()
|
||||
released(processes[0])
|
||||
|
||||
|
||||
def test_missing_podman_refuses_retry_without_creating_any_source(remote, monkeypatch):
|
||||
directory = setup_build(remote, monkeypatch)
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: None)
|
||||
with pytest.raises(ValueError, match="active installer build cannot be checked"):
|
||||
remote.build(RUN_ID)
|
||||
assert not (directory / "src").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["exists", "inspect"])
|
||||
def test_container_query_timeout_never_reports_successful_stop(remote, monkeypatch, failure):
|
||||
stops = []
|
||||
def run(argv, **kwargs):
|
||||
if argv[2] == failure:
|
||||
raise subprocess.TimeoutExpired(argv, kwargs["timeout"])
|
||||
return types.SimpleNamespace(returncode=0, stdout="")
|
||||
monkeypatch.setattr(remote.subprocess, "run", run)
|
||||
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: stops.append(argv))
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
remote.stop_owned_container(RUN_ID)
|
||||
assert not stops
|
||||
+898
-2
@@ -1,10 +1,346 @@
|
||||
import threading
|
||||
from collections import deque
|
||||
from unittest.mock import Mock
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from halo_frame_installer.ssh import Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
|
||||
from halo_frame_installer.ssh import CancelledError, ConnectionLostError, RemoteTimeoutError, Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
|
||||
|
||||
|
||||
class FragmentedChannel:
|
||||
"""A channel whose packets represent stdout, stderr, idle polls or EOF.
|
||||
|
||||
Exit status is available only after all queued packets have been drained.
|
||||
None leaves a live channel waiting; -1 closes it without a server status.
|
||||
"""
|
||||
def __init__(self, packets, status=0):
|
||||
self.packets = deque(packets)
|
||||
self.status = status
|
||||
self.combined = False
|
||||
self.close_calls = 0
|
||||
|
||||
def set_combine_stderr(self, combined):
|
||||
self.combined = combined
|
||||
|
||||
def recv_ready(self):
|
||||
return bool(self.packets and self.packets[0] is not None)
|
||||
|
||||
def recv(self, count):
|
||||
stream, data = self.packets.popleft()
|
||||
assert stream in ('stdout', 'stderr')
|
||||
assert stream != 'stderr' or self.combined
|
||||
if len(data) > count:
|
||||
self.packets.appendleft((stream, data[count:]))
|
||||
return data[:count]
|
||||
|
||||
def exit_status_ready(self):
|
||||
if self.packets and self.packets[0] is None:
|
||||
self.packets.popleft()
|
||||
return False
|
||||
return not self.packets and self.status is not None
|
||||
|
||||
def recv_exit_status(self):
|
||||
assert self.exit_status_ready()
|
||||
return self.status
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
def fragmented_connection(monkeypatch, packets, status=0):
|
||||
channel = FragmentedChannel(packets, status)
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
return SSHConnection(Settings('frame', 'private')), channel
|
||||
|
||||
|
||||
class InputChannel(FragmentedChannel):
|
||||
"""Emulate SSH backpressure, short writes, and a helper awaiting stdin EOF."""
|
||||
def __init__(self, *, read_input=True):
|
||||
super().__init__([('stdout', b'HFI_LOG {"stage":"preflight","message":"Receiving helper"}\n')])
|
||||
self.read_input = read_input
|
||||
self.received = bytearray()
|
||||
self.eof = False
|
||||
self.ready_polls = 0
|
||||
self.writes = []
|
||||
|
||||
def send_ready(self):
|
||||
self.ready_polls += 1
|
||||
return self.read_input and self.ready_polls % 3 != 0
|
||||
|
||||
def send(self, data):
|
||||
assert not self.eof and 0 < len(data) <= 16384
|
||||
self.writes.append(len(data))
|
||||
count = min(len(data), 997)
|
||||
self.received.extend(data[:count])
|
||||
return count
|
||||
|
||||
def shutdown_write(self):
|
||||
assert not self.eof
|
||||
self.eof = True
|
||||
self.packets.append(('stdout', b'HFI_RESULT {"verified":true}\n'))
|
||||
|
||||
def exit_status_ready(self):
|
||||
return self.eof and super().exit_status_ready()
|
||||
|
||||
|
||||
def input_connection(monkeypatch, channel):
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
return SSHConnection(Settings('frame', 'private'))
|
||||
|
||||
|
||||
def test_large_real_helper_streams_short_writes_and_backpressure_before_stdin_eof(monkeypatch):
|
||||
from halo_frame_installer.install import Installer, run_remote_source
|
||||
from halo_frame_installer import ssh
|
||||
source = Installer().remote_source()
|
||||
assert len(source.encode()) > 131072
|
||||
channel = InputChannel()
|
||||
connection = input_connection(monkeypatch, channel)
|
||||
monkeypatch.setattr(ssh.time, 'sleep', lambda seconds: None)
|
||||
lines = []
|
||||
result = run_remote_source(connection, source, ['preflight'], progress=lines.append)
|
||||
assert channel.received == source.encode('utf-8') and channel.eof
|
||||
assert len(channel.writes) > 100 and channel.ready_polls > len(channel.writes)
|
||||
assert result.endswith('HFI_RESULT {"verified":true}\n')
|
||||
assert lines[0].startswith('HFI_LOG ') and lines[-1].startswith('HFI_RESULT ')
|
||||
assert channel.close_calls == 1 and not connection._command_channels
|
||||
assert len(connection.client.exec_command.call_args.args[0].encode()) < 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', ['cancel', 'timeout', 'closed-input'])
|
||||
def test_stalled_or_closed_helper_input_keeps_cancellation_timeout_and_cleanup(monkeypatch, reason):
|
||||
from halo_frame_installer import ssh
|
||||
channel = InputChannel(read_input=reason == 'closed-input')
|
||||
if reason == 'closed-input':
|
||||
channel.send = lambda data: 0
|
||||
connection = input_connection(monkeypatch, channel)
|
||||
now = [0.0]
|
||||
cancel = threading.Event()
|
||||
def advance(seconds):
|
||||
now[0] += .1
|
||||
if reason == 'cancel':
|
||||
cancel.set()
|
||||
monkeypatch.setattr(ssh.time, 'monotonic', lambda: now[0])
|
||||
monkeypatch.setattr(ssh.time, 'sleep', advance)
|
||||
on_cancel = Mock()
|
||||
expected = CancelledError if reason == 'cancel' else RemoteTimeoutError if reason == 'timeout' else ConnectionLostError
|
||||
with pytest.raises(expected):
|
||||
connection.run(['python3', '-c', 'bootstrap'], input_data=b'x' * 300000,
|
||||
cancel_event=cancel, timeout=.2, on_cancel=on_cancel)
|
||||
assert channel.close_calls == 1 and not channel.eof and not channel.received
|
||||
if reason != 'closed-input':
|
||||
on_cancel.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', [0, 127])
|
||||
def test_early_remote_exit_before_helper_input_cannot_look_successful_or_hide_diagnostic(monkeypatch, status):
|
||||
diagnostic = b'python3: command not found\n' if status else b'HFI_RESULT {"verified":true}\n'
|
||||
connection, channel = fragmented_connection(monkeypatch, [('stderr', diagnostic)], status=status)
|
||||
channel.exit_status_ready = lambda: True
|
||||
channel.send_ready = lambda: pytest.fail('Must preserve an already available remote exit status')
|
||||
expected = SSHError if status else ConnectionLostError
|
||||
message = 'command not found' if status else 'transfer was complete'
|
||||
with pytest.raises(expected, match=message):
|
||||
connection.run(['python3', '-c', 'bootstrap'], input_data=b'x' * 300000)
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('delivery', ['large-helper', 'no-input'])
|
||||
def test_actual_paramiko_socketpair_streams_large_helper_and_receives_eof_before_success(delivery):
|
||||
"""Exercise real SSH windows/EOF; the local server never executes the helper."""
|
||||
import hashlib
|
||||
import json
|
||||
import shlex
|
||||
import socket
|
||||
from halo_frame_installer.install import Installer, run_remote_source
|
||||
|
||||
source = Installer().remote_source()
|
||||
expected = source.encode('utf-8') if delivery == 'large-helper' else b''
|
||||
accepted, finished = threading.Event(), threading.Event()
|
||||
recorded, failures = {}, []
|
||||
|
||||
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):
|
||||
recorded['argv'] = shlex.split(command.decode())
|
||||
recorded['channel'] = channel
|
||||
accepted.set()
|
||||
return True
|
||||
|
||||
left, right = socket.socketpair()
|
||||
server_transport = paramiko.Transport(left, default_window_size=32768)
|
||||
client_transport = paramiko.Transport(right)
|
||||
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
|
||||
|
||||
def receive_source():
|
||||
try:
|
||||
assert accepted.wait(3)
|
||||
channel = recorded['channel']
|
||||
channel.settimeout(3)
|
||||
chunks = []
|
||||
while True:
|
||||
data = channel.recv(7919)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
recorded['payload'] = b''.join(chunks)
|
||||
assert recorded['payload'] == expected
|
||||
assert len(shlex.join(recorded['argv']).encode()) < 1024
|
||||
response = {'sha256': hashlib.sha256(recorded['payload']).hexdigest(), 'eof': True}
|
||||
channel.sendall(('HFI_RESULT ' + json.dumps(response) + '\n').encode())
|
||||
channel.send_exit_status(0)
|
||||
channel.close()
|
||||
except BaseException as error:
|
||||
failures.append(error)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
receiver = threading.Thread(target=receive_source, name='test-only-helper-receiver', daemon=True)
|
||||
receiver.start()
|
||||
try:
|
||||
result = (run_remote_source(connection, source, ['preflight', '--repair'], timeout=5)
|
||||
if delivery == 'large-helper' else connection.run(['no-input-fixture'], timeout=5))
|
||||
assert finished.wait(2) and not failures
|
||||
assert json.loads(result.split(' ', 1)[1]) == {'sha256': hashlib.sha256(expected).hexdigest(), 'eof': True}
|
||||
if delivery == 'large-helper':
|
||||
assert recorded['argv'][3:] == ['preflight', '--repair']
|
||||
else:
|
||||
assert recorded['argv'] == ['no-input-fixture']
|
||||
assert not connection._command_channels
|
||||
finally:
|
||||
connection.close()
|
||||
server_transport.close()
|
||||
receiver.join(timeout=2)
|
||||
client_transport.join(timeout=2)
|
||||
server_transport.join(timeout=2)
|
||||
|
||||
|
||||
def noisy_compile_output():
|
||||
# More than the response tail limit, with the byte cutoff inside a record.
|
||||
line = b'HFI_LOG {"stage":"compile","message":"[444/1613] mincore.o ' + b'x' * 997 + b'"}\n'
|
||||
output = line * 400
|
||||
assert output[-256 * 1024:].splitlines()[0] != line.rstrip(b'\n')
|
||||
return output
|
||||
|
||||
|
||||
def test_response_tail_does_not_turn_a_truncated_activity_record_into_an_error(monkeypatch):
|
||||
connection, channel = fragmented_connection(monkeypatch, [('stdout', noisy_compile_output())], status=1)
|
||||
with pytest.raises(SSHError) as error:
|
||||
connection.run(['helper'])
|
||||
assert str(error.value) == 'Remote step failed (exit 1).'
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('final_packet', [b'', b'HFI_LOG {"stage":"compile","message":"link'])
|
||||
def test_closed_channel_without_exit_status_is_unverified_not_a_compiler_failure(monkeypatch, final_packet):
|
||||
output = noisy_compile_output()
|
||||
connection, channel = fragmented_connection(monkeypatch, [('stdout', output), ('stdout', final_packet)], status=-1)
|
||||
lines = []
|
||||
with pytest.raises(ConnectionLostError, match='without reporting an exit status') as error:
|
||||
connection.run(['helper'], progress=lines.append)
|
||||
assert all(line.startswith('HFI_LOG ') and line.endswith('"}') for line in lines)
|
||||
assert 'mincore.o' not in str(error.value) and 'private' not in str(error.value)
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
def test_fragmented_stdout_stderr_idle_and_final_eof_preserve_activity_and_result(monkeypatch):
|
||||
output = 'HFI_LOG {"stage":"compile","message":"Compiled café"}\n'.encode()
|
||||
split = output.index('é'.encode()) + 1
|
||||
connection, channel = fragmented_connection(monkeypatch, [
|
||||
('stdout', output[:13]), None, ('stdout', output[13:split]),
|
||||
('stdout', output[split:]), ('stderr', b'compiler warning\n'),
|
||||
('stdout', b'HFI_RES'), None, ('stdout', b'ULT {"built":true}\n'), ('stdout', b'')])
|
||||
lines = []
|
||||
result = connection.run(['helper'], progress=lines.append)
|
||||
assert lines == ['HFI_LOG {"stage":"compile","message":"Compiled café"}',
|
||||
'compiler warning', 'HFI_RESULT {"built":true}']
|
||||
assert result == (output + b'compiler warning\nHFI_RESULT {"built":true}\n').decode()
|
||||
assert channel.combined and channel.close_calls == 1
|
||||
|
||||
|
||||
def test_bounded_response_keeps_complete_result_after_fragmented_compile_output(monkeypatch):
|
||||
output = noisy_compile_output() + b'HFI_RESULT {"built":true}\n'
|
||||
packets = [('stdout', output[index:index + 7919]) for index in range(0, len(output), 7919)]
|
||||
connection, channel = fragmented_connection(monkeypatch, packets)
|
||||
lines = []
|
||||
result = connection.run(['helper'], progress=lines.append)
|
||||
assert len(result.encode()) <= 256 * 1024
|
||||
assert result.startswith('HFI_LOG ') and result.endswith('HFI_RESULT {"built":true}\n')
|
||||
assert len(lines) == 401 and channel.close_calls == 1
|
||||
|
||||
|
||||
def test_stderr_failure_after_bounded_activity_retains_summary_and_diagnostic(monkeypatch):
|
||||
connection, channel = fragmented_connection(monkeypatch, [
|
||||
('stdout', noisy_compile_output()), ('stderr', b'HFI_ER'),
|
||||
('stderr', b'ROR Native build failed.\nld.lld: undefined sym'),
|
||||
('stderr', b'bol: private\n')], status=1)
|
||||
with pytest.raises(SSHError) as error:
|
||||
connection.run(['helper'])
|
||||
assert str(error.value) == 'Native build failed.\nld.lld: undefined symbol: [redacted]'
|
||||
assert not isinstance(error.value, ConnectionLostError)
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('packet_size', [7919, 65536])
|
||||
def test_oversized_utf8_protocol_record_is_discarded_whole_and_next_record_parses(monkeypatch, packet_size):
|
||||
from halo_frame_installer.install import parse_activity
|
||||
huge = b'HFI_LOG {"stage":"compile","message":"' + 'é'.encode() * (600 * 1024) + b'"}\n'
|
||||
valid = 'HFI_LOG {"stage":"compile","message":"Finished café"}\nHFI_RESULT {"built":true}\n'
|
||||
output = huge + valid.encode()
|
||||
packets = [('stdout', output[index:index + packet_size]) for index in range(0, len(output), packet_size)]
|
||||
connection, channel = fragmented_connection(monkeypatch, packets)
|
||||
activities = []
|
||||
result = connection.run(['helper'], progress=lambda line: activities.append(parse_activity(line)))
|
||||
assert activities == [('detail', 'compile: Finished café', None), None]
|
||||
assert result == valid and channel.close_calls == 1
|
||||
|
||||
|
||||
def test_verified_exit_with_final_unterminated_utf8_line_preserves_the_complete_record(monkeypatch):
|
||||
output = 'HFI_LOG {"stage":"compile","message":"Finished café"}'.encode()
|
||||
split = output.index('é'.encode()) + 1
|
||||
connection, channel = fragmented_connection(monkeypatch, [('stdout', output[:split]), ('stdout', output[split:])])
|
||||
lines = []
|
||||
assert connection.run(['helper'], progress=lines.append) == output.decode()
|
||||
assert lines == [output.decode()] and channel.close_calls == 1
|
||||
|
||||
|
||||
def test_result_record_without_server_exit_status_remains_unverified(monkeypatch):
|
||||
connection, channel = fragmented_connection(monkeypatch, [('stdout', b'HFI_RESULT {"built":true}\n')], status=-1)
|
||||
with pytest.raises(ConnectionLostError, match='could not be verified'):
|
||||
connection.run(['helper'])
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('readable', [False, True])
|
||||
def test_fragmented_read_loop_deadline_stays_a_timeout(monkeypatch, readable):
|
||||
from halo_frame_installer import ssh
|
||||
connection, channel = fragmented_connection(monkeypatch, [], status=None)
|
||||
now = [0.0]
|
||||
monkeypatch.setattr(ssh.time, 'monotonic', lambda: now[0])
|
||||
monkeypatch.setattr(ssh.time, 'sleep', lambda seconds: now.__setitem__(0, now[0] + seconds))
|
||||
if readable:
|
||||
def continuous_output(count):
|
||||
now[0] += .08
|
||||
return b'HFI_LOG {"stage":"compile","message":"Working"}\n'
|
||||
channel.recv_ready = lambda: True
|
||||
channel.recv = continuous_output
|
||||
on_cancel = Mock()
|
||||
with pytest.raises(RemoteTimeoutError, match='custom deadline'):
|
||||
connection.run(['helper'], timeout=.2, timeout_message='custom deadline', on_cancel=on_cancel)
|
||||
on_cancel.assert_called_once()
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["192.168.1.12", "frame.local", "frame", "::1"])
|
||||
@@ -147,6 +483,33 @@ def test_remote_failure_shows_diagnostic_without_progress_protocol(monkeypatch,
|
||||
channel.close.assert_called_once()
|
||||
|
||||
|
||||
def test_long_remote_failure_keeps_summary_and_final_linker_diagnostic(monkeypatch):
|
||||
summary = 'Native build failed. The existing game was kept.'
|
||||
output = ('HFI_ERROR ' + summary + '\n'
|
||||
+ '[1372/1613] LINUX ARM64 CC intermediate_radiosity.o\n' * 300
|
||||
+ 'ld.lld: error: undefined symbol: halo_vr_active\n'
|
||||
+ 'compiler diagnostic private\n').encode()
|
||||
client = Mock()
|
||||
channel = Mock()
|
||||
states = iter([True, False, False])
|
||||
channel.recv_ready.side_effect = lambda: next(states)
|
||||
channel.recv.return_value = output
|
||||
channel.exit_status_ready.return_value = True
|
||||
channel.recv_exit_status.return_value = 1
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
connection = SSHConnection(Settings('frame', 'private'))
|
||||
with pytest.raises(SSHError) as error:
|
||||
connection.run(['python3', 'helper.py', 'build'])
|
||||
message = str(error.value)
|
||||
assert message.startswith(summary + '\n')
|
||||
assert 'ld.lld: error: undefined symbol: halo_vr_active' in message
|
||||
assert '[earlier details omitted]' in message
|
||||
assert '[redacted]' in message and 'private' not in message
|
||||
assert len(message) <= 12000 and 'HFI_ERROR' not in message
|
||||
channel.close.assert_called_once()
|
||||
|
||||
|
||||
def test_long_live_activity_is_forwarded_but_response_memory_is_bounded(monkeypatch):
|
||||
from collections import deque
|
||||
line = b'HFI_LOG {"stage":"compile","message":"' + b'x' * 1024 + b'"}\n'
|
||||
@@ -193,7 +556,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,
|
||||
@@ -201,3 +566,534 @@ def test_uninstall_timeout_reports_uncertain_removal_and_closes_channel(monkeypa
|
||||
assert 'were kept' not in str(error.value)
|
||||
channel.close.assert_called_once()
|
||||
assert not connection._command_channels
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', [paramiko.SSHException('SSH session not active'), SSHError('cancel command failed'), EOFError('private'), ValueError('private')])
|
||||
def test_failed_cancel_notice_does_not_mask_the_cancellation(monkeypatch, failure):
|
||||
channel = Mock()
|
||||
channel.recv_ready.return_value = False
|
||||
channel.exit_status_ready.return_value = False
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
on_cancel = Mock(side_effect=failure)
|
||||
with pytest.raises(CancelledError, match='cancelled'):
|
||||
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'],
|
||||
cancel_event=cancel, on_cancel=on_cancel)
|
||||
on_cancel.assert_called_once()
|
||||
|
||||
|
||||
def test_failed_command_start_reports_a_connection_problem(monkeypatch):
|
||||
client = Mock()
|
||||
client.exec_command.side_effect = paramiko.SSHException('SSH session not active')
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
with pytest.raises(SSHError, match='could not be started'):
|
||||
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cancelled', [False, True])
|
||||
def test_failed_sftp_open_reports_connection_problem_or_cancellation(monkeypatch, cancelled):
|
||||
client = Mock()
|
||||
client.open_sftp.side_effect = paramiko.SSHException('SSH session not active')
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
cancel = threading.Event()
|
||||
if cancelled:
|
||||
cancel.set()
|
||||
with pytest.raises(CancelledError if cancelled else SSHError) as error:
|
||||
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
|
||||
if not cancelled:
|
||||
assert 'transfer stopped' in str(error.value)
|
||||
|
||||
|
||||
def test_failed_cancel_notice_does_not_mask_the_timeout_error(monkeypatch):
|
||||
from halo_frame_installer import ssh
|
||||
channel = Mock()
|
||||
channel.recv_ready.return_value = False
|
||||
channel.exit_status_ready.return_value = False
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
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)
|
||||
on_cancel.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', [EOFError('private'), paramiko.SSHException('private'), OSError('private')])
|
||||
def test_command_start_errors_are_redacted_without_exposing_a_cause(monkeypatch, failure):
|
||||
client = Mock()
|
||||
client.exec_command.side_effect = failure
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
with pytest.raises(SSHError, match='could not be started') as error:
|
||||
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
|
||||
assert 'private' not in str(error.value)
|
||||
assert error.value.__suppress_context__ and error.value.__cause__ is None
|
||||
|
||||
|
||||
def test_cancel_during_failed_command_start_remains_a_cancellation(monkeypatch):
|
||||
cancel = threading.Event()
|
||||
client = Mock()
|
||||
def fail(*args, **kwargs):
|
||||
cancel.set()
|
||||
raise EOFError('private')
|
||||
client.exec_command.side_effect = fail
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
on_cancel = Mock(side_effect=EOFError('private'))
|
||||
with pytest.raises(CancelledError, match='cancelled') as error:
|
||||
SSHConnection(Settings('frame', 'private')).run(['helper'], cancel_event=cancel, on_cancel=on_cancel)
|
||||
on_cancel.assert_called_once()
|
||||
assert 'private' not in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', [EOFError, OSError, paramiko.SSHException])
|
||||
@pytest.mark.parametrize('failing_method', ['set_combine_stderr', 'recv_ready', 'recv', 'recv_exit_status'])
|
||||
def test_channel_setup_and_read_errors_are_redacted_and_closed(monkeypatch, failing_method, failure):
|
||||
channel = Mock()
|
||||
channel.recv_ready.return_value = failing_method == 'recv'
|
||||
channel.exit_status_ready.return_value = True
|
||||
getattr(channel, failing_method).side_effect = failure('private')
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
with pytest.raises(ConnectionLostError, match='connection stopped') as error:
|
||||
SSHConnection(Settings('frame', 'private')).run(['helper'])
|
||||
assert 'private' not in str(error.value) and error.value.__suppress_context__
|
||||
channel.close.assert_called_once()
|
||||
|
||||
|
||||
def test_channel_close_failure_cannot_replace_the_timeout(monkeypatch):
|
||||
from halo_frame_installer import ssh
|
||||
channel = Mock()
|
||||
channel.close.side_effect = paramiko.SSHException('private')
|
||||
client = Mock()
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
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)
|
||||
assert channel in connection._command_channels
|
||||
connection.close()
|
||||
assert not connection._command_channels
|
||||
client.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_step', ['timeout', 'put'])
|
||||
def test_sftp_setup_and_transfer_failure_survive_a_failed_close(monkeypatch, failure_step):
|
||||
client, sftp = Mock(), Mock()
|
||||
client.open_sftp.return_value = sftp
|
||||
sftp.close.side_effect = EOFError('private')
|
||||
target = sftp.get_channel.return_value.settimeout if failure_step == 'timeout' else sftp.put
|
||||
target.side_effect = paramiko.SSHException('private')
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
connection = SSHConnection(Settings('frame', 'private'))
|
||||
with pytest.raises(SSHError, match='transfer stopped') as error:
|
||||
connection.put('local.txt', 'remote.txt')
|
||||
assert 'private' not in str(error.value)
|
||||
assert sftp in connection._sftp_clients
|
||||
connection.close()
|
||||
assert not connection._sftp_clients
|
||||
|
||||
|
||||
def test_sftp_callback_cancellation_survives_a_failed_close(monkeypatch):
|
||||
client, sftp = Mock(), Mock()
|
||||
client.open_sftp.return_value = sftp
|
||||
sftp.close.side_effect = EOFError('private')
|
||||
cancel = threading.Event()
|
||||
def transfer(*args, **kwargs):
|
||||
cancel.set()
|
||||
kwargs['callback'](1, 2)
|
||||
sftp.put.side_effect = transfer
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
with pytest.raises(CancelledError, match='cancelled') as error:
|
||||
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
|
||||
assert 'private' not in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_step', ['connect', 'keepalive'])
|
||||
def test_eof_connect_or_keepalive_failure_is_redacted_and_tears_down(monkeypatch, failure_step):
|
||||
client = Mock()
|
||||
target = client.connect if failure_step == 'connect' else client.get_transport.return_value.set_keepalive
|
||||
target.side_effect = EOFError('private')
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
with pytest.raises(SSHError, match='Could not connect') as error:
|
||||
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_discards_the_oversized_record_and_resumes_at_the_next_line(monkeypatch):
|
||||
from collections import deque
|
||||
payload = b'A' * (2 * 1024 * 1024) + b'B\nHFI_RESULT {}\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 = []
|
||||
result = SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], progress=lines.append)
|
||||
assert lines == ['HFI_RESULT {}']
|
||||
assert result == 'HFI_RESULT {}\n'
|
||||
|
||||
|
||||
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
@@ -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"
|
||||
|
||||
+179
-8
@@ -1,5 +1,6 @@
|
||||
"""Exercise the actual Notes JavaScript against a bounded native API contract."""
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -12,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"]
|
||||
|
||||
|
||||
@@ -36,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;},
|
||||
@@ -64,14 +71,16 @@ 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,
|
||||
"maxBytes": notes.MAX_DOCUMENT_BYTES, "maxNotes": notes.MAX_NOTES})
|
||||
"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) 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),
|
||||
capture_output=True, text=True, encoding="utf-8", check=True, timeout=12)
|
||||
@@ -92,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"
|
||||
@@ -129,6 +207,80 @@ def test_matching_managed_note_is_idempotent(notes):
|
||||
assert "SaveNotes" not in methods(result)
|
||||
|
||||
|
||||
def test_only_exact_shipped_notes_are_allowed_previous_installer_content(notes):
|
||||
assert notes.PREVIOUS_NOTE_CONTENTS == (notes.A2_NOTE_CONTENT, notes.A4_NOTE_CONTENT, notes.STABLE_NOTE_CONTENT)
|
||||
assert hashlib.sha256(notes.A2_NOTE_CONTENT.encode("utf-8")).hexdigest() == \
|
||||
"a4e1776caa60548620e0510acbe194aa414645be8d042771d9cbe1ec49edc05e"
|
||||
assert hashlib.sha256(notes.A4_NOTE_CONTENT.encode("utf-8")).hexdigest() == \
|
||||
"55785ec794857e844722ba758a4ce47fdb607a6bba2c30f23a437dd8f320ae30"
|
||||
assert hashlib.sha256(notes.STABLE_NOTE_CONTENT.encode("utf-8")).hexdigest() == "ceed0d2d403fa18904c5d529ae409c8bfedad733fa1f1a7d93a6e2c2fdb8d32a"
|
||||
assert "save-v1.4" in notes.NOTE_CONTENT and "old save folder is retained" in notes.NOTE_CONTENT
|
||||
assert notes.NOTE_CONTENT != notes.A2_NOTE_CONTENT
|
||||
assert "LISTING to PUBLIC" in notes.NOTE_CONTENT and "PUBLIC lets anyone" in notes.NOTE_CONTENT
|
||||
assert "HOST LOCAL (LAN)" in notes.NOTE_CONTENT and "JOIN ONLINE (INTERNET)" in notes.NOTE_CONTENT
|
||||
assert "Flat-screen CO-OP CAMPAIGN retains local split-screen" not in notes.NOTE_CONTENT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
|
||||
def test_exact_unchanged_previous_note_is_upgraded_with_every_other_field_preserved(notes, previous):
|
||||
custom = {"id": "my-note", "title": "My campaign", "content": "Leave untouched", "extra": [1, 2]}
|
||||
old = managed(notes, content=getattr(notes, previous), ordinal=8, time_created=123, unknown={"keep": True})
|
||||
original = document(notes, [custom, old], unknown_document={"keep": [3]})
|
||||
result = run_notes(notes, document=original)
|
||||
assert result["result"]["status"] == "updated"
|
||||
assert methods(result).count("SaveNotes") == 1
|
||||
updated = result["document"]
|
||||
assert updated["unknown_document"] == original["unknown_document"]
|
||||
assert updated["notes"][0] == custom
|
||||
assert {key: value for key, value in updated["notes"][1].items() if key not in ("content", "time_modified")} == \
|
||||
{key: value for key, value in old.items() if key not in ("content", "time_modified")}
|
||||
assert updated["notes"][1]["content"] == notes.NOTE_CONTENT
|
||||
assert updated["notes"][1]["time_modified"] > old["time_modified"]
|
||||
assert run_notes(notes, document=updated)["result"]["status"] == "existing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["content", "title", "shortcut_name"])
|
||||
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
|
||||
def test_previous_note_with_any_user_edit_or_identity_mismatch_is_preserved(notes, field, previous):
|
||||
old = getattr(notes, previous)
|
||||
changes = {"content": old}
|
||||
changes[field] = {"content": old + " ", "title": "My title", "shortcut_name": "Other game"}[field]
|
||||
original = document(notes, [managed(notes, **changes)])
|
||||
result = run_notes(notes, document=original)
|
||||
assert result["result"]["status"] == "custom"
|
||||
assert result["document"] == original
|
||||
assert "SaveNotes" not in methods(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
|
||||
def test_concurrent_edit_during_previous_note_upgrade_is_kept_without_write(notes, previous):
|
||||
old = getattr(notes, previous)
|
||||
original = document(notes, [managed(notes, content=old)])
|
||||
changed = document(notes, [managed(notes, content=old + " edited")])
|
||||
result = run_notes(notes, document=original, changeDocumentAtRead=2, changedDocument=changed)
|
||||
assert result["result"]["status"] == "manual"
|
||||
assert result["document"] == changed
|
||||
assert "SaveNotes" not in methods(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", [None, [None], ["old"] * 5, ["é" * 8193]])
|
||||
def test_malformed_or_excessive_previous_content_list_never_writes(notes, previous):
|
||||
original = document(notes, [managed(notes, content=notes.A2_NOTE_CONTENT)])
|
||||
result = run_notes(notes, document=original, spec_overrides={"previousContents": previous})
|
||||
assert result["result"]["status"] == "manual"
|
||||
assert result["document"] == original
|
||||
assert "SaveNotes" not in methods(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
|
||||
def test_exact_previous_note_upgrade_does_not_need_an_extra_note_slot(notes, previous):
|
||||
others = [{"id": str(index), "content": "Keep"} for index in range(notes.MAX_NOTES - 1)]
|
||||
result = run_notes(notes, document=document(notes, [*others, managed(notes, content=getattr(notes, previous))]))
|
||||
assert result["result"]["status"] == "updated"
|
||||
assert len(result["document"]["notes"]) == notes.MAX_NOTES
|
||||
assert result["document"]["notes"][:-1] == others
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [{"content": "User edited"}, {"title": "User title"},
|
||||
{"shortcut_name": "Other game"}])
|
||||
def test_edited_or_colliding_note_is_never_overwritten(notes, changes):
|
||||
@@ -217,6 +369,25 @@ def test_wrapper_client_failure_keeps_optional_manual_instructions(notes, monkey
|
||||
assert "private endpoint" not in result["message"]
|
||||
|
||||
|
||||
def test_wrapper_reports_verified_a2_upgrade_as_updated(notes, monkeypatch):
|
||||
class UpdatedClient:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *arguments):
|
||||
pass
|
||||
|
||||
def evaluate(self, expression):
|
||||
assert "previousContents" in expression
|
||||
return {"ok": True, "status": "updated"}
|
||||
|
||||
monkeypatch.setattr(notes, "Client", UpdatedClient)
|
||||
result = notes.add_notes(notes.HOME, notes.GAME, 3000000001)
|
||||
assert result["ok"] is True and result["status"] == "updated"
|
||||
assert "online co-op instructions" in result["message"]
|
||||
assert result["content"] == notes.NOTE_CONTENT
|
||||
|
||||
|
||||
def test_module_has_no_file_or_session_mutation_routes(notes):
|
||||
source = (RESOURCES / "steam_notes.py").read_text(encoding="utf-8")
|
||||
assert "SetAppDescription" not in source and "steam_appid.txt" not in source
|
||||
@@ -231,4 +402,4 @@ def test_authored_note_matches_current_controls_and_protocol_scope(notes):
|
||||
assert "Left bumper: change grenade type" in notes.NOTE_CONTENT
|
||||
assert "Right bumper: toggle flashlight" in notes.NOTE_CONTENT
|
||||
assert "compatible native/OpenCE protocol builds" in notes.NOTE_CONTENT
|
||||
assert "experimental native ARM64 Linux/OpenXR" in notes.NOTE_CONTENT
|
||||
assert "native ARM64 Linux/OpenXR" in notes.NOTE_CONTENT
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""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
|
||||
original_which = remote.shutil.which
|
||||
monkeypatch.setattr(remote.shutil, "which", lambda name: (
|
||||
"podman" if name == "podman" else None if name == "systemd-inhibit" else original_which(name)))
|
||||
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] == ["podman", "ps"]:
|
||||
return "[]"
|
||||
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)]
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Storage selection must bind every remote step to one verified destination."""
|
||||
import json
|
||||
import threading
|
||||
import contextlib
|
||||
import io
|
||||
import types
|
||||
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.command_inputs = []
|
||||
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)
|
||||
self.command_inputs.append(kwargs.get("input_data"))
|
||||
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)
|
||||
payloads = [payload.decode("utf-8") for command, payload in zip(connection.commands, connection.command_inputs)
|
||||
if command[1] == "-c" and len(command) > 3 and command[3] == "--storage-id"]
|
||||
assert len(payloads) == len(checks)
|
||||
assert all("types.ModuleType" in source and "resolve_storage(sys.argv[-1])" in source
|
||||
and "def _sd_identity(" in source for source in payloads)
|
||||
assert all("synthetic Xbox map" not in source and "xbox-data-manifest" not in source
|
||||
for source in payloads)
|
||||
|
||||
|
||||
@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:]])
|
||||
monkeypatch.setattr("sys.stdin", types.SimpleNamespace(buffer=io.BytesIO(kwargs["input_data"])))
|
||||
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]
|
||||
+458
-6
@@ -91,6 +91,26 @@ def game_bytes(remote):
|
||||
if path.is_file() and not path.is_symlink()}
|
||||
|
||||
|
||||
def record_quarantine(remote, identifier, quarantine, saved=None, *, legacy=False):
|
||||
"""Model the journal made by an actual interrupted uninstall, not a label."""
|
||||
directory = remote.CACHE / "runs" / identifier
|
||||
metadata = remote.owned(directory)
|
||||
metadata.update({"operation": "uninstall", "state": "removing", "quarantinePath": str(quarantine),
|
||||
"savedBackupPath": str(saved) if saved else None})
|
||||
if not legacy:
|
||||
metadata["quarantineRoot"] = remote.uninstall_root_identity(quarantine)
|
||||
if saved:
|
||||
backup = {"owner": remote.OWNER + "-save-backup", "gamePath": str(remote.GAME)}
|
||||
if not legacy:
|
||||
backup["files"] = {str(path.relative_to(saved)): {"size": path.stat().st_size,
|
||||
"sha256": remote.digest(path)}
|
||||
for path in saved.rglob("*") if path.is_file() and path.name != remote.MARKER}
|
||||
metadata["savedBackupRoot"] = remote.uninstall_root_identity(saved)
|
||||
(saved / remote.MARKER).write_text(json.dumps(backup))
|
||||
remote.write_uninstall_record(directory, metadata)
|
||||
return directory / remote.MARKER
|
||||
|
||||
|
||||
def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(installation, capsys):
|
||||
remote, steam, configs, appids, foreign = installation
|
||||
original_config = (remote.GAME / "config.toml").read_bytes()
|
||||
@@ -124,6 +144,55 @@ def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(
|
||||
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
|
||||
|
||||
|
||||
def test_uninstall_removes_a_native_entry_the_user_renamed(installation):
|
||||
remote, steam, configs, appids, _ = installation
|
||||
config = configs[0]
|
||||
executable = str(remote.GAME / "halo")
|
||||
root = steam.loads((config / "shortcuts.vdf").read_bytes())
|
||||
for entry in root["shortcuts"].value.values():
|
||||
path = entry.value.get("Exe")
|
||||
if path is not None and path.value in (executable, f'"{executable}"'):
|
||||
entry.value["AppName"] = steam.Value(1, "My favorite Halo")
|
||||
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert result["uninstalled"] and result["steam"]["status"] == "removed"
|
||||
assert result["steam"]["accounts"] == ["10", "20"]
|
||||
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
|
||||
assert set(remaining) == {"0", "2"}
|
||||
assert "My favorite Halo" not in {entry.value["AppName"].value for entry in remaining.values()}
|
||||
assert result["steam"]["appids"] == sorted(set(appids))
|
||||
assert not (config / "grid" / f"{appids[0]}p.jpg").exists()
|
||||
assert not (config / "grid" / f"{appids[0]}.png").exists()
|
||||
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile"
|
||||
assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art"
|
||||
backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak"))
|
||||
assert len(backups) == 1 and b"My favorite Halo" in backups[0].read_bytes()
|
||||
|
||||
|
||||
def test_uninstall_refuses_exact_native_malformed_ids_without_changing_files_or_art(installation):
|
||||
remote, steam, configs, _, _ = installation
|
||||
config = configs[0]
|
||||
executable = str(remote.GAME / "halo")
|
||||
root = steam.loads((config / "shortcuts.vdf").read_bytes())
|
||||
root["shortcuts"].value["3"] = steam.Value(0, OrderedDict([
|
||||
("appid", steam.Value(1, "not-a-number")), ("AppName", steam.Value(1, "Broken id")),
|
||||
("Exe", steam.Value(1, executable))]))
|
||||
root["shortcuts"].value["4"] = steam.Value(0, OrderedDict([
|
||||
("appid", steam.Value(2, 0xF2233445)), ("AppName", steam.Value(1, "Odd quotes")),
|
||||
("Exe", steam.Value(1, executable + '"'))]))
|
||||
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
|
||||
before = game_bytes(remote)
|
||||
shortcut_bytes = [(path / "shortcuts.vdf").read_bytes() for path in configs]
|
||||
artwork = {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()}
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
||||
assert game_bytes(remote) == before
|
||||
assert [(path / "shortcuts.vdf").read_bytes() for path in configs] == shortcut_bytes
|
||||
assert {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()} == artwork
|
||||
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
|
||||
assert set(remaining) == {"0", "1", "2", "3", "4"}
|
||||
|
||||
|
||||
def test_uninstall_without_keep_saves_deletes_contained_saves_only(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
outside = remote.HOME / "external-saves"
|
||||
@@ -275,6 +344,389 @@ def test_partial_file_removal_reports_quarantine_and_save_backup_honestly(instal
|
||||
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
|
||||
assert str(saved) in str(error.value)
|
||||
assert "game was kept" not in str(error.value).lower()
|
||||
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
|
||||
monkeypatch.setattr(Path, "unlink", original_unlink)
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert result["uninstalled"] and not result["alreadyAbsent"] and not quarantine.exists()
|
||||
assert result["savedBackupPath"] == str(saved)
|
||||
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy", [False, True])
|
||||
def test_interrupted_uninstall_leftovers_are_finished_by_the_next_run(installation, legacy):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
saved.mkdir()
|
||||
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
|
||||
(saved / "save").mkdir()
|
||||
(saved / "save/profile.sav").write_bytes(b"earlier campaign")
|
||||
record_quarantine(remote, previous, quarantine, saved, legacy=legacy)
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
|
||||
assert not quarantine.exists() and not remote.GAME.exists()
|
||||
assert (saved / "save/profile.sav").read_bytes() == b"earlier campaign"
|
||||
assert str(quarantine) in result["warning"] and str(saved) in result["warning"]
|
||||
assert result["recoveredSaveBackupPaths"] == [str(saved)]
|
||||
assert result["savedBackupPath"] is None and result["removalPending"] is False
|
||||
|
||||
|
||||
def test_reinstall_after_interrupted_uninstall_still_clears_the_old_quarantine(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "e" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
saved.mkdir()
|
||||
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
|
||||
(saved / "config.toml").write_bytes(b"earlier settings")
|
||||
record_quarantine(remote, previous, quarantine, saved)
|
||||
remote.owned(remote.GAME)
|
||||
(remote.GAME / "halo").write_bytes(b"rebuilt native executable")
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert result["uninstalled"] is True and not result.get("alreadyAbsent", False)
|
||||
assert not quarantine.exists() and not remote.GAME.exists()
|
||||
assert str(saved) in result["warning"]
|
||||
|
||||
|
||||
def test_quarantine_pattern_folders_without_a_valid_run_or_marker_are_kept(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
games = remote.GAME.parent
|
||||
decoy = games / ".HaloCENativeVR-uninstall-not-a-run-id"
|
||||
decoy.mkdir()
|
||||
(decoy / "notes.txt").write_bytes(b"user data")
|
||||
unmarked = games / (".HaloCENativeVR-uninstall-" + "f" * 32)
|
||||
unmarked.mkdir()
|
||||
(unmarked / "stray.txt").write_bytes(b"not ours")
|
||||
forged = games / (".HaloCENativeVR-uninstall-" + "a" * 32)
|
||||
forged.mkdir()
|
||||
(forged / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
|
||||
(forged / "precious.bin").write_bytes(b"user data")
|
||||
marker_dir = games / (".HaloCENativeVR-uninstall-" + "1" * 32)
|
||||
marker_dir.mkdir()
|
||||
(marker_dir / remote.MARKER).mkdir()
|
||||
(marker_dir / "note.txt").write_bytes(b"kept")
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert not result["uninstalled"] and result["removalPending"]
|
||||
assert "manually" in result["warning"]
|
||||
assert set(result["retainedQuarantinePaths"]) == {str(decoy), str(unmarked), str(forged), str(marker_dir)}
|
||||
assert (decoy / "notes.txt").read_bytes() == b"user data"
|
||||
assert (unmarked / "stray.txt").read_bytes() == b"not ours"
|
||||
assert (forged / "precious.bin").read_bytes() == b"user data"
|
||||
assert (marker_dir / "note.txt").read_bytes() == b"kept"
|
||||
|
||||
|
||||
def test_unfinishable_leftover_is_named_and_skipped_instead_of_blocking(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "2" * 32)
|
||||
quarantine.mkdir()
|
||||
(quarantine / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
|
||||
record_quarantine(remote, "2" * 32, quarantine)
|
||||
outside = remote.HOME / "foreign.map"
|
||||
outside.write_bytes(b"outside file")
|
||||
os.link(outside, quarantine / "linked.map")
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert not result["uninstalled"] and result["removalPending"]
|
||||
assert (quarantine / "linked.map").is_file() and outside.read_bytes() == b"outside file"
|
||||
assert str(quarantine) in result["warning"] and "manually" in result["warning"]
|
||||
|
||||
|
||||
def test_many_leftovers_are_all_removed_with_a_bounded_warning(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "3" * 32)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record_quarantine(remote, "3" * 32, quarantine)
|
||||
for index in range(40):
|
||||
folder = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + f"{index:032x}")
|
||||
folder.mkdir()
|
||||
(folder / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
|
||||
(folder / "halo").write_bytes(b"earlier binary")
|
||||
record_quarantine(remote, f"{index:032x}", folder)
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
|
||||
assert not list((remote.GAME.parent).glob(".HaloCENativeVR-uninstall-*"))
|
||||
assert len(result["warning"]) <= remote.MAX_WARNING_CHARS and "additional notes omitted" in result["warning"]
|
||||
|
||||
|
||||
def test_saves_note_requires_an_owned_backup_marker(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "4" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
saved.mkdir()
|
||||
(saved / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
|
||||
record_quarantine(remote, previous, quarantine)
|
||||
journal = remote.CACHE / "runs" / previous / remote.MARKER
|
||||
metadata = json.loads(journal.read_text())
|
||||
metadata["savedBackupPath"] = str(saved)
|
||||
journal.write_text(json.dumps(metadata))
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert not result["uninstalled"] and quarantine.exists() and result["removalPending"]
|
||||
assert str(quarantine) in result["warning"] and result["recoveredSaveBackupPaths"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("same_run", [False, True])
|
||||
@pytest.mark.parametrize("keep_saves", [False, True])
|
||||
def test_retry_finishes_markerless_quarantine_after_root_removal_failure(
|
||||
installation, monkeypatch, same_run, keep_saves):
|
||||
remote, _, _, _, _ = installation
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + RUN_ID)
|
||||
real_rmdir = Path.rmdir
|
||||
|
||||
def fail_root(path, *args, **kwargs):
|
||||
if path == quarantine:
|
||||
raise OSError("root directory removal interrupted")
|
||||
return real_rmdir(path, *args, **kwargs)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(Path, "rmdir", fail_root)
|
||||
with pytest.raises(RuntimeError, match="Remaining native files"):
|
||||
remote.uninstall(RUN_ID, keep_saves=keep_saves)
|
||||
assert quarantine.is_dir() and not list(quarantine.iterdir())
|
||||
journal = json.loads((remote.CACHE / "runs" / RUN_ID / remote.MARKER).read_text())
|
||||
assert journal["quarantineRoot"] == remote.uninstall_root_identity(quarantine)
|
||||
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
|
||||
result = remote.uninstall(RUN_ID if same_run else "d" * 32, keep_saves=keep_saves)
|
||||
assert result["uninstalled"] and not result["alreadyAbsent"] and not result["removalPending"]
|
||||
assert not quarantine.exists()
|
||||
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
|
||||
assert result["recoveredSaveBackupPaths"] == ([str(saved)] if keep_saves else [])
|
||||
assert result["savedBackupPath"] == (str(saved) if keep_saves and same_run else None)
|
||||
|
||||
|
||||
def test_quarantine_journal_precedes_the_rename_and_survives_that_crash_window(installation, monkeypatch):
|
||||
remote, _, _, _, _ = installation
|
||||
real_rename = remote.rename_noreplace
|
||||
seen = []
|
||||
|
||||
def check(source, target):
|
||||
if source == remote.GAME:
|
||||
metadata = remote.private_json(remote.CACHE / "runs" / RUN_ID / remote.MARKER,
|
||||
remote.CACHE / "runs" / RUN_ID)
|
||||
assert metadata["state"] == "quarantining"
|
||||
assert metadata["quarantinePath"] == str(target)
|
||||
assert metadata["quarantineRoot"] == remote.uninstall_root_identity(source)
|
||||
seen.append(metadata)
|
||||
real_rename(source, target)
|
||||
|
||||
monkeypatch.setattr(remote, "rename_noreplace", check)
|
||||
assert remote.uninstall(RUN_ID, keep_saves=True)["uninstalled"]
|
||||
assert len(seen) == 1
|
||||
# A process killed after rename but before the next journal update leaves
|
||||
# this exact state. It remains sufficient proof even without a game marker.
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
|
||||
quarantine.mkdir()
|
||||
(quarantine / "remaining.bin").write_bytes(b"native remainder")
|
||||
record = record_quarantine(remote, "d" * 32, quarantine)
|
||||
metadata = json.loads(record.read_text())
|
||||
metadata["state"] = "quarantining"
|
||||
record.write_text(json.dumps(metadata))
|
||||
assert remote.uninstall("e" * 32)["uninstalled"] and not quarantine.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["missing", "corrupt", "foreign-owner", "foreign-operation", "wrong-path",
|
||||
"complete", "oversize", "hardlink", "invalid-root", "foreign-marker"])
|
||||
def test_unverified_quarantine_journals_are_reported_and_never_adopted(installation, damage):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record = record_quarantine(remote, previous, quarantine)
|
||||
metadata = json.loads(record.read_text())
|
||||
if damage == "missing":
|
||||
record.unlink()
|
||||
elif damage == "corrupt":
|
||||
record.write_text("{broken")
|
||||
elif damage == "oversize":
|
||||
record.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
|
||||
elif damage == "hardlink":
|
||||
os.link(record, remote.HOME / "copied-journal.json")
|
||||
elif damage == "foreign-marker":
|
||||
(quarantine / remote.MARKER).write_text('{"owner":"another-application"}')
|
||||
else:
|
||||
key, value = {"foreign-owner": ("owner", "other"), "foreign-operation": ("operation", "install"),
|
||||
"wrong-path": ("quarantinePath", str(remote.HOME / "unrelated")),
|
||||
"complete": ("state", "complete"), "invalid-root": ("quarantineRoot", {"device": True})}[damage]
|
||||
metadata[key] = value
|
||||
record.write_text(json.dumps(metadata))
|
||||
before = {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()}
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert not result["uninstalled"] and not result["alreadyAbsent"] and result["removalPending"]
|
||||
assert result["retainedQuarantinePaths"] == [str(quarantine)] and str(quarantine) in result["warning"]
|
||||
assert {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()} == before
|
||||
|
||||
|
||||
def test_copied_known_owner_marker_does_not_authorize_unrelated_folder_deletion(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
|
||||
quarantine.mkdir()
|
||||
(quarantine / remote.MARKER).write_bytes((remote.GAME / remote.MARKER).read_bytes())
|
||||
(quarantine / "precious.bin").write_bytes(b"user data")
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["removalPending"] and not result["uninstalled"]
|
||||
assert (quarantine / "precious.bin").read_bytes() == b"user data"
|
||||
assert str(quarantine) in result["warning"]
|
||||
|
||||
|
||||
def test_replaced_quarantine_root_is_kept_even_if_the_old_marker_was_copied(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record_quarantine(remote, previous, quarantine)
|
||||
intact = remote.GAME.parent / "original-quarantined-game"
|
||||
os.rename(quarantine, intact)
|
||||
quarantine.mkdir()
|
||||
(quarantine / remote.MARKER).write_bytes((intact / remote.MARKER).read_bytes())
|
||||
(quarantine / "precious.bin").write_bytes(b"replacement data")
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["removalPending"] and "identity" in result["warning"]
|
||||
assert (quarantine / "precious.bin").read_bytes() == b"replacement data"
|
||||
assert (intact / "halo").read_bytes() == b"damaged native executable"
|
||||
|
||||
|
||||
def test_markerless_legacy_quarantine_is_named_and_kept_for_manual_review(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record_quarantine(remote, previous, quarantine, legacy=True)
|
||||
(quarantine / remote.MARKER).unlink()
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["removalPending"] and "older uninstall" in result["warning"]
|
||||
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["missing", "corrupt-marker", "oversize-marker", "foreign-marker",
|
||||
"hardlink", "changed-content", "empty", "symlink-root", "symlink-child"])
|
||||
def test_unverifiable_save_backup_preserves_quarantine_and_all_backup_data(installation, damage):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
saved.mkdir()
|
||||
(saved / "campaign.sav").write_bytes(b"previous campaign")
|
||||
record_quarantine(remote, previous, quarantine, saved)
|
||||
marker = saved / remote.MARKER
|
||||
protected = None
|
||||
if damage == "missing":
|
||||
(saved / "campaign.sav").unlink()
|
||||
marker.unlink()
|
||||
saved.rmdir()
|
||||
elif damage == "corrupt-marker":
|
||||
marker.write_text("not-json")
|
||||
elif damage == "oversize-marker":
|
||||
marker.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
|
||||
elif damage == "foreign-marker":
|
||||
marker.write_text('{"owner":"another-application"}')
|
||||
elif damage == "hardlink":
|
||||
protected = remote.HOME / "protected-campaign.sav"
|
||||
os.link(saved / "campaign.sav", protected)
|
||||
elif damage == "changed-content":
|
||||
(saved / "campaign.sav").write_bytes(b"modified campaign")
|
||||
elif damage == "empty":
|
||||
(saved / "campaign.sav").unlink()
|
||||
else:
|
||||
protected = remote.HOME / "protected-saves"
|
||||
protected.mkdir()
|
||||
(protected / "keep.sav").write_bytes(b"external campaign")
|
||||
if damage == "symlink-root":
|
||||
os.rename(saved, remote.GAME.parent / "actual-backup")
|
||||
link = saved
|
||||
else:
|
||||
link = saved / "linked"
|
||||
try:
|
||||
link.symlink_to(protected, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("Windows symlink creation requires additional privileges.")
|
||||
before = {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()}
|
||||
result = remote.uninstall(RUN_ID, keep_saves=True)
|
||||
assert not result["uninstalled"] and result["removalPending"] and quarantine.exists()
|
||||
assert result["recoveredSaveBackupPaths"] == []
|
||||
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
|
||||
assert {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()} == before
|
||||
if protected:
|
||||
assert (protected.read_bytes() if protected.is_file() else (protected / "keep.sav").read_bytes()) in (
|
||||
b"previous campaign", b"external campaign")
|
||||
|
||||
|
||||
def test_same_device_bind_mount_in_a_quarantine_is_kept(installation, monkeypatch):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record_quarantine(remote, previous, quarantine)
|
||||
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [quarantine / "maps"])
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["removalPending"] and "mount" in result["warning"]
|
||||
assert (quarantine / "maps/a10.map").read_bytes() == b"damaged or incomplete Xbox maps"
|
||||
|
||||
|
||||
def test_recovery_and_verified_saves_are_reported_when_steam_removal_is_pending(installation, monkeypatch):
|
||||
remote, steam, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
saved.mkdir()
|
||||
(saved / "campaign.sav").write_bytes(b"earlier campaign")
|
||||
record_quarantine(remote, previous, quarantine, saved)
|
||||
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
||||
assert not quarantine.exists() and str(quarantine) in result["warning"]
|
||||
assert result["savedBackupPath"] is None and result["recoveredSaveBackupPaths"] == [str(saved)]
|
||||
assert (saved / "campaign.sav").read_bytes() == b"earlier campaign"
|
||||
|
||||
|
||||
def test_recovery_has_bounded_work_and_can_continue_on_another_run(installation, monkeypatch):
|
||||
remote, _, _, _, _ = installation
|
||||
previous = "d" * 32
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
||||
os.rename(remote.GAME, quarantine)
|
||||
record_quarantine(remote, previous, quarantine)
|
||||
monkeypatch.setattr(remote, "MAX_UNINSTALL_RECOVERIES", 4)
|
||||
for index in range(5):
|
||||
identifier = f"{index:032x}"
|
||||
path = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + identifier)
|
||||
path.mkdir()
|
||||
(path / "remaining.bin").write_bytes(b"verified remainder")
|
||||
record_quarantine(remote, identifier, path)
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert not result["uninstalled"] and result["removalPending"] and not result["alreadyAbsent"]
|
||||
assert len(list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))) == 2
|
||||
assert "one operation" in result["warning"] and len(result["warning"]) <= remote.MAX_WARNING_CHARS
|
||||
assert remote.uninstall("e" * 32)["uninstalled"]
|
||||
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Windows forbids these Unix filename characters.")
|
||||
@pytest.mark.parametrize("suffix", ["bad\nname", "bad\\name", "bad\x7fname"])
|
||||
def test_unreportable_quarantine_filenames_stay_pending_without_unsafe_path_fields(installation, suffix):
|
||||
remote, _, _, _, _ = installation
|
||||
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + suffix)
|
||||
quarantine.mkdir()
|
||||
(quarantine / "precious.bin").write_bytes(b"user data")
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["removalPending"] and not result["uninstalled"]
|
||||
assert result["retainedQuarantinePaths"] == []
|
||||
assert (quarantine / "precious.bin").read_bytes() == b"user data"
|
||||
assert ascii(quarantine.name) in result["warning"]
|
||||
|
||||
|
||||
def test_absent_games_parent_needs_no_creation_or_cleanup(installation):
|
||||
remote, _, _, _, _ = installation
|
||||
protected = remote.HOME / "original-games"
|
||||
os.rename(remote.GAME.parent, protected)
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["uninstalled"] and result["alreadyAbsent"] and not result["removalPending"]
|
||||
assert not remote.GAME.parent.exists()
|
||||
assert (protected / "HaloCEVR/halo.exe").read_bytes() == b"old PC version; this operation must not delete it"
|
||||
|
||||
|
||||
def test_failure_before_first_delete_restores_the_intact_game_folder(installation, monkeypatch):
|
||||
@@ -344,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"])
|
||||
@@ -364,14 +816,14 @@ def test_save_backup_prerequisite_failure_keeps_game_and_steam_untouched(install
|
||||
|
||||
def test_completed_removal_is_not_reported_as_failed_if_operation_bookkeeping_fails(installation, monkeypatch):
|
||||
remote, _, _, _, _ = installation
|
||||
real_write = Path.write_text
|
||||
real_write = remote.write_uninstall_record
|
||||
|
||||
def write(path, content, *args, **kwargs):
|
||||
if path.parent.name == RUN_ID and '"state": "complete"' in content:
|
||||
def write(directory, metadata, *args, **kwargs):
|
||||
if directory.name == RUN_ID and metadata.get("state") == "complete":
|
||||
raise OSError("operation record update failed")
|
||||
return real_write(path, content, *args, **kwargs)
|
||||
return real_write(directory, metadata, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", write)
|
||||
monkeypatch.setattr(remote, "write_uninstall_record", write)
|
||||
result = remote.uninstall(RUN_ID)
|
||||
assert result["uninstalled"] is True and not remote.GAME.exists()
|
||||
assert "operation record" in result["warning"]
|
||||
@@ -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']
|
||||
@@ -148,3 +148,105 @@ def test_post_removal_bookkeeping_warning_is_visible_without_false_failure():
|
||||
assert result.uninstalled and fake.closed
|
||||
assert ('detail', warning, None) in events
|
||||
assert events[-1][0] == 'complete'
|
||||
|
||||
|
||||
def host_response(fake, **extra):
|
||||
original = fake.run
|
||||
def run(argv, **kwargs):
|
||||
response = original(argv, **kwargs)
|
||||
if argv[1] != '-c':
|
||||
data = json.loads(response.split(' ', 1)[1])
|
||||
data.update(extra)
|
||||
response = 'HFI_RESULT ' + json.dumps(data)
|
||||
return response
|
||||
fake.run = run
|
||||
|
||||
|
||||
def test_successful_uninstall_retains_its_result_when_usb_cleanup_needs_attention():
|
||||
class CleanupFailure(UninstallConnection):
|
||||
def close(self):
|
||||
self.closed = True
|
||||
raise SSHError('Temporary USB forward cleanup failed private')
|
||||
fake = CleanupFailure(Settings('', 'private', transport='usb'))
|
||||
events = []
|
||||
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
|
||||
lambda *event: events.append(event))
|
||||
assert result.uninstalled and result.saved_backup_path
|
||||
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
|
||||
assert 'private' not in result.cleanup_warning
|
||||
assert not any(event[0] in ('disconnected', 'complete') for event in events)
|
||||
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
|
||||
|
||||
|
||||
def test_uncertain_uninstall_retains_history_without_claiming_removed_or_already_absent():
|
||||
fake = UninstallConnection(Settings('frame', 'private'))
|
||||
previous_save = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32
|
||||
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unsafe-manual-folder'
|
||||
warning = 'An unsafe uninstall folder was preserved; inspect it before retrying.'
|
||||
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
|
||||
removalPending=True, warning=warning,
|
||||
retainedQuarantinePaths=[retained], recoveredSaveBackupPaths=[previous_save])
|
||||
events = []
|
||||
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
|
||||
lambda *event: events.append(event))
|
||||
assert result.removal_pending and not result.uninstalled and not result.already_absent
|
||||
assert result.removal_warning == warning
|
||||
assert result.retained_quarantine_paths == (retained,)
|
||||
assert result.recovered_save_backup_paths == (previous_save,)
|
||||
assert result.saved_backup_path is None
|
||||
assert not any(event[0] == 'complete' for event in events)
|
||||
assert events[-1][0] == 'removal-pending' and events[-1][2] == 99
|
||||
|
||||
|
||||
def test_manual_steam_failure_preserves_recovered_save_history_and_warning():
|
||||
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
|
||||
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'b' * 32
|
||||
warning = 'Recovered saves from an interrupted uninstall.'
|
||||
host_response(fake, warning=warning, recoveredSaveBackupPaths=[backup])
|
||||
events = []
|
||||
with pytest.raises(SSHError, match='game was kept') as error:
|
||||
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
|
||||
lambda *event: events.append(event))
|
||||
assert warning in str(error.value) and backup in str(error.value)
|
||||
assert ('detail', 'Recovered campaign save backup: ' + backup, None) in events
|
||||
assert not any(event[0] == 'complete' for event in events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extra', [
|
||||
{'recoveredSaveBackupPaths': ['/home/other/Games/HaloCENativeVR-saves-' + 'a' * 32]},
|
||||
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-not-a-run']},
|
||||
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-../other']},
|
||||
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\\other']},
|
||||
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\nother']},
|
||||
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 256]},
|
||||
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32] * 2},
|
||||
{'retainedQuarantinePaths': ['folder'] * 65},
|
||||
{'warning': 'x' * 4097},
|
||||
{'removalPending': 'true'},
|
||||
{'removalPending': True, 'uninstalled': True},
|
||||
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 32]},
|
||||
])
|
||||
def test_invalid_uninstall_recovery_history_never_reports_success(extra):
|
||||
fake = UninstallConnection(Settings('frame', 'private'))
|
||||
host_response(fake, **extra)
|
||||
stages = []
|
||||
with pytest.raises(SSHError, match='invalid uninstall'):
|
||||
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
|
||||
lambda stage, message, percent=None: stages.append(stage))
|
||||
assert 'complete' not in stages and fake.closed
|
||||
|
||||
|
||||
def test_pending_overflow_can_be_reported_without_a_retained_path_list():
|
||||
fake = UninstallConnection(Settings('frame', 'private'))
|
||||
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
|
||||
removalPending=True, warning='Too many interrupted uninstall folders; retry cleanup.')
|
||||
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
|
||||
assert result.removal_pending and result.retained_quarantine_paths == ()
|
||||
|
||||
|
||||
def test_uninstall_failure_is_not_masked_by_disconnect_failure():
|
||||
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
|
||||
fake.close = lambda: (_ for _ in ()).throw(EOFError('private'))
|
||||
with pytest.raises(SSHError, match='game was kept') as error:
|
||||
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
|
||||
assert 'private' not in str(error.value)
|
||||
@@ -157,6 +157,7 @@ def test_cancellation_during_retained_hashing_is_not_swallowed(remote, monkeypat
|
||||
|
||||
|
||||
def test_build_rechecks_retained_hashes_before_fetching_source(remote, monkeypatch):
|
||||
monkeypatch.setattr(remote, "no_active_build", lambda: None)
|
||||
older, _, _ = prepare_retry(remote)
|
||||
assert remote.reuse_upload(NEW_RUN)["reusedUpload"] is True
|
||||
target = older / "upload/maps/a10.map"
|
||||
@@ -180,6 +181,7 @@ def test_finalize_rechecks_retained_hashes_before_publishing(remote, monkeypatch
|
||||
struct.pack_into("<H", header, 18, 183)
|
||||
(build / "halo").write_bytes(header)
|
||||
(build / "libSDL3.so.0").write_bytes(header)
|
||||
(build / "brokers.txt").write_text("broker.example.org:1883\n")
|
||||
changed = bytearray((older / "upload/maps/a10.map").read_bytes())
|
||||
changed[100] = 1
|
||||
(older / "upload/maps/a10.map").write_bytes(changed)
|
||||
@@ -201,6 +203,7 @@ def test_finalize_copies_only_verified_retained_maps_and_keeps_old_upload(remote
|
||||
struct.pack_into("<H", header, 18, 183)
|
||||
(build / "halo").write_bytes(header)
|
||||
(build / "libSDL3.so.0").write_bytes(header)
|
||||
(build / "brokers.txt").write_text("broker.example.org:1883\n")
|
||||
resources = remote.CACHE / "resources"
|
||||
resources.mkdir()
|
||||
(resources / "frame-controls.patch").write_bytes(b"verified native controls")
|
||||
@@ -211,11 +214,13 @@ def test_finalize_copies_only_verified_retained_maps_and_keeps_old_upload(remote
|
||||
monkeypatch.setattr(remote, "rename_noreplace", publish)
|
||||
response = remote.finalize(NEW_RUN)
|
||||
assert response["gamePath"] == str(remote.GAME)
|
||||
assert (remote.GAME / "brokers.txt").read_text() == "broker.example.org:1883\n"
|
||||
assert remote.read_marker(remote.GAME / remote.MARKER)["files"]["brokers.txt"] == remote.digest(remote.GAME / "brokers.txt")
|
||||
assert remote.verify_maps(remote.GAME, manifest) == manifest["totalBytes"]
|
||||
assert remote.verify_maps(older / "upload", manifest) == manifest["totalBytes"]
|
||||
assert (older / "src/unfinished-build").read_bytes() == b"do not resume this old build"
|
||||
assert not (remote.GAME / "unfinished-build").exists()
|
||||
assert list((remote.GAME / "save").iterdir()) == []
|
||||
assert list(remote.save_root().iterdir()) == []
|
||||
|
||||
|
||||
class RetryConnection:
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user