mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-17 14:00:26 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5423d04c55 | ||
|
|
267cfc5d07 | ||
|
|
e2df305ab8 |
No files matched your search
@@ -9,18 +9,18 @@
|
||||
<h1 align="center">Halo CE Steam Frame VR Mod</h1>
|
||||
|
||||
<h2 align="center">
|
||||
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe">Download 1.4.2 — Windows installer (.exe)</a>
|
||||
<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">
|
||||
<strong>Halo-Steam-Frame-Mod-Setup-1.4.2.exe</strong><br>
|
||||
<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.2">Release notes & SHA256 checksum</a> · <a href="docs/BUILD_PROVENANCE.md">Verify release provenance</a>
|
||||
<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>
|
||||
|
||||
Play native ARM64/OpenXR Halo CE on Steam Frame with **LAN and online campaign co-op**, **head-directed walking**, and **Xbox-style buttons**. Tracked controller aiming, a render-rate reticle, half-strength stereo sun glow, and automatic Steam artwork and Notes are included.
|
||||
|
||||
**1.4.2 adds SD-card installation and menus that open in front of your current headset view.** It retains the tutorial and unarmed-flashlight fixes, standing mode and snap turning, with improved cancellation, upgrade recovery and USB tool selection.
|
||||
**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.
|
||||
|
||||
## Install or upgrade
|
||||
|
||||
@@ -37,6 +37,6 @@ Internal installs keep **Halo: Combat Evolved VR (Native)**. SD installs use **H
|
||||
|
||||
<img src="docs/images/halocevr-steam-frame-example.jpeg" alt="Halo CE VR gameplay example with tracked hands and a plasma rifle beneath Halo's night sky" width="800">
|
||||
|
||||
*Community VR example by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/): PC HaloCEVR on Steam Frame through Proton ARM. This is not a native 1.4.2 capture.*
|
||||
*Community VR example by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/): PC HaloCEVR on Steam Frame through Proton ARM. This is not a native 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.
|
||||
@@ -1,11 +1,11 @@
|
||||
# Verify the installer and build inputs
|
||||
|
||||
The public Windows EXE is built by this repository's `.github/workflows/build.yml` after Windows/Linux tests, rootless-container checks and a complete native ARM64/OpenXR link. Release 1.4.2 adds GitHub's signed build-provenance attestation for that exact executable. It is separate from the downloadable asset and its checksum. Pull-request builds skip the attestation step; the verifier below requires the trusted main-branch source identity and does not accept a pull-request source ref.
|
||||
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.2.exe --repo ClassicWasTaken/HaloSteamFrameMod --signer-workflow ClassicWasTaken/HaloSteamFrameMod/.github/workflows/build.yml --source-ref refs/heads/main --deny-self-hosted-runners
|
||||
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.
|
||||
|
||||
+29
-7
@@ -1,13 +1,13 @@
|
||||
# Steam Frame controls — 1.4.1
|
||||
# Steam Frame controls — 1.4.3
|
||||
|
||||
1.4.1 uses head-directed walking and tracked controller aiming with Xbox-style buttons. Point the right controller to aim; the left stick moves relative to the horizontal direction of your headset. 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 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,10 +19,32 @@
|
||||
| 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 |
|
||||
|
||||
Install and Repair apply controller aiming, standing height and snap turning. A fresh install uses 30-degree snap turns, 72 Hz, resolution scale 1.0 and two-handed aiming. Repair keeps your other preferences, including snap angle, player height and refresh rate. Gesture melee is disabled so **B** remains the predictable melee input. You can change supported options in **Settings > VR Setup** or the game's `config.toml`; smooth turning remains available. 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.
|
||||
|
||||
@@ -34,7 +56,7 @@ The [included patch](../resources/frame-controls.patch) includes these controls
|
||||
|
||||
1. It sets **LB = change grenade** and **RB = flashlight**. The pinned upstream branch assigns those two bumpers the other way around.
|
||||
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. Vehicle controls keep their existing direction. Set `vr.movement = "aim"` (or `HALO_VR_MOVEMENT=aim`) to restore aim-relative movement; the installer defaults to `vr.movement = "head"`.
|
||||
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).
|
||||
|
||||
@@ -42,7 +64,7 @@ The game's Steam shortcut also suppresses Steam's duplicate virtual Xbox gamepad
|
||||
|
||||
## First launch
|
||||
|
||||
Keep Steam Home and the headset runtime awake during setup. Launch **Halo: Combat Evolved VR (Native)** from the Frame's library. 1.4.1 keeps existing 1.4.0 profiles and saves in `~/Games/HaloCENativeVR/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. 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.
|
||||
|
||||
|
||||
+22
-1
@@ -1,4 +1,25 @@
|
||||
# Halo CE Steam Frame VR Mod 1.4.2
|
||||
# Halo CE Steam Frame VR Mod 1.4.3
|
||||
|
||||
[**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).
|
||||
|
||||
This release promotes the headset-tested **1.4.3-Test9** gameplay patch without changing its bytes.
|
||||
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
## 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).
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Install Halo VR on an SD card
|
||||
|
||||
Version 1.4.2 adds **Internal storage / SD card** selection. [Download the Windows installer](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe) and [verify release provenance](BUILD_PROVENANCE.md).
|
||||
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.
|
||||
|
||||
+23
-15
@@ -1,12 +1,12 @@
|
||||
# Halo CE Steam Frame VR mod — setup 1.4.2
|
||||
# Halo CE Steam Frame VR mod — setup 1.4.3
|
||||
|
||||
[Back to the project README](../README.md)
|
||||
|
||||
1.4.2 lets you select **Internal storage or SD card** for Install, Repair and Uninstall. Internal installs stay at `~/Games/HaloCENativeVR` with **Halo: Combat Evolved VR (Native)**. SD installs use the selected card's `Games/HaloCENativeVR` and **Halo: Combat Evolved VR (Native, SD card)**, keeping the internal copy. Existing 1.4.x saves at the selected location remain in `save-v1.4`; a new SD installation starts separate profiles/campaign saves. A 1.3.5 upgrade retains old saves but needs a new profile/campaign.
|
||||
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 and run the single [**Halo-Steam-Frame-Mod-Setup-1.4.2.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe) for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. Check its SHA256 on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.2) and follow [Verify release provenance](BUILD_PROVENANCE.md).
|
||||
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 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ Halo artwork and trademarks remain copyrighted to their respective owners, and t
|
||||
|
||||
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 user confirmed SD installation and the new menu placement on their headset with the private 1.4.2 build. Earlier testing confirmed the 1.4.0 baseline's reticle motion and LAN/online campaign co-op. Measured FPS, frame timing, distant-peer details and a complete new hardware test matrix were not supplied. Earlier white flashes and stuttering have not been separately diagnosed.
|
||||
The 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.
|
||||
|
||||
@@ -22,19 +22,19 @@ The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon.
|
||||
|
||||
The source is pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`. It includes upstream network campaign co-op, delayed texture-stage binding, and vertex clip-position handling changes. These renderer changes are a candidate improvement; they do not establish the cause or resolution of reported white flashes or frame drops.
|
||||
|
||||
1.4.2 uses **`~/Games/HaloCENativeVR`** and the Steam title **Halo: Combat Evolved VR (Native)**. It keeps the 1.4.0 save format and **`~/Games/HaloCENativeVR/save-v1.4`**; existing 1.4.0/1.4.1 profiles and checkpoints do not require migration. Old 1.3.5 saves remain unchanged in their original `save` folder or configured location. The original config and program files are backed up in the owned installer run's `previous-program-files` folder. The 1.4 engine's larger game-state capacity makes 1.3.5 checkpoints incompatible. When upgrading from 1.3.5, create a new profile and campaign; setup does not automatically migrate those checkpoints or profiles.
|
||||
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.2 installer targets the internal or SD location you select.
|
||||
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#140-engine-changes) explain these requirements.
|
||||
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.2 build** and supply their own supported original Xbox game data. Each player runs the game on their own headset or computer; campaign has one local player per machine. Use the left d-pad to navigate rows and change left/right choices, **A** to accept, and **B** to go back.
|
||||
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.
|
||||
@@ -50,7 +50,7 @@ If a game is visible but joining says **FAILED**, check matching builds/maps fir
|
||||
|
||||
On **Connect Frame**, click **Refresh storage** or **Test connection**, then choose **Internal storage** or a detected **SD card** and check the displayed destination. This selection applies to Install, Repair and Uninstall. SD cards require a writable, executable **ext4 or f2fs** filesystem mounted by SteamOS and **12 GiB free**; setup does not format or mount cards. Game files, new saves, the build workspace and backups use the selected drive. Steam artwork and Notes stay in the active account on internal storage. Podman can still need internal space for its compiler container. [SD-card guide](SD_CARD.md).
|
||||
|
||||
Menus capture your current headset position and gaze when opened, then stay in place while open after a turn. Closing/reopening captures the new view; recentering deliberately places an open menu in front again.
|
||||
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.
|
||||
@@ -80,7 +80,7 @@ After you select valid local Xbox game data, the installer automatically loops t
|
||||
|
||||
### Supported Xbox ISO revisions
|
||||
|
||||
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 was validated with the stable installer.** 1.4.2 retains its map-header checks. Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -92,9 +92,9 @@ These builds come from the [pinned port's compatibility table](https://github.co
|
||||
|
||||
### Already have Halo installed?
|
||||
|
||||
To update an existing installation, close **Halo: Combat Evolved VR (Native)** and choose **Repair**. Setup rebuilds the native program and applies the 1.4.2 patch, reusing verified Xbox maps without another ISO. Install also recognizes a verified 1.3.5 installation and upgrades its program in place. Setup replaces the native program, SDL library, broker list, patch, and notices. If maps are missing or damaged, select valid local data and retry. An unknown folder can be adopted only through explicit Repair after its native executable and Xbox maps validate; other locations are left alone.
|
||||
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.0/1.4.1 profiles/checkpoints in `save-v1.4` and backs up the original config and program in the owned build run's `previous-program-files` folder. Upgrading from 1.3.5 retains its old saves at their original location and selects `save-v1.4` for a new profile/campaign; there is no automatic migration of 1.3.5 checkpoints or profiles. Repairs keep unrelated settings. A failed replacement rolls back affected program/config files while the selected mount remains valid. If an SD card changes, setup stops and keeps its original backups rather than restoring onto another mount. The separate 1.4.0a2–a5 Experimental copy is not removed or upgraded by this installer.
|
||||
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.
|
||||
|
||||
@@ -120,6 +120,8 @@ With the client running, Steam controls artwork cleanup for the removed entry; s
|
||||
|
||||
## Xbox-style controls
|
||||
|
||||
On foot and in menus:
|
||||
|
||||
| Frame control | Action |
|
||||
| --- | --- |
|
||||
| A | Jump / accept |
|
||||
@@ -131,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 |
|
||||
|
||||
Install and Repair apply motion aiming, standing mode and snap turning. Fresh installs use 30-degree snap turns, 72 Hz and render scale 1.0. Repairs keep your other settings and saves. Head movement and deliberate stick turns satisfy the first-level look tutorial; the calibration dots also accept pointing with the unarmed hand reticle. 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:
|
||||
|
||||
@@ -157,6 +163,7 @@ The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenX
|
||||
|
||||
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
|
||||
- [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).
|
||||
@@ -184,6 +191,7 @@ Full source attribution, decompilation lineage, multiplayer references, and thir
|
||||
- **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.
|
||||
@@ -203,7 +211,7 @@ python -m venv .venv
|
||||
.\.venv\Scripts\python scripts/build_release.py
|
||||
```
|
||||
|
||||
The build writes a matching installer source bundle and `SHA256SUMS.txt` to `dist/` alongside the EXE. These remain local build outputs. The public release uploads one EXE and lists its SHA256 in the release notes; matching installer source is available in the [v1.4.2 Git tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.2), with GitHub's automatic source downloads available for developers. Applicable third-party source/notices are included for rebuilding, including the LGPL Paramiko dependency. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance. The package contains installer code/resources and verified tools with their notices; no retail executable, ISO, maps, SSH password, or private key is included.
|
||||
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.
|
||||
|
||||
|
||||
+15
-5
@@ -2,13 +2,13 @@
|
||||
|
||||
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-06 against a local checkout at the exact engine revision. Release 1.4.1 adds tutorial, unarmed flashlight and setup-completion fixes to the 1.4.0 baseline. The earlier headset report confirmed the baseline's reticle motion and LAN/online campaign co-op; it does not verify the new tutorial or flashlight fixes. That report contains no measured FPS, frame timings, or peer-distance details. Source and production C checks provide additional software coverage.
|
||||
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.1. The 1.4.0 release changed the engine pin and movement patch; 1.4.1 retains that pin. Automated regression coverage does not replace physical Frame verification of the tutorial, interruption, cancellation, and full uninstall.
|
||||
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
|
||||
|
||||
@@ -22,7 +22,17 @@ Those contributions remain in 1.4.1. The 1.4.0 release changed the engine pin an
|
||||
| [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/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/include/xdk/README.md) | The port supplies its own declarations; installing the proprietary Xbox SDK is unnecessary. |
|
||||
|
||||
The installer applies [its documented controls, reticle, tutorial, flashlight and campaign-menu patch](../resources/frame-controls.patch) to that pinned revision. The 1.4.1 patch's SHA256 is `1c1f8f7961831bdc543a82dd1f1ce95475a942e29c3c0fbe41b74d82439ab7f6`. The 1.4.0 baseline patch's SHA256 was `639f2694cf9574eefca6204e74193b95a72b642128884189ad398c86f7c1b30e`. [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
|
||||
|
||||
@@ -55,7 +65,7 @@ The local patch adds independent head-directed stick movement (`vr.movement = "h
|
||||
|
||||
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; states without a valid projectile preview use the ordinary HUD. 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 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.
|
||||
|
||||
@@ -104,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.
|
||||
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ 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/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 controller/tutorial patch](../resources/frame-controls.patch) | A documented modification of the pinned upstream source; see [CONTROLS.md](CONTROLS.md). |
|
||||
| [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/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party/openxr/README.md) | OpenXR SDK 1.1.63, Apache-2.0 OR MIT. Header provenance is documented upstream. |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "halo-steam-frame-installer"
|
||||
version = "1.4.2"
|
||||
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 \
|
||||
@@ -35,9 +47,21 @@ clang --version
|
||||
cd /build/src
|
||||
phase configure 'Verifying pinned build-source download safeguards...'
|
||||
python3 tools/test_build_sources.py
|
||||
phase configure 'Checking Xbox controls, front-facing VR menus, tutorial, unarmed flashlight, render-rate reticle, stereo sun glow and LAN/online campaign menus...'
|
||||
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
|
||||
|
||||
+9133
-118
File diff suppressed because it is too large.
Load diff
@@ -106,13 +106,13 @@
|
||||
},
|
||||
{
|
||||
"name": "halo-steam-frame-installer",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"license_expression": "MIT",
|
||||
"declared_license": null,
|
||||
"project_urls": [],
|
||||
"notices": [
|
||||
{
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.4.1.dist-info/licenses/LICENSE",
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.4.3.dist-info/licenses/LICENSE",
|
||||
"sha256": "04a51df2edd89917e3099e2f6f171c36891f1d6ac234015867b57a1d10f928d5",
|
||||
"bytes": 1343
|
||||
}
|
||||
@@ -502,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
@@ -0,0 +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.
|
||||
+132
-6
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import codecs
|
||||
import contextlib
|
||||
import ctypes
|
||||
import hashlib
|
||||
import importlib.util
|
||||
@@ -12,6 +13,7 @@ import os
|
||||
import pathlib
|
||||
import platform
|
||||
import pwd
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
@@ -19,6 +21,7 @@ import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
@@ -1079,10 +1082,120 @@ class BuildLogMonitor:
|
||||
tail.finish()
|
||||
|
||||
|
||||
SLEEP_INHIBITOR_READY = b"HFI_SLEEP_INHIBITOR_READY\n"
|
||||
SLEEP_INHIBITOR_START_SECONDS = 5
|
||||
|
||||
|
||||
def sleep_inhibitor_ready(process, check_cancelled):
|
||||
"""Wait for our child, which systemd starts only after acquiring its lock."""
|
||||
response = queue.Queue(maxsize=1)
|
||||
read_done = threading.Event()
|
||||
close_stdout = threading.Event()
|
||||
process._hfi_sleep_read_done = read_done
|
||||
process._hfi_sleep_close_stdout = close_stdout
|
||||
def read_ready():
|
||||
try:
|
||||
encoded = process.stdout.readline(len(SLEEP_INHIBITOR_READY) + 1)
|
||||
except (OSError, ValueError):
|
||||
encoded = None
|
||||
finally:
|
||||
read_done.set()
|
||||
if close_stdout.is_set():
|
||||
try:
|
||||
process.stdout.close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
response.put(encoded)
|
||||
# This reader never writes installer activity. Releasing/terminating the
|
||||
# owned process closes its pipe, including on a bounded startup timeout.
|
||||
threading.Thread(target=read_ready, daemon=True).start()
|
||||
deadline = time.monotonic() + SLEEP_INHIBITOR_START_SECONDS
|
||||
while True:
|
||||
check_cancelled()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
try:
|
||||
encoded = response.get(timeout=min(0.1, remaining))
|
||||
except queue.Empty:
|
||||
if process.poll() is not None:
|
||||
return False
|
||||
continue
|
||||
return encoded == SLEEP_INHIBITOR_READY and process.poll() is None
|
||||
|
||||
|
||||
def release_sleep_inhibitor(process):
|
||||
"""EOF releases the holder normally; only its owned group may be forced."""
|
||||
try:
|
||||
try:
|
||||
process.stdin.close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
stop_build_process(process)
|
||||
finally:
|
||||
close_stdout = getattr(process, "_hfi_sleep_close_stdout", None)
|
||||
read_done = getattr(process, "_hfi_sleep_read_done", None)
|
||||
if close_stdout is None:
|
||||
process.stdout.close()
|
||||
else:
|
||||
# Closing a pipe while a thread reads it can itself block. On a
|
||||
# failed group cleanup, let the daemon reader close its own pipe
|
||||
# when the service exits; it cannot delay this helper's exit.
|
||||
close_stdout.set()
|
||||
if read_done.is_set():
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def build_sleep_inhibitor(check_cancelled):
|
||||
"""Temporarily inhibit host idle/sleep; never change power configuration."""
|
||||
process = None
|
||||
acquired = False
|
||||
try:
|
||||
executable = shutil.which("systemd-inhibit")
|
||||
if executable:
|
||||
try:
|
||||
# A private stdin pipe ties the lock's lifetime to this helper.
|
||||
# If the helper dies, EOF makes the holder exit and releases the
|
||||
# lock; the build container never inherits or owns this lock.
|
||||
holder = ("import sys\nsys.stdout.buffer.write(" + repr(SLEEP_INHIBITOR_READY) +
|
||||
")\nsys.stdout.flush()\nwhile sys.stdin.buffer.read(1): pass\n")
|
||||
process = subprocess.Popen(
|
||||
[executable, "--what=idle:sleep", "--mode=block", "--who=Halo Frame Installer",
|
||||
"--why=Building the native Halo VR game", "--no-ask-password",
|
||||
sys.executable, "-u", "-c", holder],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, bufsize=0)
|
||||
acquired = sleep_inhibitor_ready(process, check_cancelled)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
acquired = False
|
||||
if not acquired and process is not None:
|
||||
release_sleep_inhibitor(process)
|
||||
process = None
|
||||
progress("build", "Automatic sleep is temporarily blocked during the native build." if acquired else
|
||||
"Automatic sleep prevention is unavailable. Keep the Frame awake until the native build finishes.")
|
||||
yield process if acquired else None
|
||||
finally:
|
||||
if process is not None:
|
||||
active_error = sys.exc_info()[1]
|
||||
try:
|
||||
release_sleep_inhibitor(process)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
message = "Releasing the temporary sleep lock could not be confirmed: " + str(error)[:500]
|
||||
if active_error is not None:
|
||||
active_error.add_note(message)
|
||||
else:
|
||||
raise RuntimeError(message) from error
|
||||
|
||||
|
||||
def build(value, repair=False, adopt=False):
|
||||
directory = run_dir(value)
|
||||
metadata = read_marker(directory / MARKER)
|
||||
check_storage_record(metadata, active_run=True)
|
||||
no_active_build()
|
||||
existing = existing_install(repair, adopt)
|
||||
if existing and not repair:
|
||||
if existing.get("needsUpgrade"):
|
||||
@@ -1121,13 +1234,17 @@ def build(value, repair=False, adopt=False):
|
||||
progress("build", "Starting the isolated ARM64 build container; downloading its image if needed...")
|
||||
log = directory / "native-build.log"
|
||||
args = build_container_args(value, directory)
|
||||
with log.open("wb") as output:
|
||||
with build_sleep_inhibitor(check_cancelled) as sleep_lock, log.open("wb") as output:
|
||||
check_cancelled()
|
||||
no_active_build()
|
||||
process = subprocess.Popen(args, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
monitor = BuildLogMonitor(directory)
|
||||
try:
|
||||
while process.poll() is None:
|
||||
check_cancelled()
|
||||
if sleep_lock is not None and sleep_lock.poll() is not None:
|
||||
progress(monitor.stage, "The temporary sleep lock ended unexpectedly. Keep the Frame awake until the native build finishes.", monitor.percent)
|
||||
sleep_lock = None
|
||||
monitor.poll()
|
||||
monitor.heartbeat()
|
||||
time.sleep(0.25)
|
||||
@@ -1201,8 +1318,16 @@ def stop_owned_container(value):
|
||||
"""Stop this installer's labeled run without depending on its filesystem."""
|
||||
run_id(value)
|
||||
container = "halo-frame-installer-" + value
|
||||
exists = subprocess.run(["podman", "container", "exists", container],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
if exists.returncode == 1:
|
||||
return {"cancelled": True}
|
||||
if exists.returncode != 0:
|
||||
raise RuntimeError("The build container's presence could not be verified; stopping was not confirmed.")
|
||||
check = subprocess.run(["podman", "container", "inspect", container],
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=15)
|
||||
if check.returncode != 0:
|
||||
raise RuntimeError("The build container could not be inspected; stopping was not confirmed.")
|
||||
if check.returncode == 0:
|
||||
if len(check.stdout) > 64 * 1024:
|
||||
raise ValueError("The container ownership response exceeds supported bounds.")
|
||||
@@ -1243,8 +1368,8 @@ def merge_config(original, migrate=False):
|
||||
configured_save_path(settings)
|
||||
else:
|
||||
validate_save_paths(settings)
|
||||
required = {"vr": {"enabled": True, "aim": "controller", "movement": "head", "melee_gesture": False,
|
||||
"turn": "snap", "height": "standing"},
|
||||
required = {"vr": {"enabled": True, "aim": "controller", "movement": "head", "melee_gesture": True,
|
||||
"turn": "snap", "height": "standing", "scope": False, "zoom_stabilization": False},
|
||||
"update": {"auto": False}, "paths": {"data": str(GAME), "saves": str(save_root())}}
|
||||
if "coop_enemies_mode" not in settings.get("network", {}):
|
||||
required["network"] = {"coop_enemies_mode": "none"}
|
||||
@@ -1437,7 +1562,8 @@ def finalize(value, repair=False, adopt=False):
|
||||
'[update]\nauto = false\n\n[network]\nonline = true\ncoop_enemies_mode = "none"\n\n'
|
||||
'[vr]\nenabled = true\nrefresh_rate = 72.0\nresolution_scale = 1.0\nsun_glow_strength = 0.5\n'
|
||||
'aim = "controller"\nmovement = "head"\nturn = "snap"\nsmooth_turn_speed = 90.0\n'
|
||||
'melee_gesture = false\ntwo_handed = true\nheight = "standing"\ndepth = false\n')
|
||||
'melee_gesture = true\ntwo_handed = true\nheight = "standing"\ndepth = false\n'
|
||||
'scope = false\nzoom_stabilization = false\n')
|
||||
if existing:
|
||||
original = ordinary(GAME / "config.toml").read_text() if (GAME / "config.toml").exists() else ""
|
||||
config = merge_config(original, migrate=existing["sourceCommit"] != SOURCE_COMMIT)
|
||||
@@ -1470,7 +1596,7 @@ def finalize(value, repair=False, adopt=False):
|
||||
"files": {name: digest(stage / name) for name in ("halo", "libSDL3.so.0", "brokers.txt", "frame-controls.patch")},
|
||||
"maps": {"files": 24, "bytes": manifest["totalBytes"]},
|
||||
"controls": "Head-directed walking, Xbox buttons, motion aim, LB grenade change, RB flashlight",
|
||||
"installerVersion": "1.4.2", "networkProtocol": 17, "saveRoot": str(save_root()),
|
||||
"installerVersion": "1.4.3", "networkProtocol": 17, "saveRoot": str(save_root()),
|
||||
"previousSavePaths": existing.get("previousSavePaths", []) if existing else [],
|
||||
"buildLog": str(directory / "native-build.log")}
|
||||
(stage / MARKER).write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
@@ -1542,7 +1668,7 @@ def no_active_build():
|
||||
if not isinstance(containers, list):
|
||||
raise ValueError("Podman returned unsupported build status. The game was kept.")
|
||||
if containers:
|
||||
raise ValueError("An installer build is still running on the Frame. Finish or cancel that build before uninstalling Halo.")
|
||||
raise ValueError("An installer build is still running on the Frame. Wake the Frame, then let that build finish or stop it using Cancel before trying Install, Repair or Uninstall again. No second build was started.")
|
||||
|
||||
|
||||
def uninstall_identity():
|
||||
|
||||
@@ -9,7 +9,7 @@ import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
VERSION = "1.4.2"
|
||||
VERSION = "1.4.3"
|
||||
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
|
||||
|
||||
def allowed_source_files():
|
||||
|
||||
@@ -7,10 +7,12 @@ 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
|
||||
@@ -35,6 +37,29 @@ PATCH_FILES = frozenset((
|
||||
"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",
|
||||
))
|
||||
|
||||
|
||||
@@ -396,6 +421,151 @@ def check_tutorial(source):
|
||||
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
|
||||
@@ -418,7 +588,7 @@ 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", "-DHALO_VR=1", "-iquote port/linux/game", "build/linux_arm64/brokers.txt", "port/assets/network/brokers.txt"):
|
||||
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:
|
||||
@@ -445,6 +615,80 @@ def project_source(source, destination):
|
||||
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,
|
||||
@@ -472,14 +716,20 @@ def check_source(source: Path):
|
||||
require(result.returncode == 0, "The local patch does not apply to the canonical pinned engine source.")
|
||||
if patched:
|
||||
for path in changed:
|
||||
require((source / path).read_bytes().replace(b"\r\n", b"\n")
|
||||
== (projection / path).read_bytes().replace(b"\r\n", b"\n"),
|
||||
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:])
|
||||
@@ -492,6 +742,11 @@ def check_source(source: Path):
|
||||
"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}
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
|
||||
__version__ = "1.4.2"
|
||||
__version__ = "1.4.3"
|
||||
@@ -1521,8 +1521,16 @@ class App(tk.Tk):
|
||||
self.connection_status.set("Connection check cancelled.")
|
||||
self._append(text)
|
||||
return
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
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":
|
||||
|
||||
@@ -34,6 +34,26 @@ 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
|
||||
@@ -290,7 +310,7 @@ class Installer:
|
||||
self.resource_dir = resource_dir or resources_path()
|
||||
|
||||
def remote_source(self) -> str:
|
||||
"""Bootstrap the same bundled storage validator before the inline helper."""
|
||||
"""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.
|
||||
@@ -324,13 +344,13 @@ class Installer:
|
||||
"except (OSError,ValueError,UnicodeError,subprocess.SubprocessError) as error:\n"
|
||||
" print('HFI_ERROR '+str(error).replace('\\n',' ')[:600],flush=True)\n"
|
||||
" raise SystemExit(1)\n")
|
||||
argv = ["python3", "-c", inline] + storage_arguments(settings)
|
||||
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(connection.run(
|
||||
argv, cancel_event=cancel_event, timeout=90)), settings.storage_id)
|
||||
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.")
|
||||
@@ -367,7 +387,7 @@ class Installer:
|
||||
raise CancelledError("Storage check cancelled.")
|
||||
connection.connect()
|
||||
source = (self.resource_dir / "frame_storage.py").read_text(encoding="utf-8")
|
||||
inventory = parse_result(connection.run(["python3", "-c", source],
|
||||
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)
|
||||
@@ -498,13 +518,13 @@ class Installer:
|
||||
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.remote_source()
|
||||
preflight_args = ["python3", "-c", source, "preflight-library" if registration_only else "preflight"]
|
||||
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")
|
||||
preflight_args += storage_arguments(settings)
|
||||
info = parse_result(connection.run(preflight_args,
|
||||
info = parse_result(run_remote_source(connection, source, preflight_args,
|
||||
progress=remote_progress, cancel_event=cancel_event, timeout=90))
|
||||
destination = validate_preflight_location(info, settings)
|
||||
upload = self.guarded_upload(connection, settings, destination, cancel_event)
|
||||
@@ -655,7 +675,7 @@ class Installer:
|
||||
check_cancel()
|
||||
progress("preflight", "Checking the native game location and safe uninstall support...", None)
|
||||
helper_source = self.remote_source()
|
||||
info = parse_result(connection.run(["python3", "-c", helper_source, "preflight-uninstall"] + storage_arguments(settings),
|
||||
info = parse_result(run_remote_source(connection, helper_source, ["preflight-uninstall"] + storage_arguments(settings),
|
||||
progress=remote_progress, cancel_event=cancel_event, timeout=90))
|
||||
destination = validate_preflight_location(info, settings)
|
||||
upload = self.guarded_upload(connection, settings, destination, cancel_event)
|
||||
|
||||
@@ -22,6 +22,10 @@ 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."""
|
||||
|
||||
@@ -277,8 +281,11 @@ class SSHConnection:
|
||||
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:
|
||||
@@ -305,7 +312,10 @@ class SSHConnection:
|
||||
expired = None
|
||||
try:
|
||||
with self._handshake_watchdog(min(30, timeout) + self.handshake_grace, cancel_event) as expired:
|
||||
_, stdout, _ = self.client.exec_command(command, timeout=min(30, timeout))
|
||||
# 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):
|
||||
@@ -317,12 +327,23 @@ class SSHConnection:
|
||||
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)
|
||||
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()
|
||||
discarding_line = False
|
||||
sent_bytes = 0
|
||||
input_finished = input_data is None
|
||||
try:
|
||||
channel.set_combine_stderr(True)
|
||||
while True:
|
||||
@@ -332,6 +353,27 @@ class SSHConnection:
|
||||
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.")
|
||||
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
|
||||
@@ -351,27 +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)
|
||||
if len(pending) > 1024 * 1024:
|
||||
# Endless newline-free output must not grow host memory
|
||||
# without bound; keep only the most recent tail.
|
||||
del pending[:len(pending) - 64 * 1024]
|
||||
while b"\n" in pending:
|
||||
line, _, rest = pending.partition(b"\n")
|
||||
pending[:] = rest
|
||||
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()
|
||||
@@ -397,7 +463,8 @@ class SSHConnection:
|
||||
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 SSHError("The remote connection stopped before this step could be verified. Check the Frame's connection and retry.") 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()
|
||||
@@ -407,6 +474,10 @@ class SSHConnection:
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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)
|
||||
@@ -3,6 +3,8 @@ import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -77,13 +79,89 @@ def test_protocol_11_engine_cannot_be_mislabeled_as_matching_experimental_coop(t
|
||||
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 "1.4.2" in text and "Halo-Steam-Frame-Mod-Setup-1.4.2.exe" in text
|
||||
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
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe" in readme
|
||||
notes = (ROOT / "docs/RELEASE_NOTES.md").read_text(encoding="utf-8")
|
||||
assert "cannot load old checkpoints" in notes and "save-v1.4" in notes
|
||||
sd_guide = (ROOT / "docs/SD_CARD.md").read_text(encoding="utf-8")
|
||||
@@ -92,6 +170,47 @@ def test_public_release_documentation_names_the_single_installer_download():
|
||||
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:
|
||||
@@ -99,6 +218,8 @@ def test_actual_exact_pinned_engine_and_shipped_patch_when_checkout_is_supplied(
|
||||
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"]
|
||||
|
||||
@@ -1733,6 +1733,39 @@ def test_genuine_failure_during_a_set_cancel_still_needs_attention(app):
|
||||
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])
|
||||
|
||||
+16
-2
@@ -608,13 +608,24 @@ def test_remote_detects_changed_original_xbox_header(remote, tmp_path):
|
||||
|
||||
|
||||
def test_repair_applies_standing_snap_without_resetting_other_vr_preferences(remote):
|
||||
old = '[vr]\nheight="seated"\nturn="smooth"\nsnap_turn_angle=45.0\nrefresh_rate=90.0\nplayer_height=1.8\n'
|
||||
old = '[vr]\nheight="seated"\nturn="smooth"\nmelee_gesture=false\nsnap_turn_angle=45.0\nrefresh_rate=90.0\nplayer_height=1.8\n'
|
||||
updated = remote.tomllib.loads(remote.merge_config(old))
|
||||
assert updated['vr']['height'] == 'standing' and updated['vr']['turn'] == 'snap'
|
||||
assert updated['vr']['melee_gesture'] is True
|
||||
assert updated['vr']['snap_turn_angle'] == 45.0
|
||||
assert updated['vr']['refresh_rate'] == 90.0 and updated['vr']['player_height'] == 1.8
|
||||
|
||||
|
||||
@pytest.mark.parametrize('original', ['', '[vr]\nscope=true\nzoom_stabilization=true\nscope_size=0.2\nrefresh_rate=90.0\n'])
|
||||
def test_install_and_repair_disable_removed_optics_without_resetting_other_preferences(remote, original):
|
||||
updated = remote.tomllib.loads(remote.merge_config(original))
|
||||
assert updated['vr']['scope'] is False
|
||||
assert updated['vr']['zoom_stabilization'] is False
|
||||
if original:
|
||||
assert updated['vr']['scope_size'] == 0.2
|
||||
assert updated['vr']['refresh_rate'] == 90.0
|
||||
|
||||
|
||||
def test_config_repair_preserves_isolated_save_location_and_user_preferences(remote):
|
||||
old = '[paths]\nsaves = ' + json.dumps(str(remote.save_root())) + '\n\n[audio]\nvolume = 0.4\n\n[vr]\naim="head"\nturn="snap"\nrefresh_rate=90.0\n\n[[plugins]]\nname="custom"\n'
|
||||
result = remote.tomllib.loads(remote.merge_config(old))
|
||||
@@ -645,6 +656,7 @@ def test_new_config_missing_paths_gets_versioned_defaults(remote):
|
||||
assert updated['vr']['aim'] == 'controller' and updated['vr']['movement'] == 'head'
|
||||
assert updated['vr']['sun_glow_strength'] == 0.5
|
||||
assert updated['vr']['turn'] == 'snap' and updated['vr']['height'] == 'standing'
|
||||
assert updated['vr']['melee_gesture'] is True
|
||||
assert updated['network']['coop_enemies_mode'] == 'none'
|
||||
|
||||
|
||||
@@ -1214,7 +1226,8 @@ def test_verified_stable_upgrade_keeps_old_checkpoints_and_backs_up_config(remot
|
||||
assert settings["audio"]["volume"] == 0.3 and settings["vr"]["turn"] == "snap"
|
||||
assert remote.save_root().is_dir() and not any(remote.save_root().iterdir())
|
||||
marker = remote.read_marker(remote.GAME / remote.MARKER)
|
||||
assert marker["installerVersion"] == "1.4.2" and marker["networkProtocol"] == 17
|
||||
from halo_frame_installer import __version__
|
||||
assert marker["installerVersion"] == __version__ and marker["networkProtocol"] == 17
|
||||
assert marker["saveRoot"] == str(remote.save_root()) and "experimental" not in marker
|
||||
assert not remote.existing_install()["needsUpgrade"]
|
||||
# A later Repair preserves both generations and the former custom location.
|
||||
@@ -1283,6 +1296,7 @@ def test_automatic_upgrade_refuses_unverified_or_unknown_state(remote, damage):
|
||||
|
||||
|
||||
def test_legacy_library_and_finalize_cannot_shortcut_the_upgrade(remote, monkeypatch):
|
||||
monkeypatch.setattr(remote, "no_active_build", lambda: None)
|
||||
manifest, _ = native_install(remote)
|
||||
monkeypatch.setattr(remote, "preflight_uninstall", lambda: {"home": str(remote.HOME)})
|
||||
with pytest.raises(ValueError, match="Use Install"):
|
||||
|
||||
@@ -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,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
|
||||
+346
-8
@@ -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 CancelledError, RemoteTimeoutError, 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"])
|
||||
@@ -314,16 +650,17 @@ def test_cancel_during_failed_command_start_remains_a_cancellation(monkeypatch):
|
||||
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):
|
||||
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 = EOFError('private')
|
||||
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(SSHError, match='connection stopped') as error:
|
||||
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()
|
||||
@@ -414,9 +751,9 @@ def test_silent_exec_handshake_is_bounded_by_a_watchdog(monkeypatch):
|
||||
assert time_module.monotonic() - started < 5 # unbounded waits defeat this without the watchdog
|
||||
|
||||
|
||||
def test_newline_free_flood_keeps_only_the_recent_tail(monkeypatch):
|
||||
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\n'
|
||||
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)
|
||||
@@ -427,8 +764,9 @@ def test_newline_free_flood_keeps_only_the_recent_tail(monkeypatch):
|
||||
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
|
||||
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
|
||||
lines = []
|
||||
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], progress=lines.append)
|
||||
assert lines and len(lines[-1]) < 200 * 1024
|
||||
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):
|
||||
|
||||
@@ -373,6 +373,9 @@ def test_interrupted_sd_uninstall_recovers_after_same_card_remount(sd_remote, mo
|
||||
|
||||
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)
|
||||
@@ -384,6 +387,8 @@ def test_live_sd_build_detects_remount_and_stops_labeled_container_without_card_
|
||||
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 ""
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import threading
|
||||
import contextlib
|
||||
import io
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -37,6 +38,7 @@ class StorageConnection:
|
||||
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
|
||||
@@ -53,6 +55,7 @@ class StorageConnection:
|
||||
|
||||
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()]}
|
||||
@@ -266,10 +269,13 @@ def test_sd_each_put_has_independent_bundled_before_and_after_checks(tmp_path):
|
||||
checks = storage_checks(connection)
|
||||
assert len(checks) == len(connection.uploads) * 2
|
||||
assert all(command[-2:] == ["--storage-id", SD_ID] for command in checks)
|
||||
assert all("types.ModuleType" in command[2] and "resolve_storage(sys.argv[-1])" in command[2]
|
||||
and "def _sd_identity(" in command[2] for command in checks)
|
||||
assert all("synthetic Xbox map" not in command[2] and "xbox-data-manifest" not in command[2]
|
||||
for command in checks)
|
||||
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", [
|
||||
@@ -437,6 +443,7 @@ def test_missing_card_inline_check_emits_bounded_error_without_traceback(tmp_pat
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user