14 Commits
Author SHA1 Message Date
ClassicWasTaken 0ec49c5177 Release 1.4.0 native VR co-op and in-place upgrades 2026-10-06 11:11:45 -07:00
ClassicWasTaken 53be44d55f Fix verified uninstall recovery and SSH cancellation for v1.3.5
Closes #1

Closes #2

Closes #3
2026-10-06 00:36:53 -07:00
ClassicWasTaken 2ec7378c6a Merge contributed SSH error-precedence fix from PR #9 2026-10-06 00:22:21 -07:00
ClassicWasTaken 0aba1aa2b5 Merge contributed renamed-shortcut fix from PR #8 2026-10-06 00:22:21 -07:00
ClassicWasTaken 7e7b944e81 Merge contributed interrupted-uninstall fix from PR #7 2026-10-06 00:22:16 -07:00
ClassicWasTaken 9b7810ac49 Identify setup as a community VR mod and remove game-download site references 2026-10-05 23:53:51 -07:00
dime-online d5795d8d8c fix: keep SSH failures from masking cancellation and step errors 2026-10-06 01:28:13 -04:00
dime-online 4cda8e5be3 fix: finish an interrupted uninstall on the next run 2026-10-06 01:26:08 -04:00
dime-online cc7b8a4d5e fix: remove a renamed native shortcut during uninstall 2026-10-06 01:23:27 -04:00
ClassicWasTaken b1110a361a Make Steam info automatic and keep music loading responsive 2026-10-05 21:44:49 -07:00
ClassicWasTaken 65fa540845 Shorten the README and move details into the setup guide 2026-10-05 21:23:14 -07:00
ClassicWasTaken 94cf32cbf9 Use the classic Halo PC installer icon in v1.3.2 2026-10-05 21:17:41 -07:00
ClassicWasTaken 4426813d4d Use the filesystem owner in library preflight test 2026-10-05 21:05:14 -07:00
ClassicWasTaken 62338d7c2b Add Halo library artwork and Steam Notes in v1.3.1 2026-10-05 21:03:51 -07:00
53 changed files with 9383 additions and 575 deletions

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
shell: pwsh
run: |
$reportPath = Join-Path $env:RUNNER_TEMP 'halo-installer-smoke.json'
$installers = @(Get-ChildItem -LiteralPath 'dist' -Filter 'Halo-Steam-Frame-Setup-*.exe')
$installers = @(Get-ChildItem -LiteralPath 'dist' -Filter 'Halo-Steam-Frame-Mod-Setup-*.exe')
if ($installers.Count -ne 1) { throw 'Expected one standalone installer executable' }
$exePath = $installers[0].FullName
$process = Start-Process -FilePath $exePath -ArgumentList @('--self-test-report', ('"' + $reportPath + '"')) -WindowStyle Hidden -Wait -PassThru
+17 -147
View File
@@ -1,157 +1,27 @@
# Halo on Steam Frame — guided native VR setup
<p align="center">
<img src="resources/artwork/halo-ce-hero.jpg" alt="Halo: Combat Evolved — Master Chief in battle" width="800">
</p>
A Windows setup app for installing the native ARM64/OpenXR Halo: Combat Evolved port on Steam Frame. Its original Xbox dashboard-inspired interface uses dark panels, luminous green accents, and beveled controls to guide you through choosing game data, connecting over USB-C or a network, and installing or repairing the game. Both connection options use encrypted SSH. Setup copies your own original Xbox data, builds the native VR executable on the Frame, applies Xbox-style controls, and guides Steam library registration with Halo CE cover art.
<p align="center">
<img src="resources/artwork/halo-ce-logo.png" alt="Halo: Combat Evolved" width="180">
</p>
**Version 1.3.0 — USB-C transfer and experimental VR support.** Choose a direct USB-C cable or Wi-Fi / Ethernet in the connection step. The wired path uses ADB port forwarding for the same encrypted SSH connection, with no Frame IP required. USB handling is software-tested; a physical Steam Frame USB transfer has not yet been verified. Setup retains the 1.2.3 safeguard that keeps Steam Home and SteamVR running; it never shuts down or starts another Steam client. Direct library-file edits require Steam to be already closed, with manual steps shown when needed. The underlying VR implementation is an open upstream pull request. The pinned native game, Xbox controls, and live Steam shortcut/artwork helper were previously tested on a real Frame. The complete first-install wizard and full game uninstall still need fresh-device hardware tests. Performance varies by scene and device. This is an independent community project, unaffiliated with Microsoft, Bungie, or Valve.
**Halo CE Steam Frame VR Mod — 1.4.0**
## Download and run
[**Download Halo-Steam-Frame-Mod-Setup-1.4.0.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.0/Halo-Steam-Frame-Mod-Setup-1.4.0.exe) for Windows 10/11 x64. Run this one file; Python, an installer ZIP, and a separate USB tools download are unnecessary. [Release notes & SHA256 checksum](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.0) · [1.4.0 source](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.0).
**[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.0.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.0/Halo-Steam-Frame-Setup-1.3.0.exe)**
Play native ARM64/OpenXR Halo CE with **LAN and online campaign co-op**, **head-directed walking**, and Xbox-style buttons. Aim with the right controller; the reticle follows the nominal shot trajectory at render rate. Stereo sun glow defaults to half intensity. The headset tester confirmed reticle motion and LAN/online campaign co-op work with the engine patch used by this release. Measured performance and distant-peer coverage have not been reported.
This is the only installer file you need. It includes the installer runtime and the ADB files needed for USB-C transfer; no installer ZIP, Python installation, or separate Platform Tools download is needed. The bundled ADB files retain their original notices. Run setup on Windows 10/11 x64. You do not need administrator rights. The **[version 1.3.0 release](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.0)** description includes the EXE's SHA256 checksum.
<p align="center">
<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">
</p>
The executable has a Master Chief helmet icon with olive armor and a gold visor, drawn for this project.
*Community VR example by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/): PC HaloCEVR on Steam Frame through Proton ARM. This is not a native 1.4.0 capture.*
1. **Game data.** Choose **Install Halo VR**, then use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
2. **Connect Frame.** Enable Developer Mode and set the Frame's user password, then choose **Network (Wi-Fi or Ethernet)** or **USB-C cable**. For network transfer, keep both devices on a network that permits connections between them and enter the Frame's hostname/IP. For USB, connect a data-capable cable directly to the PC and approve device authorization on the headset if prompted; no Frame IP is required. The SSH login remains `steamos`, port 22; username and port are under **Advanced connection settings**. Click **Test connection** and approve the displayed host fingerprint only after identifying your device. Your password is used in memory and is never saved or logged.
3. **Install Halo VR.** Save and close games, confirm **I have saved and closed games on my Frame**, then click **Install Halo VR**. Keep Steam Home and SteamVR running, and leave the Frame awake and connected. The app uploads maps, creates a rootless build container, compiles the pinned VR source, applies the controls patch, and registers **Halo: Combat Evolved VR (Native)** through the running Steam client when its verified library interface is available. Any remaining shortcut or artwork step is shown at the end; it does not require rebuilding the game. The first build may take tens of minutes. Activity opens automatically and shows the current phase and live build output; use **Save log** for troubleshooting.
Supply your own authorized **original Xbox Halo CE ISO/XISO or extracted maps**. USA Rev 2 was validated with the stable installer; PC, Xbox 360 Anniversary, and MCC data are unsupported. No retail executable, disc image, maps, product keys, or soundtrack is included or downloaded. Halo artwork and licensed components retain their rights and notices.
### Transfer over a USB-C cable
Choose **Install**, select your data, and follow the USB-C or network SSH guide. Setup adds Steam artwork and Notes automatically. The first build on the Frame can take tens of minutes.
1. In the Frame's **Steam Settings > System**, enable **Developer Mode**. In **Developer**, set a user password. Leave Steam Home and SteamVR running.
2. Connect the Frame directly to your Windows PC with a USB data cable. A charging-only cable cannot transfer files. Keep the Frame awake and allow this computer's device authorization on the headset if prompted.
3. In **Connect Frame**, choose **USB-C cable**. Setup first uses ADB already available on your computer's `PATH`, then falls back to its included ADB 37.0.1 files. **Advanced connection settings > Browse adb.exe** can select a specific installation. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
4. Enter the same Frame user password used for SSH and click **Test connection**. Setup selects the sole physical USB device automatically. If several physical USB devices are connected, unplug the others or enter the Frame's exact ADB serial in **Advanced connection settings**. If a serial cannot be verified as a physical USB connection, unplug other devices and leave the serial field blank for automatic detection. Emulators and network-connected ADB devices are not USB targets. Review the Frame's SSH host fingerprint when asked.
5. Continue with install, repair, library registration, or uninstall. Leave the cable attached until setup reports that it has disconnected. Setup removes only the temporary ADB forward it created and leaves the shared ADB server and other forwards running.
1.4.0 **replaces 1.3.5 in place** at `~/Games/HaloCENativeVR`, keeping **Halo: Combat Evolved VR (Native)** in Steam. **Install** recognizes a verified 1.3.5 installation and rebuilds it; **Repair** also upgrades it. Verified Xbox maps can be reused without an ISO. Old saves remain unchanged; the new engine uses `save-v1.4` and requires a new profile/campaign because older checkpoints are incompatible. Campaign has one player per machine, with LAN, Internet, and invite joining. Peers need matching **protocol 17** builds; 1.3.5 protocol 11 cannot join.
The cable carries encrypted SSH/SFTP traffic between the PC and Frame. **The Frame still needs its own internet connection** to fetch native source, the build container, and compiler dependencies. USB does not provide internet sharing or change Wi-Fi/Steam settings. Cable transfer may help on a weak or congested wireless network; actual speed depends on the cable, USB ports, and device. It does not speed up the native compilation phase. USB behavior has automated software coverage; physical Steam Frame USB transfer verification is pending. [Valve's USB/ADB guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) and [Google's ADB reference](https://developer.android.com/tools/adb) describe the underlying connection and forwarding tools.
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress; a remaining manual Steam or artwork step stays at 99%, and only full success reaches 100%.
Setup explicitly closes its SFTP and command channels and the underlying SSH connection before reporting completion. The activity feed shows disconnecting and disconnected stages; failure and cancellation also close that connection. USB cleanup removes only this operation's temporary ADB forward. Setup does not stop the shared ADB server or change the Frame's Wi-Fi or Steam streaming settings.
The included ADB executable, two companion DLLs, and unmodified notices are checksum-verified before being copied to `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1`. This versioned cache remains after setup closes so a shared ADB server can keep using it without locking the EXE's temporary extraction folder. The selected client's protocol is checked against an already-running local ADB server; a mismatch stops USB setup without restarting that server. Setup also verifies that the temporary forwarded port listens only on this PC's loopback addresses before sending the SSH password. [Bundled-tool provenance and licenses](docs/THIRD_PARTY.md#adb-for-usb-transfer).
These changes apply after you launch the new EXE and start a run. An installation already running in an older EXE keeps its existing behavior; downloading a replacement cannot change that ongoing process.
![The version 1.2.2 installer with its dark original Xbox dashboard styling, green accents, and guided setup](docs/images/installer-1.2.jpg)
Progress preview with sample compiler output:
![Version 1.2.2 showing a left-to-right progress bar and detailed activity with sample compiler output](docs/images/installer-progress-1.2.jpg)
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
### Supported Xbox ISO revisions
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 is validated with this installer.** Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
| Map-header build | Upstream region |
| --- | --- |
| `01.10.12.2276` | NTSC |
| `01.08.15.1749` | NTSC |
| `01.01.14.2342` | PAL |
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
### Already have Halo installed?
Choose **Repair Halo VR** in the **Game data** step, connect your Frame, and click **Repair Halo VR** in the **Install Halo VR** step. Setup detects the standard native folder `~/Games/HaloCENativeVR` and reinstalls the native executable, SDL library, controls/tutorial patch, and notices; it restores the required VR/control settings while preserving saves and unrelated settings. Valid original Xbox maps already on the Frame can be reused without selecting another ISO. If maps are missing or damaged, select a valid local image/maps folder and retry. Previous installs made by this installer or the original guided native setup are recognized. A hand-installed native game in the same folder can be adopted only through the explicit Repair action, after its native executable and original Xbox maps validate. Unknown folders and other install locations are not overwritten.
The old PC installation at `~/Games/HaloCEVR` is detected and explained separately. Its PC maps cannot be used for this native Xbox build. Setup installs the native version separately and does not automatically remove the PC version. Close the native game before repair. Program-file backups are retained in the owned build workspace, and a failed repair rolls back the affected files. **Add to Steam again** repairs library registration and adds missing library artwork; it does not rebuild the game.
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB of free internal storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select `~/Games/HaloCENativeVR/halo`, apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
### Uninstall Halo VR
Choose **Uninstall Halo VR** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup asks the running client to remove the exact native shortcut from its active account, then verifies that removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. An owned installation build still running also stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
**Keep my campaign saves in a backup (recommended)** is checked by default. Setup backs up saves inside the native game folder and the original config to `~/Games/HaloCENativeVR-saves-<operation ID>`, and reports the exact backup location. The recognized native folder, including its maps, program files, and config, is then removed; configured saves outside that folder remain untouched. Turning off the checkbox allows saves inside the removed folder to be deleted. Uninstall leaves unrelated games, foreign or unrecognized folders, and unrelated settings alone. The build cache remains available for troubleshooting. Setup disconnects its SSH session after the operation and reaches 100% only after completion.
With the client running, Steam controls artwork cleanup for the removed entry; setup does not delete its live grid files. With Steam already closed, setup removes only artwork matching the installer's known image hashes and preserves custom images.
## Xbox-style controls
| Frame control | Action |
| --- | --- |
| A | Jump / accept |
| B | Melee / back |
| X | Reload / use |
| Y | Change weapon |
| Right trigger / left trigger | Fire / throw grenade |
| Left stick / click | Move / crouch |
| Right stick / click | Turn / zoom |
| Left bumper / right bumper | Change grenade / flashlight |
| Menu / View | Pause / scoreboard |
| Both grips held | Recenter |
Motion aiming stays enabled. Smooth turning defaults to 90 degrees/second, refresh rate to 72 Hz, and render scale to 1.0. Repairs refresh the VR/control defaults and keep unrelated settings and saves. Head movement also satisfies the first-level look tutorial. The patch does not skip the campaign or increase combat difficulty.
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:
```text
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%
```
See [controls and patch details](docs/CONTROLS.md).
## What gets installed
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New-install saves are in that game's separate `save` folder; repairs preserve existing save locations. Build workspace: `~/.cache/halo-frame-installer`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup does not remove another Halo installation or touch unrelated games.
The original Halo CE cover is shown inside the installer. With Steam running, setup asks the verified client interface to add missing portrait and landscape art for the native shortcut, verifies the active account and executable, and checks the written image hashes. It does not directly write live Steam grid files. If Steam is already closed, setup can use guarded file updates instead. Both paths preserve existing artwork, including custom images with another supported file extension. The landscape layout keeps the cover's proportions. Repair and **Add to Steam again** use the same artwork handling. If artwork cannot be verified, setup reports a manual artwork step separately from shortcut registration. Artwork is bundled with the installer; no separate download is needed. The user confirmed that the entry and artwork looked correct on the Frame tested with 1.2.3.
The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenXR VR and competitive multiplayer come from the port. The PC-only Chimera, PC Restored maps, and HaloCEVR DLLs do not belong in this native build. Multiplayer requires compatible native/OpenCE protocol builds; Halo PC/Custom Edition/MCC servers and PC save files are incompatible.
## Sources and credits
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
- [Exact VR source commit `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106), with a documented local controller/tutorial patch.
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr).
- [Valve's official Frame setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) and [SSH/debugging instructions](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
- [Google's ADB connection and forwarding reference](https://developer.android.com/tools/adb) and [official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools).
- [Original Xbox dashboard visual references](docs/SOURCES.md#dashboard-visual-references) and [Halo CE cover-art attribution](docs/SOURCES.md#steam-library-artwork).
Full source attribution, decompilation lineage, multiplayer references, and third-party licenses are in [SOURCES.md](docs/SOURCES.md) and [THIRD_PARTY.md](docs/THIRD_PARTY.md). Original installer code is MIT licensed. Upstream notices apply separately; no license here grants rights to commercial game data.
## Troubleshooting
- **Cannot resolve `frame`:** for Wi-Fi / Ethernet, use the Frame's IP address from its network settings. Both devices must be on a network that permits connections between clients. USB-C transfer does not need the hostname/IP.
- **USB device missing or offline:** verify Developer Mode, keep the Frame awake, use a data-capable cable and a direct PC USB port, and reconnect it. A charging indicator alone does not confirm data transfer. Approve device authorization on the headset if prompted. Setup does not reset shared ADB services or change device settings automatically.
- **Several USB devices detected:** unplug other physical USB devices or enter the Frame's exact ADB serial in **Advanced connection settings**. Setup refuses to guess which device to use. If that serial's physical USB path cannot be verified, leave it blank with only the Frame attached and retry automatic detection.
- **ADB unavailable:** the release EXE includes ADB. If its files cannot be used, extract [Google's official Windows Platform Tools package](https://developer.android.com/tools/releases/platform-tools), keeping `adb.exe` and its companion DLLs together, then choose it with **Browse adb.exe**. Wi-Fi / Ethernet remains available without ADB.
- **ADB server version differs:** setup leaves that server running. Choose the matching `adb.exe` under **Advanced connection settings**, or close the other application's USB tools normally and retry. Network transfer remains available.
- **USB forwarding has network access enabled:** setup refuses to send the SSH password over a forward listening beyond loopback. Close the other application's USB tools normally and retry, or choose network transfer. Setup does not reset the shared server automatically.
- **USB SSH connection refused:** enable Developer Mode and confirm the SSH port in **Advanced connection settings**. USB still needs the Frame's SSH service and user password; ADB authorization alone does not authenticate SSH. The USB path forwards the Frame's default SSH port 22.
- **Connection refused:** enable Developer Mode and verify the address and SSH port. [Valve guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
- **Authentication fails:** enter the password set in the Frame's Developer settings; your Steam account password is unrelated.
- **Host key changed:** setup refuses the connection. Verify the new device/key independently before removing its old public fingerprint from `%LOCALAPPDATA%/HaloFrameInstaller/hosts.json`.
- **Insufficient disk space or Podman missing:** resolve the failed preflight check and retry. Setup does not disable SteamOS protections or install system packages as root.
- **Build/download fails:** the error dialog shows a short failure headline; open **Show activity** or **Save log** for the full redacted details. Keep the Frame's build log and retry. Version 1.2.1 fixes the container dependency-installation permission errors (`setgroups`, `seteuid`, or APT cache permissions) seen in 1.2.0. Retry can reuse previously uploaded maps only when the complete map set matches the selected local data manifest and passes header and SHA256 verification; otherwise setup uploads fresh data. Reused data stays in its previous owned upload run and is verified again at build and finalization. A failed staging build does not replace an existing game or save.
- **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.
- **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.
- **Box art absent:** click **Add to Steam again** to retry artwork through the running client, or use Steam's custom artwork controls if its interface remains unavailable. Setup's direct file edits require Steam to be already closed. Existing custom artwork is kept. An artwork error is reported separately from successfully adding the game shortcut.
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](docs/SOURCES.md#steam-session-safeguard).
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
The release executable is unsigned. Verify its SHA256 against the checksum in the release description; this project does not ask you to disable antivirus or other security protections. Review logs for private information before posting an issue.
## Develop and rebuild the executable
```powershell
python -m venv .venv
.\.venv\Scripts\python -m pip install -e ".[dev]"
.\.venv\Scripts\python -m pytest
.\.venv\Scripts\python -m halo_frame_installer
.\.venv\Scripts\python scripts/build_release.py
```
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.0 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.0)** so you can modify and rebuild it, including its LGPL Paramiko dependency. GitHub's automatically generated Source code ZIP is optional for developers and is not needed to run setup. ADB binaries are not checked into Git; [bundle_usb_tools.py](scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](resources/usb/manifest.json) records those hashes and provenance, and the original notices are retained. The build script packages installer code/resources and verified ADB components with their notices; no game binary, user ISO, maps, SSH password, or private key is included. GitHub Actions runs tests and builds the Windows setup executable. A local rebuild also writes a source bundle and checksum file to `dist/`; the public release attaches only the clearly named installer executable and prints its checksum in the release description.
The workflow also includes an ARM64 Linux check, [check-build-container.py](scripts/check-build-container.py), that uses the same rootless Podman arguments as the Frame build. It checks APT package installation, `setgroups`, `seteuid`, `setegid`, `chown`, and that container output belongs to the ordinary host user. The Windows executable CI job depends on that check as well as the unit tests. This container check uses no game data and does not replace a full Steam Frame installation test.
Before reporting a hardware test, record the SteamOS/SteamVR version, source commit, install status, and whether menu, campaign, Xbox controls, tutorial, and compatible multiplayer worked. Do not attach game data or credentials.
[Setup](docs/SETUP.md) · [Controls](docs/CONTROLS.md) · [Release notes](docs/RELEASE_NOTES.md) · [Sources](docs/SOURCES.md) · [Third-party notices](docs/THIRD_PARTY.md)
+4
View File
@@ -17,3 +17,7 @@ The Xbox image parser treats selected files as untrusted, bounds directory/file
The source revision is fixed, but the native build still downloads an Ubuntu container and toolchain/dependencies from their upstream distributors. This is not an offline or completely hermetic build. Updates to the fixed source and controller patch must be reviewed and tested together.
Cancellation stops the installer-owned build container; an already completed installation may remain. Repairs back up program files and roll back a failed transaction, preserving saves and unrelated settings. Supplied valid Xbox maps may replace existing maps with backups; no-image repairs reuse existing verified maps. Unknown native folders are adopted only through an explicit Repair request and strict native/data validation. With Steam running, library registration uses the verified client's bounded local interface and reads back the exact native shortcut settings and artwork. Direct library-file edits require Steam to be already closed, use backups, and preserve unrelated entries. Setup never shuts down or relaunches Steam. All paths close their SSH channels; USB also removes its own temporary forward.
The Steam info operation verifies an existing native install and updates only its library information, without map transfers or compilation. Hero, logo, covers, and icon sources are checksum-verified; custom images and icon paths are preserved. The icon's persistent sidecar is restricted to the private owned game folder. Live shortcut-file access for icon identity is read-only, and an uncertain resident icon is deferred rather than overwritten.
The optional game description uses Steam Game Notes through the verified running client, not an unofficial store-identity substitution. Account, native executable, shortcut name, note schema, and document size are checked. Existing notes and unknown fields are retained, edited installer notes are preserved, and a concurrent reread precedes saving. Notes use Steam's normal cloud synchronization; no password or private setup log is placed in them. Unverified Notes results show manual copyable text without restarting Steam or directly writing notes files. The private client interfaces may change; unsupported capabilities fall back to manual steps.
+11 -6
View File
@@ -1,10 +1,10 @@
# Steam Frame controls
# Steam Frame controls — 1.4.0
The default installation uses tracked controller aiming with Xbox-style buttons. Point the right controller to aim; move your head to look around. These controls assume the game's default profile button layout.
1.4.0 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.
| Frame input | Action |
| --- | --- |
| Left stick | Move |
| Left stick | Move relative to headset direction |
| Left stick click | Crouch |
| Right stick left/right | Turn |
| Right stick click | Zoom |
@@ -24,19 +24,24 @@ The default installation uses tracked controller aiming with Xbox-style buttons.
The installer defaults to controller aiming, seated height, 72 Hz, resolution scale 1.0, smooth turning at 90 degrees per second, and two-handed aiming. Gesture melee is disabled so **B** remains the predictable melee input. These are installer preferences, not the upstream defaults. You can change supported options in **Settings > VR Setup** or the game's `config.toml`. Choose snap turning in VR Setup if you prefer turning in steps. Some display changes require restarting the game.
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 the updated reticle motion works. 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.
## Local changes to upstream
The [included patch](../resources/frame-controls.patch) makes two changes:
The [included patch](../resources/frame-controls.patch) includes these controls and menu changes:
1. It sets **LB = change grenade** and **RB = flashlight**. The pinned upstream branch assigns those two bumpers the other way around.
2. Physical headset movement counts toward the campaign's look-direction tutorial while motion-controller aiming is active. Follow the technician's instructions and physically look up, down, left or right as requested. The patch ignores small tracking jitter and does not add head rotation to the weapon's aim. It lets the tutorial complete at the selected difficulty; it does not skip the campaign or raise its difficulty.
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"`.
4. **Multiplayer > CO-OP CAMPAIGN** offers **HOST LOCAL (LAN)**, **JOIN LOCAL (LAN)**, **HOST ONLINE (INTERNET)**, **JOIN ONLINE (INTERNET)**, and **JOIN INVITE LINK**. Choose a host route, then campaign level and difficulty. Your friend uses the corresponding join route. Online hosting stays private by default; **LISTING = PUBLIC** allows server-browser joining by anyone. Each machine has one campaign player in VR and flat mode; campaign has no second-controller or split-screen option. See the [co-op setup guide](SETUP.md#online-campaign-co-op-with-a-friend).
The game's Steam shortcut also suppresses Steam's duplicate virtual Xbox gamepad with `SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0`. Frame controller poses, buttons and haptics still use OpenXR. This setting applies only to this Halo shortcut.
## First launch
Keep Steam Home and the headset runtime awake during setup. Launch **Halo: Combat Evolved VR (Native)** from the Frame's library and create a profile. Use the d-pad to select menu items and **A** to accept. Keep the headset on and hold both grips if the menu appears outside your view.
Keep Steam Home and the headset runtime awake during setup. Launch **Halo: Combat Evolved VR (Native)** from the Frame's library. 1.4.0 replaces the 1.3.5 program while retaining its old saves unchanged. The new engine uses `~/Games/HaloCENativeVR/save-v1.4`; create a new profile and campaign because old checkpoints are incompatible and setup does not migrate 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.
For a control issue, first check **Settings > Gamepads** for a changed profile layout. Then check **Settings > VR Setup**: this layout uses controller aiming. Upstream also offers gamepad aiming, which moves aim with the right stick and has different camera behavior.
The original controls and all VR configuration options are documented in the [pinned upstream VR guide](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr). The [SDL hint source](https://github.com/libsdl-org/SDL/blob/release-3.4.16/include/SDL3/SDL_hints.h) documents virtual-gamepad filtering.
The original controls and all VR configuration options are documented in the [pinned upstream VR guide](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr). The [SDL hint source](https://github.com/libsdl-org/SDL/blob/release-3.4.16/include/SDL3/SDL_hints.h) documents virtual-gamepad filtering.
+11 -16
View File
@@ -1,23 +1,18 @@
# Halo Steam Frame Setup 1.3.0
# Halo CE Steam Frame VR Mod 1.4.0
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.0.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.0/Halo-Steam-Frame-Setup-1.3.0.exe)**. This is the complete Windows installer, including the ADB components needed for USB transfer. No installer ZIP, Python installation, or separate Platform Tools download is needed.
[**Download Halo-Steam-Frame-Mod-Setup-1.4.0.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.0/Halo-Steam-Frame-Mod-Setup-1.4.0.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.0); matching installer source is in the [v1.4.0 tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.0). The headset tester confirmed the engine and controls used by this release, including reticle motion and LAN/online campaign co-op.
Version 1.3.0 adds a **USB-C cable** option in **Connect Frame**, alongside **Network (Wi-Fi or Ethernet)**. A data-capable cable connects the PC directly to the Frame. Setup forwards its encrypted SSH connection over ADB, so USB does not require the Frame's hostname or IP address. Developer Mode, the Frame's user password, and approval of its SSH host fingerprint remain required; authorize the computer on the headset if prompted.
- **Render-rate reticle:** corrects the visible stepping reported after 1.4.0a4. The read-only shot preview uses current-frame aim for the local on-foot player instead of only the 30 Hz unit direction. It retains the shared muzzle/camera calculation, trigger offsets, aim assist, collision mask and current hit test. Actual shots, spread and ballistics keep their behavior. Vehicle aiming retains the bounded turret path. The tester confirmed the new motion on the headset; no frame-time or FPS measurements were supplied.
- **LAN and online campaign:** Multiplayer > CO-OP CAMPAIGN offers HOST LOCAL (LAN), JOIN LOCAL (LAN), HOST ONLINE (INTERNET), JOIN ONLINE (INTERNET), and JOIN INVITE LINK. Campaign uses one local player per machine in VR and flat mode; the former second-controller/split-screen campaign route is removed. Campaign hosting starts on SINGLEPLAYER maps. Competitive multiplayer keeps its existing routes.
- **Updated Steam Notes:** Install/Repair adds the new co-op directions automatically. Only exact unchanged previous installer notes are upgraded; edited notes and other user fields are preserved.
- **Earlier fixes retained:** nominal shot/reticle alignment, half-strength stereo sun glow, head-directed movement, Xbox-style buttons and physical-head tutorial controls. The tester reported that 1.4.0a4 corrected aiming alignment, then confirmed the updated reticle motion and campaign routes work in the build used for 1.4.0.
- Selects the sole physical USB device automatically. When several are connected, unplug the others or enter the Frame's exact ADB serial. Emulators and ADB network devices are excluded from USB selection.
- Includes verified ADB 37.0.1 files (protocol 1.0.41) with their original notices. An existing ADB client on `PATH` takes precedence; **Advanced connection settings > Browse adb.exe** can select a specific installation. [Google's official download and terms](https://developer.android.com/tools/releases/platform-tools) remain available for replacements. The bundled files use a verified persistent cache so a shared server cannot lock the EXE's temporary extraction folder.
- Uses the same guided install, repair, **Add to Steam again**, and uninstall workflows over either connection. Encrypted SSH/SFTP, user-password authentication, and changed-host-key refusal remain in place.
- Closes SSH and removes only this operation's temporary ADB forward on completion, failure, or cancellation. Setup leaves the shared ADB server and other applications' forwards running. It does not change the Frame's Wi-Fi, Steam streaming settings, or Steam/SteamVR session.
- Refuses an incompatible existing ADB server without replacing it, clears inherited remote-server overrides, and checks the actual forwarded TCP listener is restricted to loopback before sending the SSH password.
- Retains the 1.2.3 Steam Home safeguard, verified live shortcut/artwork registration, Xbox-style controls, original Xbox shader/data support, tutorial head-look fix, safe repair and save-backup options, and the original Xbox dashboard UI.
- Retains measured left-to-right progress, detailed activity, and redacted logs. A remaining manual Steam/artwork step stays at 99%; full success is reported only after disconnecting.
**1.4.0 replaces 1.3.5 in place.** Close the native game and choose **Install** or **Repair**. Install recognizes a verified 1.3.5 installation and rebuilds it; verified Xbox maps can be reused without another ISO. The game remains at `~/Games/HaloCENativeVR` with the Steam title **Halo: Combat Evolved VR (Native)** and the normal installer cache.
**The Frame still needs its own internet connection** to download native source, build containers, and compiler dependencies. USB carries PC-to-Frame traffic; it does not provide internet sharing or speed up the compilation that still runs on the Frame. Transfer speed depends on the cable, USB ports, and device, so a speed improvement is not guaranteed.
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 new engine uses `~/Games/HaloCENativeVR/save-v1.4`: create a new profile and campaign. Its larger game-state layout cannot load old checkpoints, and setup does not automatically migrate checkpoints or profiles. Existing 1.4.0a2–a5 copies under `~/Games/HaloCENativeVRExperimental` remain untouched and can be kept for reference; this public installer targets the normal native installation.
**Validation:** USB behavior has automated software coverage; a physical Steam Frame USB transfer has not yet been verified. The native game, Xbox controls, and 1.2.3 live Steam shortcut/artwork helper were previously tested on one Frame with Steam Home and SteamVR active. Those tests do not replace USB hardware testing, a fresh first installation, or a complete game uninstall test. The upstream native ARM64/OpenXR port remains experimental, and performance varies by scene and device.
Source remains pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`, native peer protocol **17**. Use matching builds/maps for co-op; stable protocol 11 and retail Halo clients cannot join. Online lobbies stay private by default; public listing lets anyone join. Internet play has no gameplay relay, so restrictive NAT can prevent distant peers from connecting. The reported headset test does not specify peer distance or prove compatibility across all networks. Renderer changes do not establish the cause or resolution of earlier white flashes or stuttering; actual Frame performance has not been measured.
Use your own authorized **original Xbox** Halo: Combat Evolved ISO/XISO or extracted maps. **USA Rev 2 is validated.** Other retail revisions and regions, including images labeled USA Rev 1, are accepted only when all 24 required version-5 maps share an approved build from the [pinned compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631). PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages remain unsupported. No game ISO, commercial maps, soundtrack, keys, or game binary is included.
Supply your own authorized original Xbox Halo CE data. No retail executable, ISO, maps, keys, or soundtrack is bundled or downloaded. Halo identification artwork and licensed components retain their notices. The EXE is unsigned; verify it against the SHA256 shown on the release page.
The **[version 1.3.0 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.0)** includes installer source, build scripts, and applicable third-party notices/source. GitHub's automatically generated source downloads are optional for developers. The public release attaches one clearly labeled EXE; its SHA256 is printed in the release description. The executable is unsigned.
Sources: [Valve's Steam Frame USB/SSH guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging), [Google's ADB reference](https://developer.android.com/tools/adb), and [Google's Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools). See the repository README and [full source acknowledgements](SOURCES.md) for native VR, controls, game-data compatibility, artwork, dependencies, and troubleshooting.
[Setup guide](SETUP.md) - [Sources and credits](SOURCES.md) - [Third-party notices](THIRD_PARTY.md)
+204
View File
@@ -0,0 +1,204 @@
# Halo CE Steam Frame VR mod — setup 1.4.0
[Back to the project README](../README.md)
1.4.0 replaces an existing verified 1.3.5 installation at `~/Games/HaloCENativeVR`, keeping the Steam title **Halo: Combat Evolved VR (Native)**. Old saves are retained; the new engine starts with `save-v1.4` and a new profile/campaign.
## Before you begin
Download and run the single [**Halo-Steam-Frame-Mod-Setup-1.4.0.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.0/Halo-Steam-Frame-Mod-Setup-1.4.0.exe) for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. Verify its SHA256 against the checksum shown on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.0).
Use your own authorized original Xbox Halo CE ISO/XISO or extracted maps. USA Rev 2 was validated with the stable installer; the supported-build table below explains other retail revisions. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are unsupported. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded. The project provides no game-download sources.
Halo artwork and trademarks remain copyrighted to their respective owners, and third-party software retains its licenses and notices. This community project is unaffiliated with Microsoft, Xbox, Bungie, or Valve. Its license does not grant rights in retail game data or certify the legal status of upstream source; see [third-party notices](THIRD_PARTY.md).
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB of free internal storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
The headset tester confirmed the engine and controls used by this release, including reticle motion and LAN/online campaign co-op. The earlier 1.4.0a4 test confirmed shot/reticle alignment but identified visible reticle stepping, which the current render-rate preview addresses. No measured FPS, frame timing, peer-distance details, or complete uninstall test was supplied. Earlier white flashes and stuttering have not been separately diagnosed.
The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon. Its [source and hashes](../resources/ui/README.md) are documented separately from the installer's code license. The Steam library's project-drawn helmet icon is unchanged.
## Engine and save compatibility
The source is pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`. It includes upstream network campaign co-op, delayed texture-stage binding, and vertex clip-position handling changes. These renderer changes are a candidate improvement; they do not establish the cause or resolution of reported white flashes or frame drops.
1.4.0 uses **`~/Games/HaloCENativeVR`** and the Steam title **Halo: Combat Evolved VR (Native)**, replacing 1.3.5's program in place. 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 new engine's larger game-state capacity makes old checkpoints incompatible, so setup selects **`~/Games/HaloCENativeVR/save-v1.4`** for new saves. Create a new profile and campaign; setup does not automatically migrate 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.0 installer targets the normal native installation.
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.
## Online campaign co-op with a friend
Both players should use **the same 1.4.0 build** and supply their own supported original Xbox game data. Each player runs the game on their own headset or computer; campaign has one local player per machine. Use the left d-pad to navigate rows and change left/right choices, **A** to accept, and **B** to go back.
1. **Host:** open **Multiplayer > CO-OP CAMPAIGN**. On the same network choose **HOST LOCAL (LAN)**; across different networks choose **HOST ONLINE (INTERNET)**. The Map screen starts on **SINGLEPLAYER**. Choose a campaign level and difficulty.
2. In **Server Setup**, set **MAXIMUM PLAYERS** to 2 for two players, then select **START GAME**. For online browser discovery, change **LISTING** to **PUBLIC**. **PUBLIC lets anyone see and join the game.** Online co-op starts **PRIVATE** by default; setup does not force public listing.
3. **Friend:** open **Multiplayer > CO-OP CAMPAIGN > JOIN LOCAL (LAN)** or **JOIN ONLINE (INTERNET)** to match the host. Choose **REFRESH** if needed, select the host's game and press **A**. When both names appear in the lobby, let the countdown finish or select **START NOW**. If joining a game already underway opens its preview, select **JOIN GAME**.
For invite-only play, keep **LISTING = PRIVATE**. When the host's **INVITE LINK** row shows a `halo://join/` link, select that row and press **A** to copy it. Share it yourself through an app on the host's Frame. The friend copies it onto **their Frame's clipboard**, opens **Multiplayer > CO-OP CAMPAIGN > JOIN INVITE LINK**, selects **PASTE LINK**, then selects the host's game. Returning to the game can also consume the clipboard invite automatically. The PC and Frame have separate clipboards. The invite becomes unusable after the hosting game quits; switching a public game to private makes a new invite.
If a public game is missing, check that internet play is enabled on both games and that both use at least one reachable common broker. The browser needs broker support for retained listings; the included list supplies the defaults. Repairs preserve custom network settings, so an existing `network.online = false` or `network.public_lobby = false` must be corrected. **Settings > Network Setup > INTERNET PLAY** can enable internet play; restart Halo afterward.
If a game is visible but joining says **FAILED**, check matching builds/maps first. Gameplay uses **direct UDP**, STUN, and optional UPnP; the MQTT brokers carry discovery/signaling, **not a gameplay relay**. Strict NAT or carrier-grade NAT can prevent a distant friend joining. Try hosting from the other network. If an accessible home router still blocks it, choose a fixed `network.tunnel_port` in that game's `config.toml`, restart Halo, and forward that **UDP** port to the Frame on that router. UPnP cannot bypass a second upstream NAT. The successful headset report does not specify distant-peer or restrictive-NAT test coverage. [Primary internet-play and menu sources](SOURCES.md#online-co-op-menu-and-connection).
## Install Halo VR
1. **Game data.** Choose **Install**. For a first installation, use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. A recognized, verified 1.3.5 installation can reuse its Xbox maps without another ISO; see [upgrade instructions](#already-have-halo-installed). Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
2. **Connect Frame.** Enable Developer Mode and set the Frame's user password, then choose **Network (Wi-Fi or Ethernet)** or **USB-C cable**. For network transfer, keep both devices on a network that permits connections between them and enter the Frame's hostname/IP. For USB, connect a data-capable cable directly to the PC and approve device authorization on the headset if prompted; no Frame IP is required. The SSH login remains `steamos`, port 22; username and port are under **Advanced connection settings**. Click **Test connection** and approve the displayed host fingerprint only after identifying your device. Your password is used in memory and is never saved or logged.
3. **Install Halo VR.** Save and close games, confirm **I have saved and closed games on my Frame**, then click **Install Halo VR**. Keep Steam Home and SteamVR running, and leave the Frame awake and connected. The app uploads or verifies maps, creates a rootless build container, compiles the pinned VR source, applies the controls patch, and registers **Halo: Combat Evolved VR (Native)** through the running Steam client when its verified library interface is available. Any remaining shortcut or artwork step is shown at the end; it does not require rebuilding the game. The first build may take tens of minutes. Activity opens automatically and shows the current phase and live build output; use **Save log** for troubleshooting.
### Transfer over a USB-C cable
1. In the Frame's **Steam Settings > System**, enable **Developer Mode**. In **Developer**, set a user password. Leave Steam Home and SteamVR running.
2. Connect the Frame directly to your Windows PC with a USB data cable. A charging-only cable cannot transfer files. Keep the Frame awake and allow this computer's device authorization on the headset if prompted.
3. In **Connect Frame**, choose **USB-C cable**. Setup first uses ADB already available on your computer's `PATH`, then falls back to its included ADB 37.0.1 files. **Advanced connection settings > Browse adb.exe** can select a specific installation. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
4. Enter the same Frame user password used for SSH and click **Test connection**. Setup selects the sole physical USB device automatically. If several physical USB devices are connected, unplug the others or enter the Frame's exact ADB serial in **Advanced connection settings**. If a serial cannot be verified as a physical USB connection, unplug other devices and leave the serial field blank for automatic detection. Emulators and network-connected ADB devices are not USB targets. Review the Frame's SSH host fingerprint when asked.
5. Continue with install, repair, library registration, or uninstall. Leave the cable attached until setup reports that it has disconnected. Setup removes only the temporary ADB forward it created and leaves the shared ADB server and other forwards running.
The cable carries encrypted SSH/SFTP traffic between the PC and Frame. **The Frame still needs its own internet connection** to fetch native source, the build container, and compiler dependencies. USB does not provide internet sharing or change Wi-Fi/Steam settings. Cable transfer may help on a weak or congested wireless network; actual speed depends on the cable, USB ports, and device. It does not speed up the native compilation phase. USB behavior has automated software coverage; physical Steam Frame USB transfer verification is pending. [Valve's USB/ADB guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) and [Google's ADB reference](https://developer.android.com/tools/adb) describe the underlying connection and forwarding tools.
The installation bar fills from left to right and does not move backward during a run. Its overall percentage combines weighted stages with measured upload bytes and completed Ninja build tasks. During work without a measurable total, such as package setup or configuration, the bar holds its last value while the activity feed continues showing available output. This percentage describes completed work, not elapsed time or a finish-time estimate. Failure or cancellation preserves the reached progress. Pending Steam information, uninstall files, or connection cleanup stays at 99%, with the remaining action explained; only full success reaches 100%.
Setup closes its SFTP and command channels and the underlying SSH connection after each operation, including failure and cancellation. The activity feed shows disconnecting and disconnected stages when cleanup succeeds. A connection-cleanup failure is shown separately from the game result: an installed game remains installed, and a completed removal remains recorded. Follow the displayed connection-cleanup instructions before closing setup; a remaining cleanup step stays at 99%. USB cleanup removes only this operation's temporary ADB forward. Setup does not stop the shared ADB server or change the Frame's Wi-Fi or Steam streaming settings.
If a prepared build is cancelled, times out, or loses its connection, setup tries to stop that same installer run. If the existing stop request fails, it makes one bounded reconnect attempt using the SSH host key you already approved. It does not approve a different key. If the Frame cannot confirm stopping, setup warns that the build may still be running and provides the exact stop command for the Frame's terminal. Restore the connection and follow that command before retrying installation or uninstalling. The original cancellation or failure remains visible even if its stop request or connection cleanup also fails.
The included ADB executable, two companion DLLs, and unmodified notices are checksum-verified before being copied to `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1`. This versioned cache remains after setup closes so a shared ADB server can keep using it without locking the EXE's temporary extraction folder. The selected client's protocol is checked against an already-running local ADB server; a mismatch stops USB setup without restarting that server. Setup also verifies that the temporary forwarded port listens only on this PC's loopback addresses before sending the SSH password. [Bundled-tool provenance and licenses](THIRD_PARTY.md#adb-for-usb-transfer).
These changes apply after you launch the new EXE and start a run. An installation already running in an older EXE keeps its existing behavior; downloading a replacement cannot change that ongoing process.
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Validation and music preparation run in the background. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
### Supported Xbox ISO revisions
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 was validated with the stable installer.** 1.4.0 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 |
| --- | --- |
| `01.10.12.2276` | NTSC |
| `01.08.15.1749` | NTSC |
| `01.01.14.2342` | PAL |
These builds come from the [pinned port's compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c#L616-L631). Its [PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/pal_tags.c#L4-L22) describe the shared NTSC maps across three retail releases. Setup checks the image contents, not its filename or a Rev label: it requires all 24 original Xbox version-5 maps from one supported build. Incomplete or mixed-build sets and unlisted builds are rejected. Halo PC/Custom Edition, Xbox 360 Anniversary, MCC, and Quest packages are unsupported inputs. Supply your own authorized retail ISO/XISO or extracted maps.
### Already have Halo installed?
To replace 1.3.5, close **Halo: Combat Evolved VR (Native)** and choose **Install** or **Repair**. Install recognizes a verified 1.3.5 installation in `~/Games/HaloCENativeVR` and rebuilds it with 1.4.0. Setup replaces the native program, SDL library, broker list, patch, and notices, reusing verified Xbox maps without another ISO. 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 upgrade retains old saves at their original location and backs up the original config and program in the owned build run's `previous-program-files` folder. It points the new engine to `save-v1.4`, leaving the old game-state data untouched. Start a new profile/campaign; there is no automatic checkpoint or profile migration. Later repairs retain 1.4.0 saves and unrelated settings. A failed replacement rolls back the affected program/config files. The separate 1.4.0a2–a5 Experimental copy is not removed or upgraded by this installer.
The old PC installation at `~/Games/HaloCEVR` is detected and explained separately. Its PC maps cannot be used for this native Xbox build. Setup installs the native version separately and does not automatically remove the PC version. Close the native game before repair. Program-file backups are retained in the owned build workspace, and a failed repair rolls back the affected files. **Add to Steam again** repairs library registration and adds missing library artwork; it does not rebuild the game.
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select `~/Games/HaloCENativeVR/halo`, apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
### Automatic Steam library information
Install and Repair automatically update the native Steam shortcut, missing artwork, and **Halo CE VR — About & controls** note after the game is ready. The note includes online hosting/joining instructions. Only exact unchanged earlier installer notes are upgraded; edited or unfamiliar notes remain untouched. There is no separate Steam info option to select. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects. If registration, artwork, or Notes remains pending at the end, **Add to Steam again** retries that step without transferring maps or rebuilding the game.
Open **Notes** on the Halo game's Steam details page or in its overlay to read the description and control guide. These are private game notes using Steam's normal notes synchronization. They do not replace the non-Steam About panel or add a store page, achievements, reviews, or another game's Steam identity. Existing notes and edited installer notes remain unchanged. If Notes is unavailable or synchronization cannot be verified, setup shows copyable text for manual use. Missing artwork or an icon awaiting Steam's shortcut-file persistence is reported separately and can be retried with **Add to Steam again**. Notes requires a running, verified Steam client; setup leaves a closed Steam client closed.
### Uninstall Halo VR
Choose **Uninstall** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup matches native shortcuts by their exact executable, so renaming the entry in Steam does not prevent removal. It verifies shortcut removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. Invalid matching shortcut records also stop removal before library-file changes. An owned installation build still running stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
**Keep my campaign saves in a backup (recommended)** is checked by default. Setup backs up recognized saves inside the native game folder, including retained `save` and active `save-v1.4` data, together with the game's config to `~/Games/HaloCENativeVR-saves-<operation ID>`. It reports the exact backup location. The recognized native folder, including its maps, program files, and config, is then removed; configured saves outside that folder remain untouched. Turning off the checkbox allows saves inside the removed folder to be deleted. Uninstall leaves unrelated games, foreign or unrecognized folders, and unrelated settings alone. The build cache remains available for troubleshooting. Setup disconnects its SSH session after the operation and reaches 100% only after completion.
If an earlier uninstall was interrupted, run **Uninstall** again. Setup can finish deleting its verified unfinished game folders even when the main game folder is gone or Halo has since been reinstalled. It checks the earlier operation's private record and the remaining folder's identity before resuming. Verified save backups remain in place, and their paths appear in the result. Changed or unverified folders are preserved and listed for review; setup reports removal pending at 99% instead of claiming the game was already absent. Review **Show activity** and **Save log** for these paths. Do not delete a reported save backup. If setup reports that it reached its recovery limit, run Uninstall again to continue; folders that fail verification need manual review.
With the client running, Steam controls artwork cleanup for the removed entry; setup does not delete its live grid files. With Steam already closed, setup removes only artwork matching the installer's known image hashes and preserves custom images.
## Xbox-style controls
| Frame control | Action |
| --- | --- |
| A | Jump / accept |
| B | Melee / back |
| X | Reload / use |
| Y | Change weapon |
| Right trigger / left trigger | Fire / throw grenade |
| Left stick / click | Move / crouch |
| Right stick / click | Turn / zoom |
| Left bumper / right bumper | Change grenade / flashlight |
| Menu / View | Pause / scoreboard |
| Both grips held | Recenter |
Motion aiming stays enabled. Smooth turning defaults to 90 degrees/second, refresh rate to 72 Hz, and render scale to 1.0. Repairs refresh the VR/control defaults and keep unrelated settings and saves. Head movement also satisfies the first-level look tutorial. The patch does not skip the campaign or increase combat difficulty.
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:
```text
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%
```
See [controls and patch details](CONTROLS.md).
## What gets installed
Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, config, notices, and an ownership/provenance manifest. New engine saves use `save-v1.4`; old 1.3.5 saves stay in `save` or their original configured location. Build workspace: `~/.cache/halo-frame-installer`; previous program/config backups are retained inside the owned installer run's `previous-program-files` folder. Windows installer state uses `%LOCALAPPDATA%/HaloFrameInstaller`. Source is fetched at the fixed commit below, not a moving branch. Build cache is retained for troubleshooting. Setup replaces the recognized 1.3.5 native program and leaves other Halo installations and unrelated games alone.
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the project-drawn Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is reported as pending rather than replacing an uncertain user choice.
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; Install, Repair, and any completion retry use the same handling. No artwork download is needed. The bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](../resources/artwork/README.md). The user confirmed the entry and covers on the Frame tested with 1.2.3. Banner, logo, icon, and Notes integration also have automated coverage.
The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenXR VR and competitive multiplayer come from the port. The PC-only Chimera, PC Restored maps, and HaloCEVR DLLs do not belong in this native build. Multiplayer requires compatible native/OpenCE protocol builds; Halo PC/Custom Edition/MCC servers and PC save files are incompatible.
## Sources and credits
- [OpenCE Steam Frame VR PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85), by startupfoundry.
- [Exact VR source commit `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4), with a documented local controller/tutorial patch.
- [Upstream native Linux/VR build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr).
- [Valve's official Frame setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) and [SSH/debugging instructions](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
- [Google's ADB connection and forwarding reference](https://developer.android.com/tools/adb) and [official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools).
- [Original Xbox dashboard visual references](SOURCES.md#dashboard-visual-references) and [Halo CE artwork attribution and Steam Notes references](SOURCES.md#steam-library-artwork).
Full source attribution, decompilation lineage, multiplayer references, and third-party licenses are in [SOURCES.md](SOURCES.md) and [THIRD_PARTY.md](THIRD_PARTY.md). Original installer code is MIT licensed. Upstream notices apply separately; no license here grants rights to commercial game data.
## Troubleshooting
- **Cannot resolve `frame`:** for Wi-Fi / Ethernet, use the Frame's IP address from its network settings. Both devices must be on a network that permits connections between clients. USB-C transfer does not need the hostname/IP.
- **USB device missing or offline:** verify Developer Mode, keep the Frame awake, use a data-capable cable and a direct PC USB port, and reconnect it. A charging indicator alone does not confirm data transfer. Approve device authorization on the headset if prompted. Setup does not reset shared ADB services or change device settings automatically.
- **Several USB devices detected:** unplug other physical USB devices or enter the Frame's exact ADB serial in **Advanced connection settings**. Setup refuses to guess which device to use. If that serial's physical USB path cannot be verified, leave it blank with only the Frame attached and retry automatic detection.
- **ADB unavailable:** the release EXE includes ADB. If its files cannot be used, extract [Google's official Windows Platform Tools package](https://developer.android.com/tools/releases/platform-tools), keeping `adb.exe` and its companion DLLs together, then choose it with **Browse adb.exe**. Wi-Fi / Ethernet remains available without ADB.
- **ADB server version differs:** setup leaves that server running. Choose the matching `adb.exe` under **Advanced connection settings**, or close the other application's USB tools normally and retry. Network transfer remains available.
- **USB forwarding has network access enabled:** setup refuses to send the SSH password over a forward listening beyond loopback. Close the other application's USB tools normally and retry, or choose network transfer. Setup does not reset the shared server automatically.
- **USB SSH connection refused:** enable Developer Mode and confirm the SSH port in **Advanced connection settings**. USB still needs the Frame's SSH service and user password; ADB authorization alone does not authenticate SSH. The USB path forwards the Frame's default SSH port 22.
- **Connection refused:** enable Developer Mode and verify the address and SSH port. [Valve guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
- **Authentication fails:** enter the password set in the Frame's Developer settings; your Steam account password is unrelated.
- **Host key changed:** setup refuses the connection. Verify the new device/key independently before removing its old public fingerprint from `%LOCALAPPDATA%/HaloFrameInstaller/hosts.json`.
- **Insufficient disk space or Podman missing:** resolve the failed preflight check and retry. Setup does not disable SteamOS protections or install system packages as root.
- **Build/download fails:** the error dialog shows a short failure headline; open **Show activity** or **Save log** for the full redacted details. Keep the Frame's build log and retry. Version 1.2.1 fixes the container dependency-installation permission errors (`setgroups`, `seteuid`, or APT cache permissions) seen in 1.2.0. Retry can reuse previously uploaded maps only when the complete map set matches the selected local data manifest and passes header and SHA256 verification; otherwise setup uploads fresh data. Reused data stays in its previous owned upload run and is verified again at build and finalization. A failed staging build does not replace an existing game or save.
- **Cancelled, but remote stopping is unconfirmed:** the build may still be running. Restore the connection and run the exact stop command shown by setup in the Frame's terminal before installing or uninstalling again. Setup's recovery connection accepts only your previously approved host key.
- **Game operation finished, connection cleanup pending:** follow the separate cleanup warning and save the activity log. The game outcome is retained; a USB-forward cleanup error does not undo an installation or completed uninstall. Setup leaves the shared ADB server and unrelated forwards alone.
- **Uninstall interrupted or folders preserved:** run Uninstall again to finish verified leftovers. Save backups remain separate and their verified paths are reported. If files cannot be verified, setup lists the preserved folders and remains at 99%; review the log before any manual removal. An invalid matching Steam shortcut must be corrected in Steam before retrying uninstall.
- **Unsupported ISO or maps:** choose original Xbox retail data matching a [supported map build](#supported-xbox-iso-revisions). USA Rev 2 is validated; a filename containing Rev 1 or Rev 2 does not establish compatibility by itself. Keep all required maps from one release together.
- **Flat window instead of VR:** wake the headset and start SteamVR before launching. The port falls back to a flat window if no usable OpenXR session is available.
- **Controls doubled:** check the native shortcut's launch option above. Steam Input may otherwise add a second virtual controller stream.
- **Library entry absent:** click **Add to Steam again** to retry registration through the running client. If its library interface remains unavailable, use Steam's **Add a Non-Steam Game** with the executable and launch option above. Enable **Include in VR Library** and leave forced compatibility tools disabled in its properties. Setup's direct library-file edits require Steam to be already closed.
- **Artwork or description absent:** Install and Repair add these automatically. If setup reports pending Steam information, click **Add to Steam again** after the run. The description is under **Notes**, not the non-Steam About panel. Pending artwork, icon, or Notes is reported separately from the shortcut; manual Notes includes copyable text. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](SOURCES.md#steam-session-safeguard).
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
The executable is unsigned. Verify its SHA256 against the checksum shown on the release page; this project does not ask you to disable antivirus or other security protections. Review logs for private information before sharing them.
## Develop and rebuild the executable
```powershell
python -m venv .venv
.\.venv\Scripts\python -m pip install -e ".[dev]"
.\.venv\Scripts\python -m pytest
.\.venv\Scripts\python -m halo_frame_installer
.\.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.0 Git tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.0), with GitHub's automatic source downloads available for developers. Applicable third-party source/notices are included for rebuilding, including the LGPL Paramiko dependency. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance. The package contains installer code/resources and verified tools with their notices; no retail executable, ISO, maps, SSH password, or private key is included.
To check an exact local engine checkout without connecting a headset or downloading data, run `python scripts/check-experimental-engine.py PATH_TO_ENGINE_CHECKOUT`. The checker accepts a clean or fully patched checkout at the pinned revision, checks patch applicability in a private temporary projection, and verifies renderer/co-op/protocol/control integration. It generates the ARM64 Ninja graph offline with external downloads disabled; this is a source/configuration check, not an ARM64 compilation or headset test.
The workflow also includes an ARM64 Linux check, [check-build-container.py](../scripts/check-build-container.py), that uses the same rootless Podman arguments as the Frame build. It checks APT package installation, `setgroups`, `seteuid`, `setegid`, `chown`, and that container output belongs to the ordinary host user. The Windows executable CI job depends on that check as well as the unit tests. This container check uses no game data and does not replace a full Steam Frame installation test.
Before reporting a hardware test, record the SteamOS/SteamVR version, source commit, install status, and whether menu, campaign, Xbox controls, tutorial, and compatible multiplayer worked. Do not attach game data or credentials.
+71 -18
View File
@@ -1,26 +1,61 @@
# Sources and acknowledgements
# Halo CE Steam Frame VR mod sources and acknowledgements
The 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.
This community VR mod installer builds the experimental native ARM64 Linux VR port of **Halo: Combat Evolved** from the revision below. It provides a guided installation; the decompilation, renderer, OpenXR implementation and networking are the work of the credited upstream contributors. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded; users provide authorized original Xbox game data.
Sources were checked on 2026-10-05. Links to pinned files describe the build used here; moving documentation may describe newer behavior.
Sources were checked on 2026-10-06 against a local checkout at the exact engine revision. Release 1.4.0 uses the headset-tested 1.4.0a5 engine patch: the tester confirmed reticle motion and LAN/online campaign co-op work. The earlier 1.4.0a4 test confirmed shot/reticle alignment but identified visible stepping. The current build adds render-rate reticle presentation and explicit network campaign routes. The report contains no measured FPS, frame timings, or peer-distance details; source and production C checks provide additional software coverage.
## 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.0. This release separately changes the engine pin and movement patch; automated regression coverage does not replace physical Frame verification of interruption, cancellation, and full uninstall.
## Native game and VR
| Source | Contribution or verified requirement |
| --- | --- |
| [startupfoundry's native Steam Frame VR work, OpenCE PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85) | OpenXR stereo, tracked head and controllers, native ARM64 host, desktop OpenGL and stereo multiview. This is experimental work in an open pull request. The normal upstream CI builds do not enable `--vr`. |
| [Pinned fork revision `88142798513ebd99fc7c6224023e8b44c05d0106`](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106) | Exact source revision selected for this installer, rather than a moving branch. |
| [Upstream build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#64-bit-arm) | Native ARM64 compiler requirements, SDL 3.4.16, musl 1.2.5, and the `linux_arm64` target. |
| [Upstream VR documentation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/README.md#vr) | `configure.py --vr`, OpenXR runtime requirements, motion aiming, gamepad mode, comfort options, and experimental limitations. |
| [startupfoundry's native Steam Frame VR work, OpenCE PR #85](https://github.com/OpenCommunityEdition/OpenCE/pull/85) | OpenXR stereo, tracked head and controllers, native ARM64 host, desktop OpenGL and stereo multiview. This experimental work is documented in the pull request. The normal upstream CI builds do not enable `--vr`. |
| [Pinned fork revision `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4) | Exact source revision selected for this installer, rather than a moving branch. |
| [Upstream build instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#64-bit-arm) | Native ARM64 compiler requirements, SDL 3.4.16, musl 1.2.5, and the `linux_arm64` target. |
| [Upstream VR documentation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#vr) | `configure.py --vr`, OpenXR runtime requirements, motion aiming, gamepad mode, comfort options, and experimental limitations. |
| [OpenCommunityEdition/OpenCE](https://github.com/OpenCommunityEdition/OpenCE) | Community port and integration work from which the VR fork descends. |
| [bnunu/halo-1](https://github.com/bnunu/halo-1) and [punpckhdq/halo](https://github.com/punpckhdq/halo) | Decompilation lineage identified by the upstream README. |
| [Upstream XDK declarations](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/include/xdk/README.md) | The port supplies its own declarations; installing the proprietary Xbox SDK is unnecessary. |
| [Upstream XDK declarations](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/include/xdk/README.md) | The port supplies its own declarations; installing the proprietary Xbox SDK is unnecessary. |
The installer applies [its documented controller and tutorial patch](../resources/frame-controls.patch) to that pinned revision. [Controls and defaults](CONTROLS.md) distinguish these changes from upstream behavior. Performance depends on game scene, headset software and settings; native ARM64 does not imply unlimited performance.
The installer applies [its documented controls, reticle and campaign-menu patch](../resources/frame-controls.patch) to that pinned revision. Its SHA256 is `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.
### 1.4.0 engine changes
- [Two-pass texture-stage resolution and binding](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/d3d8_gl.c#L2669-L2743) resolves/uploads all textures before binding each sampler stage, avoiding an upload overwriting a previous stage's binding.
- [Vertex clip-position handling](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/nv2a_vsh.c#L309-L402) retains the pre-reciprocal clip position and moves zero/NaN clip `w` behind the camera. These changes are upstream renderer candidates; they do not diagnose the user's white flashes or prove improved Frame performance.
- [Network co-op implementation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/network_coop.c) and [distributed-netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/NETCODE.md) describe campaign maps hosted over LAN/internet, synchronized script presentation/events, and host-run AI. The local patch replaces the split-screen campaign entry with explicit LAN and Internet choices.
- [Native network version](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/include/halo_port_limits.h#L54-L74) is **17**, compared with **11** in the stable engine. Compatible peers need matching versions and capacity limits; legacy retail/PC/MCC clients are incompatible.
- [Capacity and saved-game warning](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/include/halo_port_capacity.h#L19-L45) records the larger game-state/AI pools and explicitly invalidates earlier save layouts. 1.4.0 replaces the verified 1.3.5 program at `~/Games/HaloCENativeVR` and keeps **Halo: Combat Evolved VR (Native)**. Old saves stay unchanged at their original location; the new engine uses `save-v1.4` and a new profile/campaign. The original config and program are backed up in the owned installer run's `previous-program-files` folder. Checkpoints and profiles are not automatically migrated. Existing 1.4.0a2–a5 Experimental copies remain untouched.
- [Co-op and broker defaults](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/port_config.c#L340-L375) include 50% additional enemies per extra player and `network.brokers_file`. [The ARM64 staging graph](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/tools/linux_arm64_build.py#L182-L187) includes [the broker list](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/assets/network/brokers.txt); setup stages it while preserving custom broker settings.
The local patch adds independent head-directed stick movement (`vr.movement = "head"`, `HALO_VR_MOVEMENT`), with `"aim"` retaining the previous behavior. It transforms local on-foot throttle after aiming and before queuing the player's action, leaving controller weapon aiming, vehicles, cinematics, menus, gamepad/flat input and remote players separate. Finite-pose/focus checks stop invalid movement; near-vertical head direction uses the last usable horizontal heading. The existing Xbox bumper mapping and physical-head tutorial hook are ported to this source.
[check-experimental-engine.py](../scripts/check-experimental-engine.py) verifies the exact checkout, clean patch applicability, these renderer/network/configuration invariants, and the actual offline ARM64 Ninja graph. Focused production C math checks cover locomotion. These checks complement the successful headset report; they do not measure comfort or hardware performance or establish co-op behavior across every network.
### Reticle and sun-glow changes
The local renderer patch previews the nominal primary shot through shared calculations from [weapon projectile creation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/items/weapons.c) and [player projectile aiming](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/game/aim_assist.c). The preview omits target/timestamp writes and firing randomness. It uses the projectile collision mask and current hit depth instead of the controller-origin ray and smoothed reticle depth. This predicts the nominal line, not random spread or later ballistic drop. Production C checks cover the shared calculation and render integration. The tester confirmed alignment in 1.4.0a4 and confirmed the updated reticle motion in the 1.4.0a5 build used for 1.4.0.
The render-rate preview supplies an optional presentation pose for the local on-foot player: current-frame aim and the unshaken interpolated observer camera. It keeps the game's muzzle projection and trigger offsets, rather than adding physical headset/hand translations to the shot origin. The unit, weapon and aim-assist helpers share their ordinary math; real firing uses no presentation override. Limited turrets retain the authoritative aiming path; 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 sun-glow patch retains the original mask/depth/blur routine and scales only its final VR composite by `vr.sun_glow_strength` (default 0.5, bounded 0–1). Non-finite settings fall back to 0.5; flat rendering uses 1.0. The production C sun tests verify asymmetric stereo projection, per-eye occlusion and source sampling, state restoration and intensity bounds.
### Online co-op menu and connection
The [pinned internet-play guide](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/README.md#internet-play) documents invite links, public server browsing, broker discovery, UDP hole punching and UPnP. It explicitly provides **no gameplay relay**: some pairs of strict NATs cannot connect without a usable forwarded port. The broker list and [configuration declarations](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/port_config.c#L320-L383) enable internet play and the browser by default, while `network.coop_public` defaults to false. A custom broker file and network settings remain user choices.
[Upstream menu code](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/menu_functions.c) implements **Create Game > Internet**, a **SINGLEPLAYER** map chooser, campaign difficulty, **Server Setup > LISTING**, **Join Game > Server Browser**, and **Direct Link > PASTE LINK**. [Cooperative server setup](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/interface/ui_widget_event_handler_functions.c#L5950-L5976) marks a selected campaign map as a network co-op game. The direct-link paste operation reads the clipboard on the machine running Halo, so the Frame's clipboard is needed, not its installer PC's.
The [local integration patch](../resources/frame-controls.patch) replaces **CO-OP CAMPAIGN** with explicit LAN/Internet host and join actions in VR and flat mode, selecting campaign maps with one local player. Its scoped map intent is cleared during synchronous widget loading; failed loading disposes the new host/client. The campaign XML and its generator are changed together so regeneration cannot restore the split-screen path. Generic profile widgets and competitive routes are retained. `tools/test_online_coop_menu.py` executes the production host/map/action helpers and XML event contracts, including failures, cancellation and single-local-player campaign behavior. These tests do not open a network connection or prove gameplay with a distant friend. [Practical hosting and joining steps](SETUP.md#online-campaign-co-op-with-a-friend).
## Game data and compatibility
[Upstream game-data instructions](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/README.md#game-data) require an **original Xbox Halo: Combat Evolved** disc image (`.iso` or `.xiso`) or its extracted maps. This installer uses the exact released-map build list in the [pinned cache compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c#L616-L631):
[Upstream game-data instructions](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/README.md#game-data) require an **original Xbox Halo: Combat Evolved** disc image (`.iso` or `.xiso`) or its extracted maps. This installer uses the exact released-map build list in the [pinned cache compatibility table](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c#L616-L631):
| Accepted Xbox version-5 map build | Upstream region |
| --- | --- |
@@ -28,13 +63,13 @@ The installer applies [its documented controller and tutorial patch](../resource
| `01.08.15.1749` | NTSC |
| `01.01.14.2342` | PAL |
**USA Rev 2 is validated with this installer.** Other original Xbox retail revisions and regions, including an image labeled USA Rev 1 or the original USA release, are accepted only when all 24 required maps have one of these builds. [Upstream PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/game/pal_tags.c#L4-L22) identify shared NTSC maps across three releases and explain the PAL timing conversion. Those source files do not provide a definitive filename-to-Rev-label mapping; setup validates actual headers instead of trusting a filename. Incomplete or mixed-build sets and other build strings are rejected. This is a supported-data rule, not a claim that every disc pressing has been tested on a Frame.
**USA Rev 2 was validated with the stable installer.** 1.4.0 retains its map-header checks. Other original Xbox retail revisions and regions, including an image labeled USA Rev 1 or the original USA release, are accepted only when all 24 required maps have one of these builds. [Upstream PAL conversion notes](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/game/pal_tags.c#L4-L22) identify shared NTSC maps across three releases and explain the PAL timing conversion. Those source files do not provide a definitive filename-to-Rev-label mapping; setup validates actual headers instead of trusting a filename. Incomplete or mixed-build sets and other build strings are rejected. This is a supported-data rule, not a claim that every disc pressing has been tested on a Frame.
Select a local disc image or maps that you are authorized to use. The installer checks original Xbox map headers and extracts the required data locally. The repository and downloadable installer contain no game disc image or commercial map data and provide no unauthorized game-download links.
Halo PC, Custom Edition, Xbox 360 Anniversary and MCC data are different formats. This is the native **Steam Frame** Linux VR port; the Android port in the upstream repository is not a promise of a Quest VR installation. PC Chimera, PC Restored map replacements and HaloCEVR DLLs are not loaded by this ARM64 build. Its native renderer and VR implementation supply the relevant graphics and headset support. PC Halo saves and profiles also differ from the native port's Xbox formats.
[Upstream multiplayer](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/README.md#multiplayer) supports LAN and internet play among compatible builds of this port. The [netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/NETCODE.md) describes its replacement networking. Use matching native/OpenCE protocol versions; compatibility with legacy Halo PC, Custom Edition, MCC or retail Xbox servers is not established by this project.
[Upstream multiplayer](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/README.md#multiplayer) supports LAN and internet play among compatible builds of this port. The [netcode design](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/NETCODE.md) describes its replacement networking. Use matching native/OpenCE protocol versions; compatibility with legacy Halo PC, Custom Edition, MCC or retail Xbox servers is not established by this project.
## Steam Frame setup and connection
@@ -95,16 +130,34 @@ These links credit visual and historical references. Dashboard styling is an ind
## Steam library artwork
The Windows installer uses the classic Halo PC helmet-and-shoulders icon in
1.3.2. [HaloNet's favicon ICO](https://halonet.net/favicon.ico) is byte-identical
to icon group 102 of the locally installed retail Halo PC executable. Only the
icon is bundled; its [resource notice](../resources/ui/README.md) records the
verification and exact hashes. The README displays the banner, title logo and
credited community gameplay example below, with the installer icon separate
from Steam's retained project drawing.
The bundled portrait image is the original **Halo: Combat Evolved** cover from [Halopedia's cover-art file page](https://www.halopedia.org/File:HCE_Cover_Art.jpg), with the [original JPEG](https://www.halopedia.org/images/8/8e/HCE_Cover_Art.jpg). Halopedia records the [official Halo Facebook image](https://www.facebook.com/Halo/photos/a.137195553028391/1561119617302637/) as its source. The landscape library image arranges the same cover in a wider layout without stretching it.
The cover remains copyrighted Microsoft/Halo artwork; the installer's MIT license does not apply to it. Source attribution and exact hashes are retained in the [artwork resource notice](../resources/artwork/README.md) and described in [THIRD_PARTY.md](THIRD_PARTY.md#dashboard-and-library-artwork). A thumbnail appears inside the installer. The library images are applied only to its native Halo shortcut, preserving user-selected custom art. The user confirmed the entry and artwork looked correct on the Frame tested with 1.2.3.
The cover remains copyrighted Microsoft/Halo artwork; the installer's MIT license does not apply to it. Source attribution and exact hashes are retained in the [artwork resource notice](../resources/artwork/README.md) and described in [THIRD_PARTY.md](THIRD_PARTY.md#dashboard-and-library-artwork). A thumbnail appears inside the installer. The library images are applied only to its native Halo shortcut, preserving user-selected custom art. The user confirmed the entry and covers looked correct on the Frame tested with 1.2.3.
Version 1.3.1 adds a 1,920 × 620 banner cropped from the original CE Scorpion/Chief promotional image. [Halopedia's source record](https://www.halopedia.org/File:HCE_MC_Scorpion.jpg) documents the official **Xbox Imagery, 11 Jan 2002** press CD-ROM and original `HI_06_HO.TIF` filename. The transparent title image comes from [Halopedia's Halo CE logo record](https://www.halopedia.org/File:Halo_-_Combat_Evolved_Logo_Huge.png); this record does not identify a publisher URL or grant a new license. The shortcut icon reuses the project's original helmet drawing. [Valve's library-asset specifications](https://partner.steamgames.com/doc/store/assets/libraryassets?language=english) describe the hero/banner and transparent logo roles and dimensions. Their specification is a visual-format reference, not documentation of the installer's private client adapter.
The new adapter's artwork type mapping, `SetCustomArtworkForApp`, `SetShortcutIcon`, and Game Notes schema were checked against the **installed Steam client's own JavaScript** on 5 October 2026. The `steamui/library.js` GameNotesCloudStore and its associated chunks use `notes_shortcut_<sanitized name>` documents containing a `shortcut_name` and an array of BBCode notes. These are observed, undocumented interfaces. Live icon changes also require read-only owned shortcut-file proof and a confirmed empty resident icon; uncertain or custom icons are kept. No live Steam file writes or session restarts are added.
### Description and controls in Steam Notes
[Valve's June 2023 Steam update](https://store.steampowered.com/news/app/593110/view/3687931965598906184), also available in its [official announcement feed](https://store.steampowered.com/news/posts/?enddate=1687195871&feed=steam_blog), introduces rich-text Notes accessible from a game's details page and overlay, with Steam synchronization. This patch uses Notes for an original Halo summary, Xbox controls, compatibility guidance, and source links. It does not replace Steam's non-Steam About text or create official store metadata.
The helper matches the current account and exact native executable before writing, synchronizes before reading, preserves the full existing document, guards against intervening changes, saves one identified note, and verifies the result. User-edited installer notes remain untouched. Unsupported schemas, account/name ambiguity, or synchronization failures produce a manual, copyable description. Notes use Steam's normal private-note synchronization; setup sends no description to another game's app ID or outside service. The banner, logo, icon, and Notes integration have automated tests.
## README gameplay example
The [README image](images/halocevr-steam-frame-example.jpeg) is the unchanged screenshot posted by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/) in **Halo (HaloCEVR). Working great on Steam Frame standalone. (Proton ARM)**. It illustrates the PC HaloCEVR mod running on Steam Frame through Proton ARM, not this project's native 1.4.0 build. The original image, exact hash and rights notice are recorded in [the image source notice](images/README.md).
## Local Halo menu music
The preview reads only the `sound\music\title1\loops` sound tag from the user's selected original Xbox `ui.map`, either inside an ISO/XISO or in extracted maps. Cache and sound structures are based on the pinned native port's [cache_files.c](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/cache/cache_files.c) and [sound_definitions.h](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/source/sound/sound_definitions.h). Xbox ADPCM decoding follows its [dsound_sdl.c implementation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/linux/src/dsound_sdl.c).
The installer reads only the `sound\music\title1\loops` sound tag from the user's selected original Xbox `ui.map`, either inside an ISO/XISO or in extracted maps. Cache and sound structures are based on the pinned native port's [cache_files.c](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/cache/cache_files.c) and [sound_definitions.h](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/source/sound/sound_definitions.h). Xbox ADPCM decoding follows its [dsound_sdl.c implementation](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/linux/src/dsound_sdl.c).
The installer decodes the loop into a private temporary Windows WAV with a fixed 12% sample-amplitude gain. Playback loops automatically after local game data validates. Closing the installer stops playback and clears its temporary audio. No soundtrack is present in the repository or executable, fetched from a website, or uploaded over SSH. Audio-file extensions are rejected by the release packager. Remote-only repair has no local audio preview, and an unavailable preview does not block installation. The original music remains copyrighted game content; the installer's code license does not apply to it. Playback still needs a listening check.
## General preservation resources
User-suggested background links: [Vimm's Lair](https://vimm.net/) and [Internet Archive's about page](https://archive.org/about/). These are general references, not Halo file-download links, software dependencies, or assurances of game-data permissions. The installer selects a local original Xbox image/maps supplied by the user; it does not fetch commercial game files. The PC edition is not an input to the native Frame build.
+24 -12
View File
@@ -1,10 +1,12 @@
# Third-party software notices
# VR mod and third-party notices
The installer code has its own [MIT license](../LICENSE). Each dependency keeps its own license. Original notice text is included under [resources/licenses](../resources/licenses); [manifest.json](../resources/licenses/manifest.json) records package versions, notice hashes and source URLs from the environment used to build the Windows executable.
This community VR mod installer's original code has its own [MIT license](../LICENSE). Each dependency keeps its own license. Original notice text is included under [resources/licenses](../resources/licenses); [manifest.json](../resources/licenses/manifest.json) records package versions, notice hashes and source URLs from the environment used to build the Windows executable.
The repository and executable include no retail game executable, disc image, maps, product keys, or soundtrack, and the installer does not download these files. Users supply authorized original Xbox game data. Copyrighted Halo artwork and trademarks remain separate from the installer code's license, as detailed below. Upstream source licenses are reported as published by their maintainers; this project does not independently certify the source's legal status. This is an unaffiliated community project.
The manifest includes build and test dependencies as well as runtime dependencies, so an entry does not imply that every file of that package is embedded in the executable. It records no machine paths, passwords, private SSH keys or game data.
## Windows installer
## Windows mod installer
| Component | Initial build version | License and use |
| --- | --- | --- |
@@ -37,7 +39,7 @@ ADB binaries are ignored by Git and regenerated by [bundle_usb_tools.py](../scri
Paramiko remains unmodified by this installer. Its complete official 4.0.0 source distribution is supplied alongside its LGPL notice. The included source archive is verified against the SHA-256 published in [PyPI's version metadata](https://pypi.org/pypi/paramiko/4.0.0/json); its digest and source URL are recorded in the manifest.
You may modify Paramiko and rebuild the installer with that modified library. This project imposes no restriction on reverse engineering for debugging such library modifications. Obtain the installer source for the matching release and follow the [source build instructions](../README.md). After installing the build dependencies, replace Paramiko in that environment with your modified source before the executable-freezing step, for example:
You may modify Paramiko and rebuild the installer with that modified library. This project imposes no restriction on reverse engineering for debugging such library modifications. Obtain the installer source for the matching release and follow the [source build instructions](SETUP.md#develop-and-rebuild-the-executable). After installing the build dependencies, replace Paramiko in that environment with your modified source before the executable-freezing step, for example:
```powershell
python -m pip install --no-deps --force-reinstall C:\path\to\modified-paramiko
@@ -47,18 +49,18 @@ Then rebuild using the provided packaging script. The installer source, resource
## Native game built on the Frame
The Windows download contains the installer and build instructions, not the commercial game's maps or disc image. The native program is built from pinned upstream source on the Frame. Its upstream license and component notices remain applicable to that build.
The Windows download contains the mod installer and build instructions; no retail game executable, disc image, maps, product keys, or soundtrack is included. The native program is built from pinned upstream source on the Frame. Its upstream license and component notices remain applicable to that build, separately from rights in retail game content.
| Upstream component | Notice/reference |
| --- | --- |
| [Halo native port / decompilation](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/LICENSE.md) | CC0-1.0 for the upstream source. This does not grant rights in the commercial game data or third parties' rights. |
| [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). |
| [SDL 3.4.16](https://github.com/libsdl-org/SDL/blob/release-3.4.16/LICENSE.txt) | zlib license; retain the original notice with the native library. |
| [musl 1.2.5](https://git.musl-libc.org/cgit/musl/tree/COPYRIGHT?h=v1.2.5) | MIT and notices identified in its COPYRIGHT file. Used for the upstream ILP32 guest. |
| [Khronos OpenXR SDK headers](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party/openxr/README.md) | OpenXR SDK 1.1.63, Apache-2.0 OR MIT. Header provenance is documented upstream. |
| [mbedTLS, expat, miniupnpc, KCP, tomlc17 and stb](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party) | Their individual upstream license files apply. Preserve the actual files when producing the native installation. |
| [extract-xiso](https://github.com/startupfoundry/halo-ce-universal/blob/88142798513ebd99fc7c6224023e8b44c05d0106/port/third_party/extract-xiso/LICENSE.TXT) | Custom BSD-style notice with acknowledgement and attribution conditions. This product includes software developed by in <in@fishtank.com>. |
| [Upstream font resources](https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106/port/assets/fonts) | Retain Overpass's SIL Open Font License and Newtown's public-domain notice when using those upstream resources. |
| [Khronos OpenXR SDK headers](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party/openxr/README.md) | OpenXR SDK 1.1.63, Apache-2.0 OR MIT. Header provenance is documented upstream. |
| [mbedTLS, expat, miniupnpc, KCP, tomlc17 and stb](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party) | Their individual upstream license files apply. Preserve the actual files when producing the native installation. |
| [extract-xiso](https://github.com/startupfoundry/halo-ce-universal/blob/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/third_party/extract-xiso/LICENSE.TXT) | Custom BSD-style notice with acknowledgement and attribution conditions. This product includes software developed by in <in@fishtank.com>. |
| [Upstream font resources](https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4/port/assets/fonts) | Retain Overpass's SIL Open Font License and Newtown's public-domain notice when using those upstream resources. |
The rootless build environment installs [Ubuntu packages](https://ubuntu.com/legal/open-source) and [LLVM](https://llvm.org/docs/DeveloperPolicy.html#license) under their own licenses inside its container. The installer does not relicense those tools or SteamOS/SteamVR. Game artwork, names and data remain the property of their respective rights holders.
@@ -66,11 +68,21 @@ The rootless build environment installs [Ubuntu packages](https://ubuntu.com/leg
The dashboard's circular core, panels, and green menus are independently drawn project resources covered by the project's MIT license. The original Xbox dashboard screenshots and Microsoft's launch-campaign announcement are credited as visual/historical references in [SOURCES.md](SOURCES.md#dashboard-visual-references); the interface is not an official Xbox or Microsoft application.
The installer icon is an original project drawing of Master Chief's helmet, with olive armor and a gold visor, created in code and supplied as SVG, PNG, and multi-size ICO resources. The project's MIT license covers its drawing code. Microsoft retains the underlying rights in Halo, Master Chief, the character design, and associated trademarks; those rights are separate from the installer code's license.
The Windows installer icon in 1.3.2 is the classic Halo PC helmet-and-shoulders application icon. The [unchanged HaloNet ICO](https://halonet.net/favicon.ico) was verified against icon group 102 of the locally installed retail `halo.exe`; only the icon was copied. Its original 12 image entries are embedded in the installer, and its 48-pixel RGBA frame supplies the Tk icon. The [icon resource notice](../resources/ui/README.md) records exact hashes and provenance. This copyrighted Halo artwork is separate from the project's MIT license.
The earlier original project drawing of Master Chief's helmet remains the Steam shortcut icon. Its SVG source and PNG rendition are in `resources/artwork`. The project's MIT license covers its drawing code. Microsoft retains the underlying rights in Halo, Master Chief, the character design, and associated trademarks; those rights are separate from the installer code's license.
The bundled Halo CE portrait cover and the landscape library layout retain copyrighted Microsoft/Halo cover artwork. The [Halopedia file page](https://www.halopedia.org/File:HCE_Cover_Art.jpg) identifies it as Halo: Combat Evolved cover art and records an [official Halo Facebook image](https://www.facebook.com/Halo/photos/a.137195553028391/1561119617302637/) as the source. This artwork is separate from the installer code's MIT license and is not represented as public domain or freely licensed. Attribution does not transfer rights in the cover or the Halo/Xbox trademarks.
A cover thumbnail identifies Halo inside the installer, and the portrait and landscape images identify the native Halo shortcut in the user's Steam library. The wider image preserves the cover proportions. Existing library art is retained, including custom images with another supported file extension; missing images can be installed. Exact image hashes and source attribution are in the [artwork resource notice](../resources/artwork/README.md). The repository and executable still contain no disc image, commercial map data, product key, or commercial game binary.
A cover thumbnail identifies Halo inside the installer, and the portrait and landscape images identify the native Halo shortcut in the user's Steam library. The wider image preserves the cover proportions. Version 1.3.1 also includes a cropped original CE promotional banner from [the Xbox press-image record](https://www.halopedia.org/File:HCE_MC_Scorpion.jpg) and a resized transparent [Halo CE title logo](https://www.halopedia.org/File:Halo_-_Combat_Evolved_Logo_Huge.png). These retain Microsoft's artwork and trademark rights and are separate from the code's MIT license. The persistent shortcut icon is a PNG copy of the project's existing helmet drawing, with the same underlying character/trademark distinction above.
Existing library art and icons are retained, including custom images with another supported file extension; missing assets can be installed. Exact image hashes, original-image hashes, and source attribution are in the [artwork resource notice](../resources/artwork/README.md). The optional artwork preparation utility uses Pillow only during development, not in the frozen installer runtime. The repository and executable still contain no disc image, commercial map data, product key, or commercial game binary.
The Steam Notes description is original summary and control-guide text, not copied store text. Notes remain a private Steam feature governed by Steam's own behavior; no Steam client code is redistributed by the helper. Observed client interfaces are credited in [SOURCES.md](SOURCES.md#description-and-controls-in-steam-notes).
## README gameplay image
`docs/images/halocevr-steam-frame-example.jpeg` is an unchanged community screenshot by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/), showing the PC HaloCEVR mod on Steam Frame through Proton ARM. It is labeled as a community example and is not a native 1.4.0 capture. The screenshot and underlying Halo content retain their respective rights; the installer's MIT code license does not apply to this image. See [the image source notice](images/README.md) for its original URL and SHA256.
## User-supplied menu audio
+10
View File
@@ -0,0 +1,10 @@
# README gameplay image source
`halocevr-steam-frame-example.jpeg` is an unchanged 1,920 × 1,080 JPEG screenshot by **u/Sea-Communication760**, retrieved on 2026-10-06 from their [HaloCEVR Steam Frame post](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/).
- Original image: [Reddit-hosted JPEG](https://i.redd.it/u721sjfvxnrh1.jpeg).
- SHA256: `612f7d0471940fd1fba4e7b0f6052384a69164e401729ce7e95a2c0d62c562f0`.
- Depicted software: the PC **HaloCEVR** mod running on Steam Frame through **Proton ARM**, as identified by the original post. This is not a capture of this project's native 1.4.0 build.
- Rights: the screenshot and underlying Halo content retain their respective rights. The project's MIT code license does not apply to this image; no open image license is claimed.
The local copy keeps the README image available without depending on the external image host. No pixels were cropped, resized or retouched; the README only sets its displayed width.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "halo-steam-frame-installer"
version = "1.3.0"
description = "Guided USB and network SSH installer for the native ARM64 Halo CE Steam Frame VR port"
version = "1.4.0"
description = "Community Halo CE VR mod installer for Steam Frame; original Xbox game data must be supplied separately"
requires-python = ">=3.11"
license = "MIT"
dependencies = ["paramiko==4.0.0"]
+63 -7
View File
@@ -29,9 +29,10 @@ SHA-256:
f9a665a7f0379c7c32943aed10461ef8e16bc809ad59d16df69cab249bba0375 halo-ce-thumbnail.png
```
The installer bundles all three files in its executable. It displays the
thumbnail locally, uploads the cover and wide tile over SSH, and fills missing
artwork in the active Steam account's `config/grid/` directory.
The installer displays the thumbnail locally and uploads the cover and wide
tile over SSH to fill missing art for the active Steam account's native Halo
shortcut. When Steam is running, its own artwork API writes and verifies the
images; direct `config/grid/` updates require Steam to be already closed.
Portrait artwork uses `<unsigned-shortcut-appid>p.jpg`; the wide tile uses
`<unsigned-shortcut-appid>.png`. Existing custom artwork is preserved, including
an image with a different supported file extension. The same operation runs
@@ -39,7 +40,62 @@ for an existing native shortcut and for **Add to Steam again**. New files are
published atomically without replacing an existing file; if publication fails,
only unchanged new files created by that operation are rolled back.
The filename convention was checked against the open-source
[steam-shortcut-artwork implementation](https://github.com/pmacoutinho/steam-shortcut-artwork/blob/main/steam-artwork.py).
Artwork registration is covered by local filesystem tests. It has not yet been
verified on a physical Steam Frame.
The filename convention was originally compared with the open-source
[steam-shortcut-artwork implementation](https://github.com/pmacoutinho/steam-shortcut-artwork/blob/main/steam-artwork.py),
then the client API and written file hashes were verified on a Frame for 1.2.3.
The 1.3.1 hero, logo, and icon additions have software tests; physical Frame
verification is pending. [Valve's library-asset specifications](https://partner.steamgames.com/doc/store/assets/libraryassets?language=english)
describe the separate banner and transparent logo roles.
## Steam detail-page artwork added in 1.3.1
`halo-ce-hero.jpg` is a 1,920 × 620 JPEG rendition of the original Halo: Combat
Evolved Scorpion/Chief promotional screenshot. The source is
[Halopedia's original press-image record](https://www.halopedia.org/File:HCE_MC_Scorpion.jpg),
which identifies the image as ripped directly from the official **Xbox Imagery,
11 Jan 2002** press CD-ROM, original filename `HI_06_HO.TIF`.
Downloaded original: https://www.halopedia.org/images/6/6c/HCE_MC_Scorpion.jpg.
The installer uses a literal crop `(0, 480, 1920, 1100)` of the 1,920 × 1,440
original and encodes it as a JPEG; it does not stretch or invent image content.
Chief appears on the right, leaving the left side for Steam's separate title
logo overlay. The original is not duplicated in the installer.
`halo-ce-logo.png` is a 1,000 × 431 transparent PNG rendition of the original
Halo: Combat Evolved title logo, resized with its original aspect ratio and alpha
channel. It is obtained from
[Halopedia's title-logo record](https://www.halopedia.org/File:Halo_-_Combat_Evolved_Logo_Huge.png).
Downloaded original: https://www.halopedia.org/images/9/91/Halo_-_Combat_Evolved_Logo_Huge.png.
This record identifies the copyrighted/trademarked CE logo but does not document
an upstream publisher URL. Do not describe this rendition as a newly licensed
asset or the project's own original drawing.
`halo-ce-icon.png` is the exact 256 × 256 PNG rendition of the original project
Master Chief helmet drawing (`resources/artwork/halo-ce-icon.svg`). It remains
the Steam shortcut icon. Version 1.3.2 uses a separate classic Halo PC icon for
the Windows installer, documented in [its notice](../ui/README.md).
Halo promotional artwork and the Halo/Xbox names and logos belong to Microsoft.
These images identify the user's Halo game in their library. They are separate
from the installer's MIT-licensed source code. Halopedia's descriptions do not
grant a general artwork redistribution license. No game maps, ISO, executable,
or music recording is contained in these assets.
SHA-256 of bundled new files:
```text
3eb9ba1515bd7e25b11a31911edb8610ee33fe9df0505f1a805214e9a9dcff31 halo-ce-hero.jpg
7df3336e9988291552270764e80196b1892ab9d11b79d7d289e6243746d42dc8 halo-ce-logo.png
184620356407e42a33877528c4f14b702df8b026415a99ed16976565fcfbce36 halo-ce-icon.png
```
SHA-256 of separately downloaded originals (kept outside the repository):
```text
e5604c94c66d7a9740b247af4f10ccae674d4c53b44f01a0cec4433b2ca75b21 HCE_MC_Scorpion.jpg
07afdc25f5fe0e11e966ff5e5af2f4adc0196c4ac89b0aeba7df99cc065ab76f Halo_-_Combat_Evolved_Logo_Huge.png
```
New bundled assets total 944,913 bytes. The optional developer utility
`scripts/generate_library_artwork.py` prepares these renditions with Pillow;
Pillow is not required by the installer at runtime. Source originals and the
three final renditions were visually inspected on 5 October 2026. The hero is
original CE-era art, not Anniversary/MCC or another Halo game's art.
Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

+5
View File
@@ -28,6 +28,11 @@ for tool in clang ld.lld llvm-ar; do
done
clang --version
cd /build/src
phase configure 'Checking Xbox controls, render-rate reticle, stereo sun glow and LAN/online campaign menus...'
python3 tools/test_vr_locomotion.py --cc clang
python3 tools/test_vr_sun_glow.py --cc clang
python3 tools/test_vr_projectile_reticle.py --cc clang
python3 tools/test_online_coop_menu.py --cc clang
phase configure 'Configuring the native ARM64 OpenXR game and downloading its build sources...'
python3 configure.py --release --vr --linux-arm64-cc clang
export CMAKE_BUILD_PARALLEL_LEVEL=4
File diff suppressed because it is too large. Load diff
+4 -26
View File
@@ -106,15 +106,15 @@
},
{
"name": "halo-steam-frame-installer",
"version": "1.3.0",
"version": "1.4.0",
"license_expression": "MIT",
"declared_license": null,
"project_urls": [],
"notices": [
{
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.0.dist-info/licenses/LICENSE",
"sha256": "b19db6d1e8942380f6a0dc3b9ec39cec802606c9d1c08a6050eae2990568afbb",
"bytes": 1318
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.4.0.dist-info/licenses/LICENSE",
"sha256": "04a51df2edd89917e3099e2f6f171c36891f1d6ac234015867b57a1d10f928d5",
"bytes": 1343
}
]
},
@@ -215,28 +215,6 @@
}
]
},
{
"name": "pillow",
"version": "12.3.0",
"license_expression": "MIT-CMU",
"declared_license": null,
"project_urls": [
"Release notes, https://pillow.readthedocs.io/en/stable/releasenotes/index.html",
"Changelog, https://github.com/python-pillow/Pillow/releases",
"Documentation, https://pillow.readthedocs.io",
"Funding, https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi",
"Homepage, https://python-pillow.github.io",
"Mastodon, https://fosstodon.org/@pillow",
"Source, https://github.com/python-pillow/Pillow"
],
"notices": [
{
"path": "packages/pillow/pillow-12.3.0.dist-info/licenses/LICENSE",
"sha256": "4f7866a74802c6326f81faff59a56546b6aec2b10b91973e0e9308de95e79857",
"bytes": 78016
}
]
},
{
"name": "pip",
"version": "25.0.1",
@@ -1,25 +0,0 @@
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
@@ -1,25 +0,0 @@
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
@@ -1,25 +1,25 @@
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
+455 -54
View File
@@ -23,7 +23,8 @@ import tomllib
OWNER = "halo-frame-installer"
SOURCE_URL = "https://github.com/startupfoundry/halo-ce-universal.git"
SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
LEGACY_SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
MIN_FREE_BYTES = 12 * 1024 ** 3
EXPECTED_MAPS = frozenset("maps/" + name + ".map" for name in (
"a10", "a30", "a50", "b30", "b40", "c10", "c20", "c40", "d20", "d40",
@@ -33,6 +34,8 @@ SUPPORTED_BUILDS = frozenset(("01.01.14.2342", "01.10.12.2276", "01.08.15.1749")
MAX_MAP_BYTES = 0x11600000
MAX_TOTAL_BYTES = 4 * 1024 ** 3
MAX_MANIFEST_BYTES = 64 * 1024
MAX_WARNING_CHARS = 4000
MAX_UNINSTALL_RECOVERIES = 64
MAX_RETRY_RUNS = 32
MAX_RETRY_ENTRIES = 256
HOME = pathlib.Path("/home/steamos")
@@ -177,6 +180,62 @@ def installed_maps_manifest():
return value
def save_root():
return GAME / "save-v1.4"
def configured_save_path(settings):
"""Inspect a legacy location without opening or modifying its contents."""
paths = settings.get("paths", {})
if not isinstance(paths, dict):
raise ValueError("The game configuration has an unsupported paths section.")
location = paths.get("saves", str(GAME / "save"))
if not isinstance(location, str) or not location:
raise ValueError("The configured save location is invalid. Existing saves were kept.")
selected = pathlib.Path(location)
selected = pathlib.Path(os.path.normpath(selected if selected.is_absolute() else GAME / selected))
if selected == GAME:
raise ValueError("The save location points at the entire game folder. Existing saves were kept.")
if selected.is_relative_to(GAME):
beneath(selected, GAME)
return str(selected)
def previous_save_paths(metadata):
paths = metadata.get("previousSavePaths", [])
if not isinstance(paths, list) or len(paths) > 32:
raise ValueError("The native marker has an invalid previous save-location record.")
checked = []
for location in paths:
selected = configured_save_path({"paths": {"saves": location}})
if selected not in checked:
checked.append(selected)
return checked
def validate_save_paths(settings, require_explicit=False):
"""New engine checkpoints must never share the incompatible legacy directory."""
paths = settings.get("paths", {})
if not isinstance(paths, dict):
raise ValueError("Version 1.4 game and save paths must use their owned installation folder.")
for key, expected in (("data", GAME), ("saves", save_root())):
beneath(expected, HOME)
if key not in paths and not require_explicit:
continue
value = paths.get(key)
if not isinstance(value, str) or not value:
raise ValueError("Repair the game to set its version 1.4 game and save paths before reuse.")
candidate = pathlib.Path(value)
if not candidate.is_absolute():
candidate = GAME / candidate
try:
beneath(candidate, GAME)
except ValueError:
raise ValueError("Version 1.4 game and save paths must stay in their owned installation folder. Existing saves were kept.") from None
if candidate != expected:
raise ValueError("Version 1.4 must use its own game folder and save-v1.4 subfolder. Existing saves were kept.")
def existing_install(repair=False, adopt=False):
beneath(GAME, HOME)
if not GAME.exists():
@@ -199,17 +258,39 @@ def existing_install(repair=False, adopt=False):
installed_maps_manifest()
value = {"owner": "manual-native-adoption", "files": {}}
source = None
if source != SOURCE_COMMIT and not repair:
needs_upgrade = source == LEGACY_SOURCE_COMMIT and value["owner"] in (OWNER, "codex-halo-native-frame-20261005")
if source != SOURCE_COMMIT and not repair and not needs_upgrade:
raise ValueError("An existing native game uses a different source revision. It was not modified.")
if repair:
if repair or needs_upgrade:
game_closed()
else:
if not repair:
elf_arm64(GAME / "halo")
ordinary(GAME / "libSDL3.so.0")
if needs_upgrade:
elf_arm64(GAME / "libSDL3.so.0")
else:
ordinary(GAME / "libSDL3.so.0")
previous_paths = previous_save_paths(value)
migrating = source != SOURCE_COMMIT
beneath(save_root(), GAME)
if save_root().exists() and (not save_root().is_dir() or (migrating and any(save_root().iterdir()))):
raise ValueError("The version 1.4 save destination already contains files or is not a directory. Existing saves were kept.")
if (GAME / "config.toml").exists():
ordinary(GAME / "config.toml")
settings = tomllib.loads((GAME / "config.toml").read_text())
if migrating:
previous = configured_save_path(settings)
if previous not in previous_paths:
previous_paths.append(previous)
else:
validate_save_paths(settings, require_explicit=not repair)
if repair:
merge_config((GAME / "config.toml").read_text())
merge_config((GAME / "config.toml").read_text(), migrate=migrating)
elif not repair and not needs_upgrade:
raise ValueError("Repair the game to create its version 1.4 save configuration before reuse.")
elif migrating and str(GAME / "save") not in previous_paths:
previous_paths.append(str(GAME / "save"))
if len(previous_paths) > 32:
raise ValueError("The native marker exceeds the supported previous save-location count.")
# Validate all hashes recorded by either supported installer before reusing it.
files = value.get("files", {})
if not isinstance(files, dict):
@@ -234,7 +315,8 @@ def existing_install(repair=False, adopt=False):
raise
maps_verified = False
return {"gamePath": str(GAME), "reused": True, "owner": value["owner"], "sourceCommit": source,
"mapsVerified": maps_verified, "needsImage": not maps_verified, "programIssues": issues}
"mapsVerified": maps_verified, "needsImage": not maps_verified, "programIssues": issues,
"needsUpgrade": needs_upgrade, "previousSavePaths": previous_paths}
def os_release():
@@ -278,14 +360,15 @@ def preflight(repair=False, adopt=False):
beneath(CACHE / "resources", CACHE).mkdir(exist_ok=True)
beneath(CACHE / "resources/artwork", CACHE).mkdir(exist_ok=True)
beneath(CACHE / "runs", CACHE).mkdir(exist_ok=True)
for name in ("remote_install.py", "build-native.sh", "steam_shortcut.py", "steam_live.py", "frame-controls.patch",
"artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png"):
for name in ("remote_install.py", "build-native.sh", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "frame-controls.patch",
"artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png", "artwork/halo-ce-hero.jpg",
"artwork/halo-ce-logo.png", "artwork/halo-ce-icon.png"):
candidate = beneath(CACHE / "resources" / name, CACHE)
if candidate.exists() and (not candidate.is_file() or candidate.stat().st_nlink != 1 or candidate.stat().st_uid != os.getuid()):
raise ValueError("An installer resource path is not a private ordinary file.")
existing = existing_install(repair, adopt)
free = shutil.disk_usage(HOME).free
if (existing is None or repair) and free < MIN_FREE_BYTES:
if (existing is None or repair or existing.get("needsUpgrade")) and free < MIN_FREE_BYTES:
raise ValueError("At least 12 GiB of free space is needed for the Xbox data and isolated build. Free space and retry.")
# Podman itself is unprivileged. Never use sudo or disable SteamOS read-only protection.
podman = json.loads(command(["podman", "info", "--format", "json"]))
@@ -312,7 +395,8 @@ def preflight_uninstall():
directory.mkdir(exist_ok=True)
if not directory.is_dir() or directory.stat().st_uid != os.getuid():
raise ValueError("An installer resource directory is not owned by this account.")
for relative in ("remote_install.py", "steam_shortcut.py", "steam_live.py", "artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png"):
for relative in ("remote_install.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png",
"artwork/halo-ce-hero.jpg", "artwork/halo-ce-logo.png", "artwork/halo-ce-icon.png"):
path = beneath(CACHE / "resources" / relative, CACHE)
if path.exists():
details = ordinary(path).stat()
@@ -321,6 +405,19 @@ def preflight_uninstall():
return {"home": str(HOME), "cachePath": str(CACHE), "gamePath": str(GAME), "uninstallSupported": True}
def preflight_library():
"""Library updates need the verified game, without compiler prerequisites."""
info = preflight_uninstall()
current = existing_install()
if current is None:
raise ValueError("Install and verify native Halo VR before updating its Steam information.")
if current.get("needsUpgrade"):
raise ValueError("Use Install to upgrade the existing native game to version 1.4 before updating its Steam information.")
info.pop("uninstallSupported", None)
info["existing"] = current
return info
def prepare(value, use_existing_maps=False, repair=False, adopt=False):
directory = run_dir(value)
owned(directory)
@@ -730,7 +827,10 @@ class BuildLogMonitor:
def build(value, repair=False, adopt=False):
directory = run_dir(value)
metadata = read_marker(directory / MARKER)
if existing_install(repair, adopt) and not repair:
existing = existing_install(repair, adopt)
if existing and not repair:
if existing.get("needsUpgrade"):
raise ValueError("The existing native game needs a version 1.4 upgrade. Start Install to rebuild it.")
return {"reused": True}
upload = directory / "upload"
def check_cancelled():
@@ -842,12 +942,18 @@ def cancel(value):
return {"cancelled": True}
def merge_config(original):
def merge_config(original, migrate=False):
"""Restore required native controls while preserving unrelated TOML settings."""
settings = tomllib.loads(original) if original.strip() else {}
required = {"vr": {"enabled": True, "aim": "controller", "melee_gesture": False},
"update": {"auto": False}}
defaults = {"refresh_rate": 72.0, "resolution_scale": 1.0, "turn": "smooth",
if migrate:
configured_save_path(settings)
else:
validate_save_paths(settings)
required = {"vr": {"enabled": True, "aim": "controller", "movement": "head", "melee_gesture": 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"}
defaults = {"refresh_rate": 72.0, "resolution_scale": 1.0, "sun_glow_strength": 0.5, "turn": "smooth",
"smooth_turn_speed": 90.0, "two_handed": True, "height": "seated", "depth": False}
vr = settings.get("vr", {})
for key, default in defaults.items():
@@ -951,6 +1057,8 @@ def finalize(value, repair=False, adopt=False):
check_cancelled()
existing = existing_install(repair, adopt)
if existing and not repair:
if existing.get("needsUpgrade"):
raise ValueError("The existing native game needs a version 1.4 upgrade. Start Install to rebuild it.")
return existing
source = directory / "src"
binary = source / "build/linux_arm64/halo"
@@ -962,6 +1070,16 @@ def finalize(value, repair=False, adopt=False):
shutil.copy2(binary, stage / "halo")
(stage / "halo").chmod(0o755)
shutil.copy2(ordinary(source / "build/linux_arm64/libSDL3.so.0"), stage / "libSDL3.so.0")
broker_source = ordinary(source / "build/linux_arm64/brokers.txt")
if not 0 < broker_source.stat().st_size <= 16 * 1024:
raise ValueError("The native internet-play broker list is missing or invalid.")
broker_text = broker_source.read_text(encoding="utf-8")
if not any(
line.strip() and not line.lstrip().startswith("#") for line in broker_text.splitlines()):
raise ValueError("The native internet-play broker list is missing or invalid.")
shutil.copy2(broker_source, stage / "brokers.txt")
if digest(stage / "brokers.txt") != digest(broker_source):
raise ValueError("The native internet-play broker list failed copy verification.")
upload = directory / "upload"
manifest = json.loads(ordinary(upload / "xbox-data-manifest.json").read_text())
reuse_maps = run_metadata.get("mapsOrigin") == "existing"
@@ -977,15 +1095,15 @@ def finalize(value, repair=False, adopt=False):
if not existing or not reuse_maps:
verify_maps(stage, manifest, check_cancelled)
if not existing:
(stage / "save").mkdir()
config = ('[paths]\ndata = "' + str(GAME) + '"\nsaves = "' + str(GAME / "save") + '"\n\n'
'[update]\nauto = false\n\n[network]\nonline = true\n\n'
'[vr]\nenabled = true\nrefresh_rate = 72.0\nresolution_scale = 1.0\n'
'aim = "controller"\nturn = "smooth"\nsmooth_turn_speed = 90.0\n'
(stage / save_root().name).mkdir()
config = ('[paths]\ndata = ' + json.dumps(str(GAME)) + '\nsaves = ' + json.dumps(str(save_root())) + '\n\n'
'[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 = "smooth"\nsmooth_turn_speed = 90.0\n'
'melee_gesture = false\ntwo_handed = true\nheight = "seated"\ndepth = false\n')
if existing:
original = ordinary(GAME / "config.toml").read_text() if (GAME / "config.toml").exists() else ""
config = merge_config(original)
config = merge_config(original, migrate=existing["sourceCommit"] != SOURCE_COMMIT)
(stage / "config.toml").write_text(config)
shutil.copy2(CACHE / "resources/frame-controls.patch", stage / "frame-controls.patch")
notices = {
@@ -1012,12 +1130,20 @@ def finalize(value, repair=False, adopt=False):
raise ValueError("A required native runtime library is missing:\n" + libs)
metadata = {"owner": OWNER, "version": 1, "sourceCommit": SOURCE_COMMIT,
"sourceUrl": SOURCE_URL, "architecture": "native ARM64 ELF64 / ILP32 AArch64 guest",
"files": {name: digest(stage / name) for name in ("halo", "libSDL3.so.0", "frame-controls.patch")},
"files": {name: digest(stage / name) for name in ("halo", "libSDL3.so.0", "brokers.txt", "frame-controls.patch")},
"maps": {"files": 24, "bytes": manifest["totalBytes"]},
"controls": "Xbox buttons, motion aim, LB grenade change, RB flashlight",
"controls": "Head-directed walking, Xbox buttons, motion aim, LB grenade change, RB flashlight",
"installerVersion": "1.4.0", "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")
if existing:
# This directory contains only new-format checkpoints. Program rollback
# restores the old config and leaves both generations of saves untouched.
beneath(save_root(), GAME).mkdir(exist_ok=True)
if existing.get("previousSavePaths"):
progress("upgrade", "Previous checkpoints remain in " + ", ".join(existing["previousSavePaths"])
+ ". Version 1.4 uses fresh campaign saves in " + str(save_root()) + ".")
names = [item.name for item in stage.iterdir() if item.is_file() and item.name != MARKER]
if not reuse_maps:
names += [item["path"] for item in manifest["files"]]
@@ -1025,7 +1151,8 @@ def finalize(value, repair=False, adopt=False):
names.append(MARKER)
backup = repair_program_files(stage, directory, names, check_cancelled)
return {"gamePath": str(GAME), "reused": False, "repaired": True,
"sourceCommit": SOURCE_COMMIT, "backupPath": backup, "mapsReinstalled": not reuse_maps}
"sourceCommit": SOURCE_COMMIT, "backupPath": backup, "mapsReinstalled": not reuse_maps,
"previousSavePaths": existing.get("previousSavePaths", [])}
beneath(GAME.parent, HOME).mkdir(exist_ok=True)
if GAME.exists() or GAME.is_symlink():
raise ValueError("The game destination appeared during installation. It was not modified.")
@@ -1054,9 +1181,15 @@ def shortcut(close_steam=False):
current = existing_install()
if current is None:
raise ValueError("Install and verify the native game before adding it to Steam.")
if current.get("needsUpgrade"):
raise ValueError("Use Install to upgrade the existing native game to version 1.4 before adding it to Steam.")
sys.path.insert(0, str(CACHE / "resources"))
from steam_shortcut import add_native_shortcut
return add_native_shortcut(HOME, GAME, close_steam=close_steam)
response = add_native_shortcut(HOME, GAME, close_steam=close_steam)
if response.get("status") == "added":
from steam_notes import add_notes
response["notes"] = add_notes(HOME, GAME, response["appid"])
return response
def no_active_build():
@@ -1094,6 +1227,40 @@ def uninstall_signature(details):
return details.st_dev, details.st_ino, details.st_mode, details.st_uid, details.st_size, details.st_mtime_ns
def uninstall_root_identity(directory):
"""The directory's identity survives its rename and partial file removal."""
details = beneath(directory, HOME).lstat()
if not stat.S_ISDIR(details.st_mode) or details.st_uid != os.getuid():
raise ValueError("The uninstall folder is not an ordinary owned directory.")
return {"device": details.st_dev, "inode": details.st_ino, "uid": details.st_uid}
def write_uninstall_record(directory, metadata, owner=OWNER):
"""Publish a complete bounded journal before renaming the game directory."""
if metadata.get("owner") != owner:
raise ValueError("The uninstall record has an invalid owner.")
uninstall_root_identity(directory)
target = beneath(directory / MARKER, directory)
if target.exists():
current = private_json(target, directory)
if current.get("owner") != owner:
raise ValueError("The uninstall operation record belongs to another application.")
encoded = (json.dumps(metadata) + "\n").encode("utf-8")
if len(encoded) > MAX_MANIFEST_BYTES:
raise ValueError("The uninstall operation record exceeds supported bounds.")
temporary = directory / (".uninstall-record-" + os.urandom(8).hex() + ".tmp")
try:
with temporary.open("xb") as stream:
temporary.chmod(0o600)
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
if temporary.exists():
temporary.unlink()
def uninstall_mounts():
if not sys.platform.startswith("linux"):
return []
@@ -1147,7 +1314,14 @@ def uninstall_inventory(directory):
def uninstall_save_files(inventory):
roots, external = [GAME / "save"], []
roots, external = [GAME / "save", save_root()], []
metadata = uninstall_identity()
for location in previous_save_paths(metadata or {}):
selected = pathlib.Path(location)
if selected.is_relative_to(GAME):
roots.append(beneath(selected, GAME))
else:
external.append(str(selected))
config = GAME / "config.toml"
if "config.toml" in inventory:
if config.stat().st_size > MAX_MANIFEST_BYTES:
@@ -1195,23 +1369,38 @@ def copy_uninstall_save(relative, target, signature):
if details.st_nlink != 1 or uninstall_signature(details) != signature:
raise ValueError("A save file changed while preparing its backup. Game files were kept.")
target.parent.mkdir(parents=True, exist_ok=True)
checksum = hashlib.sha256()
copied = 0
with target.open("xb") as output:
shutil.copyfileobj(stream, output, 1024 * 1024)
while True:
block = stream.read(min(1024 * 1024, details.st_size - copied + 1))
if not block:
break
copied += len(block)
if copied > details.st_size:
raise ValueError("A save file grew while preparing its backup. Game files were kept.")
output.write(block)
checksum.update(block)
output.flush()
os.fsync(output.fileno())
if target.stat().st_size != details.st_size or uninstall_signature(os.fstat(stream.fileno())) != signature:
raise ValueError("A save backup could not be verified. Game files were kept.")
target.chmod(details.st_mode & 0o777)
os.utime(target, ns=(details.st_atime_ns, details.st_mtime_ns))
expected = checksum.hexdigest()
if digest(target) != expected:
raise ValueError("A save backup failed content verification. Game files were kept.")
return {"size": details.st_size, "sha256": expected}
def remove_uninstall_tree(directory, inventory, completed, total):
"""Unlink only validated paths, counting actual removal work for the UI."""
files = [relative for relative, (kind, _) in inventory.items() if kind == "file"]
files.sort(key=lambda relative: (relative in (MARKER, ".codex-halo-native-install.json"), relative))
markers = [relative for relative in files if relative in (MARKER, ".codex-halo-native-install.json")]
files = sorted(relative for relative in files if relative not in markers)
directories = sorted((relative for relative, (kind, _) in inventory.items() if kind == "directory"),
key=lambda relative: len(pathlib.Path(relative).parts), reverse=True)
for relative in files + directories:
for relative in files + directories + markers:
path = beneath(directory / relative, directory)
details = path.lstat()
kind, signature = inventory[relative]
@@ -1231,83 +1420,294 @@ def remove_uninstall_tree(directory, inventory, completed, total):
completed[0] += 1
def same_uninstall_root(expected, directory):
if (not isinstance(expected, dict) or set(expected) != {"device", "inode", "uid"}
or any(type(value) is not int or value < 0 for value in expected.values())
or expected != uninstall_root_identity(directory)):
raise ValueError("The quarantine directory does not match its recorded uninstall identity.")
def uninstall_quarantine_record(path, identifier):
"""A name or copied owner marker is never enough to authorize deletion."""
run_id(identifier)
directory = beneath(CACHE / "runs" / identifier, CACHE)
uninstall_root_identity(directory)
metadata = private_json(directory / MARKER, directory)
if (metadata.get("owner") != OWNER or metadata.get("operation") != "uninstall"
or metadata.get("state") not in ("quarantining", "removing")
or metadata.get("quarantinePath") != str(path)):
raise ValueError("No matching private interrupted-uninstall record was found.")
root = metadata.get("quarantineRoot")
if root is not None:
same_uninstall_root(root, path)
recognized = False
for name, owner in ((MARKER, OWNER), (".codex-halo-native-install.json", "codex-halo-native-frame-20261005")):
marker = path / name
if marker.exists() or marker.is_symlink():
if private_json(marker, path).get("owner") != owner:
raise ValueError("A quarantine marker belongs to another application.")
recognized = True
# Older versions wrote the exact path and operation only after the rename.
# Their private journal plus the surviving native marker can corroborate
# ownership. A markerless legacy tree cannot be adopted automatically.
if root is None and not recognized:
raise ValueError("This older uninstall has no remaining identity proof; its folder was kept.")
return directory, metadata
def verified_uninstall_backup(identifier, metadata):
"""Check a recorded backup without following links or changing any saves."""
location = metadata.get("savedBackupPath")
if location is None:
return None
saved = GAME.parent / ("HaloCENativeVR-saves-" + run_id(identifier))
if not isinstance(location, str) or location != str(saved):
raise ValueError("The uninstall save backup path does not match its operation.")
if metadata.get("savedBackupRoot") is not None:
same_uninstall_root(metadata["savedBackupRoot"], saved)
inventory = uninstall_inventory(saved)
value = private_json(saved / MARKER, saved)
if value.get("owner") != OWNER + "-save-backup" or value.get("gamePath") != str(GAME):
raise ValueError("The recorded save backup has no valid native-game ownership record.")
actual = {relative for relative, (kind, _) in inventory.items() if kind == "file" and relative != MARKER}
if not actual:
raise ValueError("The recorded save backup contains no save or configuration files.")
files = value.get("files")
if files is not None:
if not isinstance(files, dict) or set(files) != actual:
raise ValueError("The recorded save backup files do not match its manifest.")
for relative, expected in files.items():
if (not isinstance(expected, dict) or type(expected.get("size")) is not int
or expected["size"] < 0 or not isinstance(expected.get("sha256"), str)
or not re.fullmatch(r"[a-f0-9]{64}", expected["sha256"])):
raise ValueError("The recorded save backup manifest is invalid.")
target = ordinary(beneath(saved / relative, saved))
descriptor = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0))
with os.fdopen(descriptor, "rb") as stream:
signature = inventory[relative][1]
details = os.fstat(stream.fileno())
if details.st_nlink != 1 or uninstall_signature(details) != signature:
raise ValueError("A save backup changed during verification.")
checksum = hashlib.sha256()
read = 0
while True:
block = stream.read(min(1024 * 1024, details.st_size - read + 1))
if not block:
break
read += len(block)
if read > details.st_size:
raise ValueError("A save backup grew during verification.")
checksum.update(block)
if uninstall_signature(os.fstat(stream.fileno())) != signature:
raise ValueError("A save backup changed during verification.")
if details.st_size != expected["size"] or checksum.hexdigest() != expected["sha256"]:
raise ValueError("A recorded save backup file failed content verification.")
elif metadata.get("savedBackupRoot") is not None:
raise ValueError("The recorded save backup is missing its file verification manifest.")
if uninstall_inventory(saved) != inventory:
raise ValueError("The recorded save backup changed during verification.")
return str(saved)
def recover_interrupted_uninstall():
"""Finish only journaled quarantines; report every bounded unresolved entry."""
recovery = {"notes": [], "removed": 0, "retained": [], "saved": [], "limited": False, "unlisted": 0}
prefix = ".HaloCENativeVR-uninstall-"
candidates = []
# Avoid sorting/materializing an unbounded glob of user-controlled entries.
beneath(GAME.parent, HOME)
if not GAME.parent.exists():
return recovery
with os.scandir(GAME.parent) as entries:
for entry in entries:
if not entry.name.startswith(prefix):
continue
if len(candidates) >= MAX_UNINSTALL_RECOVERIES:
recovery["limited"] = True
break
candidates.append(GAME.parent / entry.name)
for path in sorted(candidates):
identifier = path.name[len(prefix):]
saved = None
try:
directory, metadata = uninstall_quarantine_record(path, identifier)
inventory = uninstall_inventory(path)
root = uninstall_root_identity(path)
saved = verified_uninstall_backup(identifier, metadata)
if saved is not None:
recovery["saved"].append(saved)
game_closed((path / "halo",))
progress("uninstall", "Finishing an earlier interrupted uninstall...", None)
if uninstall_inventory(path) != inventory or uninstall_root_identity(path) != root:
raise ValueError("The interrupted-uninstall folder changed during verification.")
remove_uninstall_tree(path, inventory, [0], 0)
except (OSError, ValueError, RuntimeError) as error:
reportable = (len(path.name) <= 255 and "\\" not in path.name
and not any(ord(character) < 32 or ord(character) == 127 for character in path.name))
if reportable:
recovery["retained"].append(str(path))
displayed = str(path)
else:
recovery["unlisted"] += 1
displayed = str(path.parent) + "/" + ascii(path.name)
recovery["notes"].append("An unfinished uninstall folder could not be safely removed and was kept: "
+ displayed + " (" + str(error)[:500] + "). Review it manually; "
"the installer did not assume ownership of unverified files."
+ (" Verified campaign saves and configuration remain in " + saved + "." if saved else ""))
continue
recovery["removed"] += 1
note = "Finished removing native files left by an interrupted uninstall: " + str(path) + "."
if saved:
note += " Verified campaign saves and configuration from that uninstall remain in " + saved + "."
metadata["state"] = "complete"
try:
write_uninstall_record(directory, metadata)
except (OSError, ValueError):
note += " Its operation record could not be updated."
recovery["notes"].append(note)
if recovery["limited"]:
recovery["notes"].append("More unfinished uninstall folders remain than can be checked in one operation. "
"They were kept; run Uninstall again to continue, or review them manually.")
return recovery
def join_notes(notes):
warning = " ".join(notes)
if len(warning) > MAX_WARNING_CHARS:
suffix = " (additional notes omitted; some folders may still need manual cleanup)"
warning = warning[:MAX_WARNING_CHARS - len(suffix)].rstrip() + suffix
return warning
def uninstall_recovery_result(response, recovery, notes=()):
pending = bool(recovery["retained"] or recovery["limited"] or recovery["unlisted"])
response["removalPending"] = pending
response["retainedQuarantinePaths"] = recovery["retained"]
response["recoveredSaveBackupPaths"] = list(dict.fromkeys(recovery["saved"]))
if pending:
response["uninstalled"] = False
response["alreadyAbsent"] = False
warning = join_notes([*recovery["notes"], *notes])
if warning:
response["warning"] = warning
return response
def uninstall(value, close_steam=False, keep_saves=False):
preflight_uninstall()
directory = run_dir(value)
if directory.exists():
metadata = private_json(directory / MARKER, directory)
if metadata.get("operation") != "uninstall":
if metadata.get("owner") != OWNER or metadata.get("operation") != "uninstall":
raise ValueError("Use a fresh run identifier for uninstall.")
else:
metadata = owned(directory)
metadata["operation"] = "uninstall"
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
write_uninstall_record(directory, metadata)
identity = uninstall_identity()
game_closed()
no_active_build()
inventory = uninstall_inventory(GAME) if identity else {}
save_files, external = uninstall_save_files(inventory) if identity and keep_saves else ([], [])
quarantine = beneath(GAME.parent / (".HaloCENativeVR-uninstall-" + value), HOME)
quarantine = GAME.parent / (".HaloCENativeVR-uninstall-" + value)
saved = beneath(GAME.parent / ("HaloCENativeVR-saves-" + value), HOME)
staging = beneath(GAME.parent / (".HaloCENativeVR-saves-" + value + ".tmp"), HOME)
for path in (quarantine, staging):
if path.exists() or path.is_symlink():
raise ValueError("An unfinished uninstall folder already exists: " + str(path))
if staging.exists() or staging.is_symlink():
raise ValueError("An unfinished save backup already exists and was kept: " + str(staging)
+ ". Use a fresh uninstall run.")
if identity and ((quarantine.exists() or quarantine.is_symlink())
or metadata.get("state") == "complete"):
raise ValueError("A previous uninstall used this run identifier. Use a fresh uninstall run for the installed game.")
if save_files and (saved.exists() or saved.is_symlink()):
raise ValueError("The selected save backup already exists. Use a fresh uninstall run.")
if save_files:
needed = sum(inventory[relative][1][4] for relative in save_files)
if shutil.disk_usage(GAME.parent).free < needed + 1024 * 1024:
raise ValueError("There is not enough space to keep the selected save files. Game files were kept.")
# Completing an earlier removal is still work this uninstall performs, but
# only after every refusal above has cleared.
recovered = recover_interrupted_uninstall()
total = len(save_files) + len(inventory) + (1 if identity else 0)
completed = [0]
staged, saved_path, renamed = False, None, False
progress("uninstall", "Preparing to remove the managed native game and its Steam shortcut...", 0)
try:
if save_files:
needed = sum(inventory[relative][1][4] for relative in save_files)
if shutil.disk_usage(GAME.parent).free < needed + 1024 * 1024:
raise ValueError("There is not enough space to keep the selected save files. Game files were kept.")
staging.mkdir(mode=0o700, exist_ok=False)
staged = True
(staging / MARKER).write_text(json.dumps({"owner": OWNER + "-save-backup", "gamePath": str(GAME),
"externalSavePathsPreserved": external}) + "\n")
backup_metadata = {"owner": OWNER + "-save-backup", "gamePath": str(GAME),
"externalSavePathsPreserved": external, "files": {}}
write_uninstall_record(staging, backup_metadata, OWNER + "-save-backup")
for relative in save_files:
copy_uninstall_save(relative, beneath(staging / relative, staging), inventory[relative][1])
backup_metadata["files"][relative] = copy_uninstall_save(
relative, beneath(staging / relative, staging), inventory[relative][1])
completed[0] += 1
progress("uninstall", f"Keeping campaign saves and configuration: {completed[0]:,} files copied.",
completed[0] * 100 // total)
write_uninstall_record(staging, backup_metadata, OWNER + "-save-backup")
sys.path.insert(0, str(CACHE / "resources"))
from steam_shortcut import remove_native_shortcut
steam = remove_native_shortcut(HOME, GAME, close_steam=close_steam)
if steam.get("status") not in ("removed", "already-absent"):
return {"gamePath": str(GAME), "uninstalled": False, "savedBackupPath": None, "steam": steam}
existing_saved = None
notes = []
if not identity and metadata.get("savedBackupPath") is not None:
try:
existing_saved = verified_uninstall_backup(value, metadata)
except (OSError, ValueError) as error:
notes.append("The current operation's recorded save backup could not be verified and was left unchanged: "
+ str(error)[:500] + ".")
return uninstall_recovery_result({"gamePath": str(GAME), "uninstalled": False,
"savedBackupPath": existing_saved, "steam": steam}, recovered, notes)
if not identity:
return {"gamePath": str(GAME), "uninstalled": True, "alreadyAbsent": True,
"savedBackupPath": metadata.get("savedBackupPath"), "steam": steam}
# Leftovers from an interrupted earlier run count as a removal this
# run finished, not as "already absent": the user still gets the
# completed-removal message and where that run's saves are.
existing_saved = None
notes = []
if metadata.get("savedBackupPath") is not None:
try:
existing_saved = verified_uninstall_backup(value, metadata)
except (OSError, ValueError) as error:
notes.append("The current operation's recorded save backup could not be verified and was left unchanged: "
+ str(error)[:500] + ".")
response = {"gamePath": str(GAME), "uninstalled": True, "alreadyAbsent": not recovered["removed"],
"savedBackupPath": existing_saved, "steam": steam}
return uninstall_recovery_result(response, recovered, notes)
game_closed()
no_active_build()
if uninstall_inventory(GAME) != inventory:
raise ValueError("The native game folder changed during uninstall. Its files were kept.")
# The complete journal is published before either rename: recovery can prove
# a markerless partial removal using this directory's dev/inode/uid.
metadata.update({"quarantinePath": str(quarantine), "quarantineRoot": uninstall_root_identity(GAME),
"savedBackupPath": str(saved) if staged else None,
"savedBackupRoot": uninstall_root_identity(staging) if staged else None,
"keepSaves": keep_saves, "state": "quarantining"})
write_uninstall_record(directory, metadata)
if staged:
rename_noreplace(staging, saved)
staged, saved_path = False, str(saved)
verified_uninstall_backup(value, metadata)
rename_noreplace(GAME, quarantine)
renamed = True
metadata.update({"quarantinePath": str(quarantine), "savedBackupPath": saved_path, "state": "removing"})
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
metadata["state"] = "removing"
write_uninstall_record(directory, metadata)
game_closed((quarantine / "halo",))
remove_uninstall_tree(quarantine, inventory, completed, total)
renamed = False
metadata["state"] = "complete"
warning = None
notes = []
try:
(directory / MARKER).write_text(json.dumps(metadata) + "\n")
write_uninstall_record(directory, metadata)
except OSError:
# Bookkeeping failure after deletion cannot undo a completed removal.
warning = "Halo was removed, but the installer operation record could not be updated."
notes.append("Halo was removed, but the installer operation record could not be updated.")
progress("uninstall", "Native Halo and its Steam shortcut were removed.", 100)
response = {"gamePath": str(GAME), "uninstalled": True, "savedBackupPath": saved_path,
"externalSavePathsPreserved": external, "steam": steam}
if warning:
response["warning"] = warning
return response
return uninstall_recovery_result(response, recovered, notes)
except BaseException as error:
if renamed and quarantine.exists():
removals = completed[0] - len(save_files)
@@ -1332,7 +1732,7 @@ def uninstall(value, close_steam=False, keep_saves=False):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("step", choices=("preflight", "preflight-uninstall", "prepare", "reuse-upload", "build", "finalize", "cancel", "shortcut", "uninstall"))
parser.add_argument("step", choices=("preflight", "preflight-uninstall", "preflight-library", "prepare", "reuse-upload", "build", "finalize", "cancel", "shortcut", "uninstall"))
parser.add_argument("--run-id")
parser.add_argument("--close-steam", action="store_true")
parser.add_argument("--repair", action="store_true")
@@ -1344,6 +1744,7 @@ def main():
parser.error("--run-id is required")
functions = {"preflight": lambda: preflight(args.repair, args.adopt_existing),
"preflight-uninstall": preflight_uninstall,
"preflight-library": preflight_library,
"prepare": lambda: prepare(args.run_id, args.use_existing_maps, args.repair, args.adopt_existing),
"reuse-upload": lambda: reuse_upload(args.run_id),
"build": lambda: build(args.run_id, args.repair, args.adopt_existing),
+136 -3
View File
@@ -458,6 +458,40 @@ ART_WRITE = "(async () => {" + ART_CONTEXT + r"""
return {ok:true, accountName:after.accountName, appid:after.appid};
})()"""
ICON_WRITE = "(async () => {" + ART_CONTEXT + r"""
if (typeof SteamClient.Apps.SetShortcutIcon !== 'function')
throw new Error('Steam shortcut icon API is unavailable');
const before = await current();
if (before.accountName !== S.accountName) throw new Error('Steam icon account changed');
const overview = typeof appStore !== 'undefined' &&
typeof appStore.GetAppOverviewByAppID === 'function' ? appStore.GetAppOverviewByAppID(S.appid) : null;
if (typeof overview?.icon_data !== 'string' || overview.icon_data.length)
throw new Error('Steam current icon is no longer confirmed empty');
let timer;
try {
await Promise.race([Promise.resolve(SteamClient.Apps.SetShortcutIcon(S.appid, S.iconPath)),
new Promise((_, reject) => {timer = setTimeout(() =>
reject(new Error('Steam shortcut icon API timed out')), 7000);})]);
} finally {clearTimeout(timer);}
const after = await current();
if (after.accountName !== S.accountName) throw new Error('Steam icon account changed');
return {ok:true, accountName:after.accountName, appid:after.appid};
})()"""
ICON_SESSION = "(async () => {" + ART_CONTEXT + r"""
const session = await current();
const overview = typeof appStore !== 'undefined' &&
typeof appStore.GetAppOverviewByAppID === 'function' ? appStore.GetAppOverviewByAppID(S.appid) : null;
const data = overview?.icon_data;
let iconRequested = false;
if (data === undefined && S.requestIcon === true && typeof SteamClient.Apps.RequestIconDataForApp === 'function') {
SteamClient.Apps.RequestIconDataForApp(S.appid);
iconRequested = true;
}
return {...session, iconRequested,
iconState:typeof data !== 'string' ? 'unknown' : data.length ? 'present' : 'empty'};
})()"""
def _artwork_config(home, account_name):
"""Resolve the live client's account, never the disk MostRecent heuristic."""
@@ -499,7 +533,8 @@ def _artwork_config(home, account_name):
def _live_artwork(home, game, appid, sources=None):
from steam_shortcut import ARTWORK, ARTWORK_EXTENSIONS, _artwork_file, _private_steam_directory
from steam_shortcut import (ARTWORK, ARTWORK_EXTENSIONS, ARTWORK_TYPES, ICON_NAME,
ICON_SHA256, _artwork_file, _private_steam_directory)
spec = native_spec(home, game, NAME)
spec["appid"] = appid
sources = Path(sources) if sources is not None else Path(__file__).resolve().parent / "artwork"
@@ -510,7 +545,9 @@ def _live_artwork(home, game, appid, sources=None):
if hashlib.sha256(content).hexdigest() != expected:
raise LiveSteamError("Bundled Halo artwork failed its integrity check.")
payloads.append({"stem": str(appid) + suffix, "extension": extension, "content": content,
"expected": expected, "assetType": 0 if suffix == "p" else 3})
"expected": expected, "assetType": ARTWORK_TYPES[suffix]})
if hashlib.sha256(_artwork_file(sources / ICON_NAME)).hexdigest() != ICON_SHA256:
raise LiveSteamError("Bundled Halo shortcut icon failed its integrity check.")
installed, preserved, unchanged = [], [], []
with Client() as client:
def snapshot():
@@ -533,7 +570,7 @@ def _live_artwork(home, game, appid, sources=None):
return "unchanged", [present[0][0].name]
return "preserved", [path.name for path, _ in present]
# Validate both existing categories before publishing either asset.
# Validate all existing categories before publishing any asset.
for payload in payloads:
inspect(payload)
for payload in payloads:
@@ -578,6 +615,97 @@ def _live_artwork(home, game, appid, sources=None):
"installed": installed, "preserved": preserved, "unchanged": unchanged}
def _live_icon(home, game, appid, sources=None):
"""Use the path API, reading persisted VDF only for identity and readback.
Unlike the artwork API, Steam's shortcut icon API does not expose an icon
path in AppDetails. Never assume such a field or write a live VDF ourselves.
A newly created shortcut that has not yet persisted can be retried safely.
"""
from steam_shortcut import (ICON_NAME, ICON_SHA256, _artwork_file,
_private_steam_directory, _publish_shortcut_icon,
_shortcut_icon_field)
spec = native_spec(home, game, NAME)
spec["appid"] = appid
spec["requestIcon"] = True
source = Path(sources) if sources is not None else Path(__file__).resolve().parent / "artwork"
_private_steam_directory(source)
if hashlib.sha256(_artwork_file(source / ICON_NAME)).hexdigest() != ICON_SHA256:
raise LiveSteamError("Bundled Halo shortcut icon failed its integrity check.")
with Client() as client:
def snapshot():
session = client.evaluate(expression(ICON_SESSION, spec))
if (session.get("appid") != appid or session.get("exe") not in (spec["exe"], '"' + spec["exe"] + '"')):
raise LiveSteamError("Steam's live icon shortcut could not be verified.")
return session
session = snapshot()
account_name = session.get("accountName")
spec["requestIcon"] = False
config = _artwork_config(home, account_name).parent
persisted = config / "shortcuts.vdf"
_private_steam_directory(config)
def current_icon():
_, _, selected = _shortcut_icon_field(_artwork_file(persisted), appid, spec["exe"])
return selected
selected = current_icon()
target = Path(game) / ".installer-artwork" / ICON_NAME
if selected:
if selected not in (str(target), '"' + str(target) + '"'):
return {"status": "preserved", "reason": "Your selected Steam shortcut icon was kept."}
_private_steam_directory(target.parent)
if hashlib.sha256(_artwork_file(target)).hexdigest() == ICON_SHA256:
return {"status": "unchanged", "path": str(target)}
return {"status": "preserved", "reason": "Your customized Halo icon was kept."}
if session.get("iconState") == "unknown" and session.get("iconRequested") is True:
# Steam's own UI requests undefined data and waits for it to become
# non-null. Request once, then accept only an explicit empty string.
load_deadline = min(client.deadline, time.monotonic() + 2)
while session.get("iconState") == "unknown" and time.monotonic() < load_deadline:
time.sleep(0.1)
session = snapshot()
if session.get("accountName") != account_name or _artwork_config(home, account_name).parent != config:
raise LiveSteamError("Steam's active icon account changed while its icon was loading.")
# The persisted record can lag a user's latest icon choice. A loaded
# resident icon wins, and an unloaded value cannot prove it is empty.
if session.get("iconState") == "present":
return {"status": "preserved", "reason": "The icon currently loaded by Steam was kept."}
if session.get("iconState") != "empty":
raise LiveSteamError("Steam's current shortcut icon has not loaded yet. Click Add to Steam again after opening Halo's library page.")
icon = _publish_shortcut_icon(Path(game), source)
if "path" not in icon:
return icon
fresh = snapshot()
if (fresh.get("accountName") != account_name or _artwork_config(home, account_name).parent != config):
raise LiveSteamError("Steam's active icon account changed during setup.")
if fresh.get("iconState") == "present":
return {"status": "preserved", "reason": "The icon currently loaded by Steam was kept."}
if fresh.get("iconState") != "empty":
raise LiveSteamError("Steam's current shortcut icon could not be verified as empty.")
# A user-selected path that appeared while preparing the sidecar wins.
if current_icon():
return {"status": "preserved", "reason": "Your selected Steam shortcut icon was kept."}
result = client.evaluate(expression(ICON_WRITE, {**spec, "accountName": account_name, "iconPath": icon["path"]}))
if result.get("ok") is not True or result.get("appid") != appid or result.get("accountName") != account_name:
raise LiveSteamError("Steam's shortcut icon update could not be verified.")
deadline = min(client.deadline, time.monotonic() + 5)
while True:
selected = current_icon()
if selected in (icon["path"], '"' + icon["path"] + '"'):
break
if selected or time.monotonic() >= deadline:
raise LiveSteamError("Steam has not yet persisted the Halo icon selection. Click Add to Steam again to retry.")
time.sleep(0.1)
if hashlib.sha256(_artwork_file(target)).hexdigest() != ICON_SHA256:
raise LiveSteamError("Steam's Halo icon source changed during setup.")
after = snapshot()
if after.get("accountName") != account_name or _artwork_config(home, account_name).parent != config:
raise LiveSteamError("Steam's active icon account changed during setup.")
return {"status": "added", "path": icon["path"]}
def add_native(home, game, name=NAME, launch_options=LAUNCH_OPTIONS, artwork=None):
value = _library(home, game, name, "add", launch_options)
try:
@@ -585,6 +713,11 @@ def add_native(home, game, name=NAME, launch_options=LAUNCH_OPTIONS, artwork=Non
except (ImportError, OSError, ValueError, RuntimeError, UnicodeError) as error:
art_result = {"status": "manual", "reason": str(error),
"instructions": "Use Steam's custom artwork options, or retry adding Halo box art."}
try:
art_result["icon"] = _live_icon(home, game, value["appid"], artwork)
except (ImportError, OSError, ValueError, RuntimeError, UnicodeError) as error:
art_result["icon"] = {"status": "manual", "reason": str(error),
"instructions": "Click Add to Steam again to retry the Halo shortcut icon."}
return {"status": "added", "libraryAdded": True, "appid": value["appid"], "name": NAME,
"unchanged": not value.get("created", False), "transport": "live-client",
"artwork": art_result}
+309
View File
@@ -0,0 +1,309 @@
"""Add an About and controls note through Steam's own live Game Notes API.
Steam Notes are private notes on the game details page. They do not replace
Steam's non-Steam description or create store metadata, achievements, or an
official Steam application. No Steam files or sessions are changed here.
"""
from __future__ import annotations
from steam_live import Client, GAME, HOME, NAME, expression, native_spec
NOTE_ID = "haloframeaboutv1"
NOTE_TITLE = "Halo CE VR — About & controls"
# Exact unchanged 1.4.0a2 note, verified from its local source ZIP.
# UTF-8 SHA256: a4e1776caa60548620e0510acbe194aa414645be8d042771d9cbe1ec49edc05e
A2_NOTE_CONTENT = """[h1]Halo: Combat Evolved VR (Experimental)[/h1]
Play the original 2001 Halo: Combat Evolved campaign as Master Chief, fighting
the Covenant and uncovering the secrets of a mysterious ringworld.
This independent community installation uses your original Xbox game data
with an experimental native ARM64 Linux/OpenXR VR port on Steam Frame.
It is unaffiliated with Microsoft, Bungie, or Valve. VR support and performance
vary by scene, headset software, and settings.
[h2]Xbox-style Frame controls[/h2]
Point the right controller to aim; move your head to look around.
Left stick: move relative to your head direction. Left stick click: crouch.
Right stick left/right: turn. Right stick click: zoom.
Right trigger: fire. Left trigger: throw grenade.
A: jump; accept menu selections. B: melee; go back in menus.
X: reload/use; hold for context actions such as exchanging a weapon.
Y: change weapon. Left bumper: change grenade type.
Right bumper: toggle flashlight. Left controller d-pad: navigate menus.
View: scoreboard/back. Menu: pause.
Both grips held: recenter the headset view; release a held foregrip first.
Left grip near a long gun's foregrip: aim the gun with both hands.
These controls assume the game's default profile button layout.
VR options are available in Settings > VR Setup.
[h2]Multiplayer[/h2]
LAN and internet play require compatible native/OpenCE protocol builds.
This experimental build uses protocol 17; stable protocol 11 cannot join it.
For campaign co-op, host a LAN or internet game and choose a SINGLEPLAYER map.
The experimental game and saves use a separate folder. Older checkpoints
cannot be loaded. Extra co-op enemies are disabled by default.
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
provided by this installer.
[h2]Sources and setup help[/h2]
[url=https://github.com/ClassicWasTaken/HaloSteamFrameMod]Stable installer project and sources[/url]
[url=https://github.com/startupfoundry/halo-ce-universal/tree/2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4]Pinned native port source[/url]
"""
A4_NOTE_CONTENT = A2_NOTE_CONTENT.replace(
"""[h2]Multiplayer[/h2]
LAN and internet play require compatible native/OpenCE protocol builds.
This experimental build uses protocol 17; stable protocol 11 cannot join it.
For campaign co-op, host a LAN or internet game and choose a SINGLEPLAYER map.
The experimental game and saves use a separate folder. Older checkpoints
cannot be loaded. Extra co-op enemies are disabled by default.
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
provided by this installer.
""",
"""[h2]Online campaign co-op with a friend[/h2]
LAN and internet play require compatible native/OpenCE protocol builds.
Both players should install the same experimental version and supply their
own supported original Xbox maps. This build uses protocol 17; stable
protocol 11, Halo PC, Custom Edition, MCC and retail Xbox clients cannot join.
Host: in an active headset, Multiplayer > CO-OP CAMPAIGN opens online
hosting with SINGLEPLAYER campaign maps. Choose a level, then difficulty.
In Server Setup, set LISTING to PUBLIC for controller-only discovery.
PUBLIC lets anyone find and join the game; PRIVATE is the default.
Choose START GAME. Friend: Multiplayer > Join Game > Server Browser,
REFRESH if needed, select the host's game and press A. When both players
appear in the lobby, wait for the countdown or select START NOW.
Use the left d-pad to navigate/change row choices, A to accept, B to go back.
Each player uses their own headset; no local second controller is required.
For invite-only play, keep PRIVATE. On the host, select the INVITE LINK row
and press A to copy its halo://join/ link; share it yourself. The friend
copies it on their Frame, then uses Join Game > Direct Link > PASTE LINK
and selects the host's game. A PC clipboard is separate from the Frame's.
An invite stops working after its hosting game quits.
Internet play uses direct UDP with signaling brokers, not a gameplay relay.
Strict NAT/CGNAT can prevent joining even when a listing is visible. UPnP,
hosting from the other network, or forwarding a fixed network.tunnel_port
on an accessible router may help; distant-peer VR co-op remains unverified.
The experimental game/saves stay separate; older checkpoints cannot load.
Extra co-op enemies are disabled by default. For the original menu route,
use Create Game > Internet and change the Map chooser to SINGLEPLAYER.
Flat-screen CO-OP CAMPAIGN retains local split-screen.
""")
# Exact previously shipped a3/a4 guide; upgrade only unchanged managed notes.
# UTF-8 SHA256: 55785ec794857e844722ba758a4ce47fdb607a6bba2c30f23a437dd8f320ae30
# Exact unchanged stable 1.3.5 note; UTF-8 SHA256: ceed0d2d403fa18904c5d529ae409c8bfedad733fa1f1a7d93a6e2c2fdb8d32a
STABLE_NOTE_CONTENT = "[h1]Halo: Combat Evolved VR (Native)[/h1]\nPlay the original 2001 Halo: Combat Evolved campaign as Master Chief, fighting\nthe Covenant and uncovering the secrets of a mysterious ringworld.\n\nThis independent community installation uses your original Xbox game data\nwith an experimental native ARM64 Linux/OpenXR VR port on Steam Frame.\nIt is unaffiliated with Microsoft, Bungie, or Valve. VR support and performance\nvary by scene, headset software, and settings.\n\n[h2]Xbox-style Frame controls[/h2]\nPoint the right controller to aim; move your head to look around.\nLeft stick: move. Left stick click: crouch.\nRight stick left/right: turn. Right stick click: zoom.\nRight trigger: fire. Left trigger: throw grenade.\nA: jump; accept menu selections. B: melee; go back in menus.\nX: reload/use; hold for context actions such as exchanging a weapon.\nY: change weapon. Left bumper: change grenade type.\nRight bumper: toggle flashlight. Left controller d-pad: navigate menus.\nView: scoreboard/back. Menu: pause.\nBoth grips held: recenter the headset view; release a held foregrip first.\nLeft grip near a long gun's foregrip: aim the gun with both hands.\nThese controls assume the game's default profile button layout.\nVR options are available in Settings > VR Setup.\n\n[h2]Multiplayer[/h2]\nLAN and internet play require compatible native/OpenCE protocol builds.\nHalo PC, Custom Edition, MCC, and retail Xbox server compatibility is not\nprovided by this installer.\n\n[h2]Sources and setup help[/h2]\n[url=https://github.com/ClassicWasTaken/HaloCE-SteamFrame]Installer, controls, and troubleshooting[/url]\n[url=https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106]Pinned native port source[/url]\n"
PREVIOUS_NOTE_CONTENTS = (A2_NOTE_CONTENT, A4_NOTE_CONTENT, STABLE_NOTE_CONTENT)
NOTE_CONTENT = (A4_NOTE_CONTENT[:A4_NOTE_CONTENT.index("[h2]Online campaign co-op with a friend[/h2]")]
+ """[h2]LAN and online campaign co-op[/h2]
LAN and internet play require compatible native/OpenCE protocol builds.
Both players should install the same experimental version and supply their
own supported original Xbox maps. This build uses protocol 17; stable
protocol 11, Halo PC, Custom Edition, MCC and retail Xbox clients cannot join.
Open Multiplayer > CO-OP CAMPAIGN. Each machine uses one local player.
On the same network: HOST LOCAL (LAN) and JOIN LOCAL (LAN).
Across different networks: HOST ONLINE (INTERNET) and JOIN ONLINE (INTERNET).
Choose a campaign level and difficulty on the host, then START GAME.
The friend selects the host's game; REFRESH if needed. Once both names
appear in the lobby, wait for the countdown or select START NOW.
Use the left d-pad to navigate/change choices, A to accept, B to go back.
Online hosting is PRIVATE by default. For server-browser discovery, set
LISTING to PUBLIC in Server Setup. PUBLIC lets anyone find and join.
For invite-only play, keep PRIVATE, select the INVITE LINK row and press A
to copy its halo://join/ link. Share it yourself. The friend copies it on
their Frame, selects JOIN INVITE LINK, then PASTE LINK and the host's game.
A PC clipboard is separate from the Frame's. The invite expires when its
hosting game quits. Each player uses their own headset or computer.
Internet play uses direct UDP with signaling brokers, not a gameplay relay.
Strict NAT/CGNAT can prevent joining even when a listing is visible. UPnP,
hosting from the other network, or forwarding a fixed network.tunnel_port
on an accessible router may help; distant-peer VR co-op remains unverified.
The experimental game/saves stay separate; older checkpoints cannot load.
Extra co-op enemies are disabled by default.
"""
+ A4_NOTE_CONTENT[A4_NOTE_CONTENT.index("[h2]Sources and setup help[/h2]"):])
# Public 1.4.0 uses the existing native shortcut and preserves older checkpoints.
NOTE_CONTENT = (NOTE_CONTENT
.replace("Halo: Combat Evolved VR (Experimental)", "Halo: Combat Evolved VR (Native)")
.replace("with an experimental native ARM64", "with a native ARM64")
.replace("the same experimental version", "the same 1.4.0 release")
.replace("This build uses protocol 17; stable", "1.4.0 uses protocol 17; 1.3.5")
.replace("The experimental game/saves stay separate; older checkpoints cannot load.",
"1.4.0 upgrades the existing native game. New profiles and saves use\n"
"save-v1.4 inside its game folder. Your old save folder is retained\n"
"unchanged; older checkpoints cannot load in the newer engine.")
.replace("Stable installer project and sources", "Installer project and sources"))
MAX_DOCUMENT_BYTES = 1024 * 1024
MAX_NOTES = 512
NOTES = r"""(async () => {
const deadline = Date.now() + 23000;
const apps = typeof SteamClient !== 'undefined' && SteamClient.Apps;
const notes = typeof SteamClient !== 'undefined' && SteamClient.GameNotes;
const store = typeof appStore !== 'undefined' && appStore.m_mapApps;
const user = typeof loginStore !== 'undefined' && loginStore.currentUser;
const manual = message => ({ok:false,status:'manual',message});
if (!apps || typeof apps.RegisterForAppDetails !== 'function' || !notes ||
['SyncToClient','GetNotes','SaveNotes','SyncToServer'].some(k => typeof notes[k] !== 'function') ||
!store || typeof store.values !== 'function' || typeof store.get !== 'function' ||
!user || typeof user.accountName !== 'string' || !user.accountName ||
typeof TextEncoder !== 'function')
return manual('Steam Game Notes is unavailable. Add the supplied text as a note manually.');
if (!Array.isArray(S.previousContents) || S.previousContents.length > 4 ||
S.previousContents.some(content => typeof content !== 'string' ||
new TextEncoder().encode(content).length > 16384))
return manual('The previous installer note list is invalid. Existing notes were preserved.');
const accountName = user.accountName;
async function bounded(value, limit=3500) {
let timer;
try {
return await Promise.race([Promise.resolve(value),new Promise((_,reject) => {
timer=setTimeout(() => reject(new Error('Steam Game Notes timed out')),
Math.max(1,Math.min(limit,deadline-Date.now())));
})]);
} finally { clearTimeout(timer); }
}
async function identity() {
if (Date.now() >= deadline) throw new Error('Steam Game Notes timed out');
if (loginStore.currentUser?.accountName !== accountName)
throw new Error('The active Steam account changed');
let count=0;
for (const entry of store.values()) {
if (++count > 100000) throw new Error('Steam library exceeds supported bounds');
if (entry.appid !== S.appid && entry.display_name === S.name)
throw new Error('Another game uses the native Halo shortcut name');
}
const overview=store.get(S.appid);
if (!overview || overview.app_type !== 1073741824 || overview.display_name !== S.name)
throw new Error('The native Halo shortcut identity changed');
await new Promise((resolve,reject) => {
let subscription,done=false;
const cleanup=() => queueMicrotask(() => subscription?.unregister());
const timer=setTimeout(() => {done=true;cleanup();
reject(new Error('Native Halo notes identity timed out'));},
Math.max(1,Math.min(3500,deadline-Date.now())));
try {
subscription=apps.RegisterForAppDetails(S.appid,data => {
if (done) return;
done=true;clearTimeout(timer);cleanup();
if (data.unAppID !== S.appid ||
![S.exe,'"'+S.exe+'"'].includes(data.strShortcutExe) ||
loginStore.currentUser?.accountName !== accountName)
reject(new Error('Native Halo notes account or executable changed'));
else resolve();
});
} catch(error) {done=true;clearTimeout(timer);cleanup();reject(error);}
});
}
function parse(response, missingAllowed=false) {
if (!response || typeof response !== 'object')
throw new Error('Steam returned an invalid notes response');
if (response.result === 9 && missingAllowed)
return {notes:[],shortcut_name:S.name};
if (response.result !== 1 || typeof response.notes !== 'string' ||
new TextEncoder().encode(response.notes).length > S.maxBytes)
throw new Error('Steam notes could not be read within supported bounds');
const doc=JSON.parse(response.notes);
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
doc.shortcut_name !== S.name || !Array.isArray(doc.notes) ||
doc.notes.length > S.maxNotes ||
doc.notes.some(note => !note || typeof note !== 'object' || Array.isArray(note)))
throw new Error('Steam notes belong to another shortcut or have an unsupported format');
return doc;
}
function encoded(doc) {
const text=JSON.stringify(doc);
if (new TextEncoder().encode(text).length > S.maxBytes)
throw new Error('Steam notes exceed supported bounds');
return text;
}
// These name and filename rules match Steam's own GameNotesCloudStore.
const filename='notes_shortcut_'+S.name.trim().replace(/[!-/:-@ [\\\]^`]/g,'_');
const images=filename+'_images/';
try {
await identity();
if (await bounded(notes.SyncToClient(),7000) !== 1)
throw new Error('Steam notes cloud synchronization is unavailable');
await identity();
const first=await bounded(notes.GetNotes(filename,images));
const doc=parse(first,true);
const matches=doc.notes.filter(note => note.id === S.noteId);
if (matches.length > 1) throw new Error('The Halo notes identifier is ambiguous');
let previous;
if (matches.length) {
await identity();
if (matches[0].title !== S.title || matches[0].shortcut_name !== S.name ||
(matches[0].content !== S.content && !S.previousContents.includes(matches[0].content)))
return {ok:true,status:'custom',message:'Your edited Halo note was kept unchanged.'};
if (matches[0].content === S.content)
return {ok:true,status:'existing',message:'The Halo About and controls note is already present.'};
previous=matches[0];
}
if (!previous && doc.notes.length >= S.maxNotes) throw new Error('Steam notes exceed supported bounds');
const timestamp=Math.floor(Date.now()/1000);
const note=previous ? {...previous,content:S.content,time_modified:timestamp} :
{id:S.noteId,shortcut_name:S.name,ordinal:0,time_created:timestamp,
time_modified:timestamp,title:S.title,content:S.content};
const updated={...doc,notes:previous ? doc.notes.map(entry => entry === previous ? note : entry) :
[...doc.notes,note]};
const text=encoded(updated);
// Do not overwrite edits made while the installer was preparing the note.
const reread=await bounded(notes.GetNotes(filename,images));
if (encoded(parse(reread,true)) !== encoded(doc))
throw new Error('Steam notes changed while setup was preparing the Halo note');
await identity();
if (await bounded(notes.SaveNotes(filename,text),7000) !== 1)
throw new Error('Steam could not save the Halo note');
await identity();
if (await bounded(notes.SyncToServer(),7000) !== 1)
throw new Error('The Halo note was saved locally but cloud synchronization is pending');
await identity();
const readback=parse(await bounded(notes.GetNotes(filename,images)));
if (encoded(readback) !== text)
throw new Error('The Halo note could not be verified after saving');
await identity();
return previous ? {ok:true,status:'updated',message:'Updated the unchanged Halo note with online co-op instructions.'} :
{ok:true,status:'added',message:'Added the Halo About and controls note to Steam Game Notes.'};
} catch(error) {
return manual('Steam could not verify the Halo note. Your other notes were preserved; add the supplied text manually.');
}
})()"""
def add_notes(home, game, appid, name=NAME):
"""Return independent notes status without interrupting game registration."""
result = {"ok": False, "status": "manual",
"message": "Steam Game Notes is unavailable. Add the supplied text as a note manually.",
"title": NOTE_TITLE, "content": NOTE_CONTENT}
try:
spec = native_spec(home, game, name)
if type(appid) is not int or not 2 ** 31 <= appid < 2 ** 32:
return result
spec.update({"appid": appid, "noteId": NOTE_ID, "title": NOTE_TITLE,
"content": NOTE_CONTENT, "maxBytes": MAX_DOCUMENT_BYTES,
"maxNotes": MAX_NOTES, "previousContents": list(PREVIOUS_NOTE_CONTENTS)})
with Client() as client:
value = client.evaluate(expression(NOTES, spec))
status = value.get("status")
if status in ("added", "updated", "existing", "custom") and value.get("ok") is True:
result.update({"ok": True, "status": status,
"message": {"added": "Added the Halo About and controls note to Steam Game Notes.",
"updated": "Updated the unchanged Halo note with online co-op instructions.",
"existing": "The Halo About and controls note is already present.",
"custom": "Your edited Halo note was kept unchanged."}[status]})
elif status == "manual":
result["message"] = "Steam could not verify the Halo note. Add the supplied text to Steam Game Notes manually."
except Exception:
# Notes are optional enrichment: failure never controls Steam, edits its
# files, or changes a successfully installed game's registration.
pass
return result
+146 -11
View File
@@ -21,8 +21,16 @@ from pathlib import Path
NAME = "Halo: Combat Evolved VR (Native)"
LAUNCH_OPTIONS = "SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%"
ARTWORK = (("p", ".jpg", "halo-ce-cover.jpg", "84fee9349f8f00d8ade44c840330ac54ec4b01ef84086d72b8c791d16c8ad248"),
("", ".png", "halo-ce-landscape.png", "179ff6b9bd1a6ba610e9c0c4b9b32c76e525719b253856d547f65eaa02d1f33d"))
("", ".png", "halo-ce-landscape.png", "179ff6b9bd1a6ba610e9c0c4b9b32c76e525719b253856d547f65eaa02d1f33d"),
("_hero", ".jpg", "halo-ce-hero.jpg", "3eb9ba1515bd7e25b11a31911edb8610ee33fe9df0505f1a805214e9a9dcff31"),
("_logo", ".png", "halo-ce-logo.png", "7df3336e9988291552270764e80196b1892ab9d11b79d7d289e6243746d42dc8"))
ICON_NAME = "halo-ce-icon.png"
ICON_SHA256 = "184620356407e42a33877528c4f14b702df8b026415a99ed16976565fcfbce36"
ICON_SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
ARTWORK_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
# Verified against the installed Steam client's library.js asset enum.
# Shortcut icons have their own path setter and are not sent through this API.
ARTWORK_TYPES = {"p": 0, "_hero": 1, "_logo": 2, "": 3}
@dataclass
@@ -304,7 +312,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
"""Remove only exact native title/path matches; keep all foreign fields."""
"""Remove only entries that launch the native executable; keep all foreign fields."""
root = loads(data)
shortcuts = root.get("shortcuts")
if shortcuts is None or shortcuts.kind != 0:
@@ -314,10 +322,15 @@ def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]:
if entry.kind != 0:
raise ValueError("Unexpected Steam shortcut record; no changes were made.")
fields = entry.value
title = fields.get("AppName", fields.get("appname"))
path = fields.get("Exe", fields.get("exe"))
if (title is not None and title.kind == 1 and title.value == NAME
and path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"')):
# Ownership is the native executable, not the display title: Steam
# lets users rename library entries, and the live client path treats
# an entry pointing at the native executable as owned as well. Match
# the path exactly, bare or quoted once, like the live client does.
# A malformed exact-native record blocks the transaction: treating it
# as absent would let uninstall delete the game while leaving its
# shortcut behind. Validate every account before publishing changes.
if path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"'):
appid = fields.get("appid")
if appid is None or appid.kind != 2 or not 2 ** 31 <= appid.value < 2 ** 32:
raise ValueError("The native Halo shortcut has an invalid app ID; no changes were made.")
@@ -420,11 +433,127 @@ def _artwork_file(path: Path, maximum: int = 8 * 1024 * 1024) -> bytes:
return content
def _shortcut_icon_field(data: bytes, appid: int, executable: str) -> tuple[OrderedDict, OrderedDict, str]:
"""Locate an exact persisted shortcut; this also serves live read-only checks."""
root = loads(data)
shortcuts = root.get("shortcuts")
if shortcuts is None or shortcuts.kind != 0:
raise ValueError("Steam's icon shortcut record is unavailable.")
matches = []
for entry in shortcuts.value.values():
if entry.kind != 0:
raise ValueError("Steam's icon shortcut record is unsupported.")
fields = entry.value
ids = [value for key, value in fields.items() if key.casefold() == "appid"]
if len(ids) != 1 or ids[0].kind != 2 or ids[0].value != appid:
continue
paths = [value for key, value in fields.items() if key.casefold() == "exe"]
icons = [value for key, value in fields.items() if key.casefold() == "icon"]
if (len(paths) != 1 or paths[0].kind != 1
or paths[0].value not in (executable, '"' + executable + '"')
or len(icons) > 1 or (icons and (icons[0].kind != 1 or not isinstance(icons[0].value, str)))):
raise ValueError("Steam's icon shortcut identity could not be verified.")
matches.append((fields, icons[0].value if icons else ""))
if len(matches) != 1:
raise ValueError("Steam has not yet persisted one exact native Halo icon record. Click Add to Steam again to retry.")
return root, matches[0][0], matches[0][1]
def _publish_shortcut_icon(game: Path, sources: Path | None = None) -> dict:
"""Persist an owned sidecar, keeping any altered or concurrent user file."""
sources = sources or Path(__file__).resolve().parent / "artwork"
_private_steam_directory(sources)
content = _artwork_file(sources / ICON_NAME)
if hashlib.sha256(content).hexdigest() != ICON_SHA256:
raise ValueError("Bundled Halo shortcut icon failed its integrity check.")
for _, _, filename, expected in ARTWORK:
if hashlib.sha256(_artwork_file(sources / filename)).hexdigest() != expected:
raise ValueError("Bundled Halo artwork failed its integrity check.")
_private_steam_directory(game)
marker = None
for name, owner in ((".halo-frame-installer.json", "halo-frame-installer"),
(".codex-halo-native-install.json", "codex-halo-native-frame-20261005")):
candidate = game / name
if not (candidate.exists() or candidate.is_symlink()):
continue
marker = json.loads(_artwork_file(candidate, 64 * 1024))
if not isinstance(marker, dict) or marker.get("owner") != owner:
raise ValueError("The native Halo icon folder has no recognized installer ownership marker.")
break
if marker is None:
raise ValueError("The native Halo icon folder has no recognized installer ownership marker.")
build = marker.get("build", {})
source = marker.get("sourceCommit")
if owner == "codex-halo-native-frame-20261005":
source = build.get("sourceCommit", source) if isinstance(build, dict) else None
files = marker.get("files", {})
expected = files.get("halo") if isinstance(files, dict) else None
if source != ICON_SOURCE_COMMIT or not isinstance(expected, str) or not re.fullmatch("[a-f0-9]{64}", expected):
raise ValueError("The Halo icon needs a verified native installation from the supported source revision.")
program = _artwork_file(game / "halo", 128 * 1024 * 1024)
if (len(program) < 64 or program[:6] != b"\x7fELF\x02\x01" or struct.unpack_from("<H", program, 18)[0] != 183
or hashlib.sha256(program).hexdigest() != expected):
raise ValueError("The Halo icon needs the verified native ARM64 executable; the game was kept.")
folder = game / ".installer-artwork"
if folder.exists() or folder.is_symlink():
_private_steam_directory(folder)
else:
folder.mkdir(mode=0o700)
_private_steam_directory(folder)
target = folder / ICON_NAME
if target.exists() or target.is_symlink():
existing = _artwork_file(target)
if hashlib.sha256(existing).hexdigest() != ICON_SHA256:
return {"status": "preserved", "reason": "The existing Halo icon file was customized and kept."}
return {"status": "unchanged", "path": str(target)}
fd, name = tempfile.mkstemp(prefix=".halo-icon-", dir=folder)
temporary = Path(name)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
temporary.chmod(0o600)
# Link publication refuses a destination created since our first read.
os.link(temporary, target)
finally:
temporary.unlink(missing_ok=True)
if hashlib.sha256(_artwork_file(target)).hexdigest() != ICON_SHA256:
raise ValueError("The newly published Halo icon could not be verified.")
return {"status": "added", "path": str(target)}
def _closed_shortcut_icon(data: bytes, game: Path, appid: int) -> tuple[bytes, dict]:
"""Change only an empty icon field in an already closed Steam transaction."""
try:
root, fields, current = _shortcut_icon_field(data, appid, str(game / "halo"))
managed = game / ".installer-artwork" / ICON_NAME
if current:
if current not in (str(managed), '"' + str(managed) + '"'):
return data, {"status": "preserved", "reason": "Your selected Steam shortcut icon was kept."}
if hashlib.sha256(_artwork_file(managed)).hexdigest() == ICON_SHA256:
return data, {"status": "unchanged", "path": str(managed)}
return data, {"status": "preserved", "reason": "Your customized Halo icon was kept."}
icon = _publish_shortcut_icon(game)
if "path" not in icon:
return data, icon
for key in list(fields):
if key.casefold() == "icon":
del fields[key]
fields["icon"] = Value(1, icon["path"])
result = dumps(root)
_shortcut_icon_field(result, appid, str(game / "halo"))
return result, {"status": "added", "path": icon["path"]}
except (OSError, ValueError, UnicodeError) as error:
return data, {"status": "manual", "reason": str(error),
"instructions": "Click Add to Steam again to retry the Halo shortcut icon."}
def install_artwork(config: Path, appid: int, sources: Path | None = None) -> dict:
"""Fill missing portrait and wide artwork without replacing custom images.
"""Fill missing library artwork without replacing custom images.
Steam names non-Steam artwork with the existing shortcut's unsigned 32-bit
app ID. Both files are staged before publication, and hard-link publication
app ID. All files are staged before publication, and hard-link publication
fails rather than replacing a file that appears concurrently. A failed
transaction removes only unchanged new files this call created.
"""
@@ -441,12 +570,14 @@ def install_artwork(config: Path, appid: int, sources: Path | None = None) -> di
if hashlib.sha256(content).hexdigest() != expected:
raise ValueError("Bundled Halo artwork failed its integrity check. Re-download the installer and retry.")
payloads.append((str(appid) + suffix, extension, content, expected))
if hashlib.sha256(_artwork_file(sources / ICON_NAME)).hexdigest() != ICON_SHA256:
raise ValueError("Bundled Halo shortcut icon failed its integrity check.")
grid = config / "grid"
if grid.is_symlink() or grid.resolve() != grid or (grid.exists() and not grid.is_dir()):
raise ValueError("Steam artwork folder is not an ordinary directory.")
if steam_running():
raise ValueError("Steam is running; its artwork was left unchanged. Use Steam's custom artwork options or retry when Steam is already closed.")
# Validate both categories before writing either. Different-extension custom
# Validate all categories before writing any. Different-extension custom
# art wins too; keep existing user images without creating competing files.
pending = []
preserved = []
@@ -539,13 +670,16 @@ def _write_native_shortcut(home: Path, game: Path) -> dict:
raise ValueError("Steam shortcuts file is not a supported ordinary file.")
before = path.read_bytes() if path.exists() else None
after, appid = update_shortcut(before, str(game / "halo"), str(game))
after, icon = _closed_shortcut_icon(after, game, appid)
# A second guard prevents overwriting Steam's live view if it opened during work.
if steam_running():
return {**manual, "reason": "Steam reopened during setup; its library files were left unchanged."}
if (path.read_bytes() if path.exists() else None) != before:
return {**manual, "reason": "Steam's shortcuts changed during setup; no changes were made."}
if before == after:
return _added_result(steam, appid, unchanged=True, artwork=_artwork_result(config, appid))
art = _artwork_result(config, appid)
art["icon"] = icon
return _added_result(steam, appid, unchanged=True, artwork=art)
backup = None
if before is not None:
backup = config / ("shortcuts.vdf.halo-frame-installer-" + str(time.time_ns()) + ".bak")
@@ -563,8 +697,9 @@ def _write_native_shortcut(home: Path, game: Path) -> dict:
finally:
if os.path.exists(temp):
os.unlink(temp)
return _added_result(steam, appid, backup=str(backup) if backup else None,
artwork=_artwork_result(config, appid))
art = _artwork_result(config, appid)
art["icon"] = icon
return _added_result(steam, appid, backup=str(backup) if backup else None, artwork=art)
except (OSError, ValueError, UnicodeError) as error:
return {**manual, "reason": str(error)}
+34
View File
@@ -0,0 +1,34 @@
# Classic Halo PC installer icon
Version 1.3.2 uses the classic Halo: Combat Evolved PC helmet-and-shoulders
application icon requested by the user. The clean image contains no desktop
background, shortcut arrow, or filename label.
`app-icon.ico` is the unchanged 29,926-byte icon downloaded from
[HaloNet's favicon](https://halonet.net/favicon.ico) on 5 October 2026. It was
verified byte-for-byte against icon group 102 reconstructed from the locally
installed retail Halo PC `halo.exe`. No game executable was copied into this
repository or the installer. The ICO contains the original 16, 24, 32, and 48
pixel images at 4, 8, and 32-bit color: 12 entries in total.
`app-icon.png` is an unchanged-pixel PNG export of the ICO's 48 × 48, 32-bit
RGBA frame for the installer's Tk window icon. It retains transparency. No
resizing, redrawing, or generative image processing was applied. Larger Windows
display sizes use the original available artwork; this is not an HD remake.
SHA256:
```text
aa212eba758abaad8e7a134c4084110c9311ded77c0924865294d7ecf26f0216 app-icon.ico
e92525343f2e88f57024a34a1eda22f4de0dfbb16c5fcc9b16c7a1ba298b1ab9 app-icon.png
```
Halo artwork, Master Chief, and associated names and trademarks retain their
rights holders' rights, including Microsoft. The icon is separate from the
installer code's MIT license; hosting and attribution do not grant a general
artwork redistribution license.
The earlier project-drawn helmet remains the Steam shortcut icon, under
`resources/artwork/halo-ce-icon.png` with its original vector source
`halo-ce-icon.svg`. Changing the Windows installer icon does not replace
existing Steam library icons.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+2 -2
View File
@@ -9,8 +9,8 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
VERSION = "1.3.0"
EXE_NAME = f"Halo-Steam-Frame-Setup-{VERSION}.exe"
VERSION = "1.4.0"
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
def allowed_source_files():
roots = [ROOT / name for name in ("src", "resources", "docs", "scripts", "tests", ".github")]
+404
View File
@@ -0,0 +1,404 @@
"""Offline checks of the pinned experimental engine and shipped local patch.
Reads a local vanilla/patched checkout. Applies the patch only to a temporary
projection and generates the real ARM64 Ninja graph without fetching tools,
compiling a game, using retail data, or connecting a headset.
"""
from __future__ import annotations
import argparse
import io
import json
from pathlib import Path, PurePosixPath
import re
import subprocess
import sys
import tarfile
import tempfile
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
PATCH_FILES = frozenset((
"port/linux/src/vr.c", "port/linux/src/vr.h", "port/linux/src/vr_locomotion.h",
"port/linux/src/port_config.c", "source/game/player_control.c", "tools/test_vr_locomotion.py",
"port/linux/game/menu_functions.c", "port/linux/game/vr_render.c",
"port/linux/src/d3d8_gl.c", "port/linux/src/nv2a_psh.c", "port/linux/src/xgpu.h",
"source/rasterizer/xbox/rasterizer_xbox_lights.c",
"tools/test_online_coop_menu.py", "tools/test_vr_sun_glow.py",
"source/items/weapons.c", "source/items/weapons.h",
"source/game/aim_assist.c", "source/game/aim_assist.h",
"tools/test_vr_projectile_reticle.py",
"port/assets/menus/ce/main_menu.multiplayer_type_select.coop.xml", "tools/port_settings.py",
"source/units/units.c", "source/units/units.h",
))
class EngineCheckError(RuntimeError):
pass
def require(condition, message):
if not condition:
raise EngineCheckError(message)
def git(source, *arguments, check=True):
result = subprocess.run(["git", "-C", str(source), *arguments], capture_output=True, timeout=30)
if check:
require(result.returncode == 0, "A local Git source check failed; no engine files were changed.")
return result
def patch_files(text):
files = re.findall(r"^diff --git a/(\S+) b/(\S+)$", text, re.M)
require(bool(files), "The local engine patch has no ordinary file diffs.")
for before, after in files:
require(before == after and after in PATCH_FILES,
"The engine patch changes a path outside the reviewed integration files.")
return frozenset(after for _, after in files)
def c_function(text, name):
# Keep offsets while hiding comments and string contents from brace counting.
comments = re.compile(r"/\*.*?\*/|//[^\n]*", re.S)
clean = comments.sub(lambda match: " " * len(match.group()), text)
masked = re.sub(r'"(?:\\.|[^"\\])*"', lambda match: " " * len(match.group()), clean)
start = re.search(r"\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", masked)
require(start is not None, "Missing engine function: " + name)
depth = 1
for offset in range(start.end(), len(masked)):
depth += (masked[offset] == "{") - (masked[offset] == "}")
if not depth:
return clean[start.end():offset]
raise EngineCheckError("Unterminated engine function: " + name)
def check_texture_binding(text):
body = c_function(text, "bind_textures")
loops = list(re.finditer(r"for\s*\(stage\s*=\s*0;\s*stage\s*<\s*D3DTSS_MAXSTAGES;\s*stage\+\+\)", body))
bindings = list(re.finditer(r"\bstate_texture\s*\(", body))
require(len(loops) == 2 and len(bindings) == 1 and bindings[0].start() > loops[1].start(),
"Texture binding is not delayed until the second complete stage pass.")
resolve = body.find("xgpu_texture_get(")
require(loops[0].start() < resolve < loops[1].start(),
"Texture uploads must finish before the final stage-binding pass.")
require("state_texture(stage, gl_targets[stage], gl_textures[stage])" in body,
"Final texture bindings do not use the resolved per-stage arrays.")
def check_upstream(source):
read = lambda path: (source / path).read_text(encoding="utf-8")
limits = read("port/linux/include/halo_port_limits.h")
require(re.search(r"^#define HALO_PORT_NETWORK_VERSION 17\s*$", limits, re.M),
"The candidate must use native peer protocol 17.")
check_texture_binding(read("port/linux/src/d3d8_gl.c"))
shader = read("port/linux/src/nv2a_vsh.c")
require("clip_position = oPos" in shader and "clip_captured = true" in shader
and "if (!(abs(position.w) > 0.0))" in shader
and "position = vec4(0.0, 0.0, 0.0, -1.0)" in shader,
"The pinned vertex clip-position safeguards are missing.")
distributed = read("port/linux/game/network_distributed.c")
coop = read("port/linux/game/network_coop.c")
for hook in ("network_coop_host_tick", "network_coop_client_tick", "network_coop_handle_presentation", "network_coop_handle_events"):
require(hook + "(" in distributed and hook in coop, "Missing integrated co-op hook: " + hook)
config = read("port/linux/src/port_config.c")
require('"network.coop_enemies_mode"' in config and '"network.coop_enemies"' in config
and '"network.brokers_file"' in config, "Co-op/broker configuration is missing.")
require((source / "port/assets/network/brokers.txt").is_file(), "The candidate broker list is missing.")
require("saved games of builds before it no longer load" in read("port/linux/include/halo_port_capacity.h"),
"The candidate saved-game layout change needs explicit compatibility handling.")
def check_controls(source):
read = lambda path: (source / path).read_text(encoding="utf-8")
vr = read("port/linux/src/vr.c")
config = read("port/linux/src/port_config.c")
require(re.search(r'"vr\.movement",\s*_config_string,\s*"\\"head\\"",\s*"HALO_VR_MOVEMENT"', config),
"Head-directed walking is not the configurable default.")
movement = c_function(vr, "halo_vr_locomotion")
for guard in ("!vr.initialized", "!settings.head_movement", "!settings.controller_aim", "settings.gamepad_aim"):
require(guard in movement, "Missing unchanged-input mode guard: " + guard)
for guard in ("vr.views.focused", "vr.views.head.valid", "vr.origin_valid", "vr.aim_synced", "!vr.menus", "halo_vr_locomotion_orientation"):
require(guard in movement, "Missing valid-tracking locomotion guard: " + guard)
require("halo_vr_locomotion_project(" in movement, "The tested production movement math is not wired into VR input.")
player = read("source/game/player_control.c")
handling = c_function(player, "handle_one_player_input")
call = handling.find("halo_vr_locomotion(player->desired_angles.yaw")
require(call >= 0 and handling.find("player->desired_angles.yaw = vr_yaw") < call
< handling.find("player->throttle = input.throttle"),
"Movement must be transformed after aiming and before the player/action throttle is stored.")
guard = handling[max(0, call - 300):call]
for condition in ("local_player_index == 0", "unit->object.parent_object_index == NONE", "!director_inhibited_facing", "!cinematic_in_progress"):
require(condition in guard, "Missing local on-foot movement gate: " + condition)
gamepad = c_function(vr, "halo_vr_gamepad")
require("XINPUT_GAMEPAD_WHITE, buttons & VR_BUTTON_RIGHT_BUMPER" in gamepad
and "XINPUT_GAMEPAD_BLACK, buttons & VR_BUTTON_LEFT_BUMPER" in gamepad,
"The agreed Xbox bumper layout was not preserved.")
require("halo_vr_tutorial_look(" in c_function(player, "player_control_action_test_note"),
"The physical-head tutorial hook was not ported to the new co-op action test.")
c_function(vr, "halo_vr_tutorial_look")
require((source / "port/linux/src/vr_locomotion.h").is_file(), "The production locomotion header is missing.")
def check_stereo_glow_and_online_menu(source):
read = lambda path: (source / path).read_text(encoding="utf-8")
lights = read("source/rasterizer/xbox/rasterizer_xbox_lights.c")
draw = c_function(lights, "rasterizer_sun_glow_draw")
require("vr_render_sun_glow_begin(" in draw and "vr_render_sun_glow_end(" in draw,
"The per-eye sun-glow wrapper is not integrated into the actual renderer.")
require('config_real("vr.sun_glow_strength")' in draw
and 'rasterizer_sun_glow_draw_internal(flare, 1.0f)' in draw,
"The VR sun-strength option must preserve the original flat-screen composite.")
strength = c_function(lights, "rasterizer_vr_sun_glow_strength")
require("isfinite(configured)" in strength and "configured < 0.0" in strength
and "configured > 1.0" in strength and "return 0.5f" in strength,
"Sun-glow intensity must reject non-finite values and stay within 0–1.")
require('"vr.sun_glow_strength", _config_real, "0.5"' in read("port/linux/src/port_config.c"),
"The bounded half-strength sun-glow default is missing.")
renderer = read("port/linux/game/vr_render.c")
begin = c_function(renderer, "vr_render_sun_glow_begin")
for integration in ("vr_render.eye_cameras[eye]", "vr_render.eyes[eye]", "halo_vr_screen_space(1)", "halo_vr_draw_eye(eye)"):
require(integration in begin, "Missing per-eye glow state: " + integration)
require("halo_vr_screen_space(0)" in c_function(renderer, "vr_render_sun_glow_end"),
"Glow screen-space state is not restored.")
backend = c_function(read("port/linux/src/d3d8_gl.c"), "prepare_draw")
require(0 <= backend.find("key.sample_eye") < backend.find("fragment_shader_get(&key)"),
"Source-eye selection must participate in the actual fragment cache key.")
shader = read("port/linux/src/nv2a_psh.c")
require("key->multiview == XGPU_MULTIVIEW_NONE && key->sample_eye" in shader,
"A mono glow scratch target does not sample the selected stereo source eye.")
menu = read("port/linux/game/menu_functions.c")
route = c_function(menu, "cooperative_begin")
routes = {
"coop_host_lan": "cooperative_network_host(widget, event, controller, _multiplayer_mode_host_lan, widget_deleted)",
"coop_host_online": "cooperative_network_host(widget, event, controller, _multiplayer_mode_host_internet, widget_deleted)",
"coop_join_lan": "cooperative_network_join(widget, controller, _multiplayer_mode_lan, widget_deleted)",
"coop_join_online": "cooperative_network_join(widget, controller, _multiplayer_mode_server_browser, widget_deleted)",
"coop_join_invite": "cooperative_network_join(widget, controller, _multiplayer_mode_direct_link, widget_deleted)",
}
for name, call in routes.items():
require(re.search(r'if \(!strcmp\(widget->name, "' + name + r'"\)\)\s*return ' + re.escape(call) + r';', route),
"Missing LAN/Internet campaign dispatch: " + name)
require("cooperative_session_cleanup();" in route and '"network_screen"' in route,
"Leaving the campaign network chooser must clean up its pending session.")
single_player = c_function(menu, "cooperative_single_player")
for integration in ("player_spawn_count = 1", "player_ui_reset_single_player_local_player_controllers()",
"lobby_join_reset()", "player_ui_local_player_left_multiplayer_game(controller)"):
require(integration in single_player, "Missing one-player campaign reset: " + integration)
host = c_function(menu, "cooperative_network_host")
require('mode == _multiplayer_mode_host_internet && !config_boolean("network.online")' in host
and "multiplayer_host_mode(widget, event, controller, mode, TRUE, widget_deleted)" in host
and "cooperative_map_opening = TRUE" in host and "cooperative_map_opening = FALSE" in host,
"Campaign hosts must distinguish offline LAN from Internet and open campaign maps.")
join = c_function(menu, "cooperative_network_join")
require('mode != _multiplayer_mode_lan && !config_boolean("network.online")' in join
and "cooperative_network_game = TRUE" in join and "multiplayer.mode = mode" in join,
"Campaign joins must distinguish LAN, Internet browser and invite modes.")
handler = c_function(menu, "pc_menu_event_function_invoke")
require(re.search(r'else if \(!strcmp\(name, "port coop begin"\)\)\s*\{\s*return cooperative_begin\(', handler),
"Campaign dispatch must work in both flat and VR builds.")
require(re.search(r'else if \(!strcmp\(name, "port coop player 2 list initialize"\)[^{]+\{\s*return campaign_fail\(\);', handler)
and "player_spawn_count = 2;" not in menu and "vr_cooperative_begin" not in menu,
"Stale split-screen campaign routes must be rejected.")
for helper in ("lobby_add_player", "lobby_join_start", "lobby_player_choose", "preview_add"):
body = c_function(menu, helper)
require("lobby_campaign_single_player()" in body and "return campaign_local_player_block();" in body,
"Campaign must block a second local player: " + helper)
require("lobby_local_player_count() > 1" in c_function(menu, "map_list_choose")
and "cooperative_single_player()" in c_function(menu, "multiplayer_game_player"),
"Ordinary map/browser entry points must also use one local campaign player.")
require('!strcmp(name, "gamespy back handler") && cooperative_network_game &&' in handler
and "!global_network_game_server_get()" in handler,
"Browser cancellation must preserve a host when returning from Server Setup.")
asset = source / "port/assets/menus/ce/main_menu.multiplayer_type_select.coop.xml"
nodes = {node.attrib["name"].rsplit("/", 1)[-1]: node for node in ET.parse(asset).getroot()}
require("player_2_profile_screen" not in nodes and "player_2_profile_list" not in nodes,
"The campaign menu must not open a second-player profile screen.")
for event in ("a", "start"):
item = nodes["multiplayer_type_coop_item"].find('on[@event="' + event + '"]')
require(item is not None and item.get("run") == "port coop begin"
and item.get("open", "").endswith("coop/network_screen"),
"The campaign button must open the LAN/Internet chooser.")
captions = {"coop_host_lan": "HOST LOCAL (LAN)", "coop_join_lan": "JOIN LOCAL (LAN)",
"coop_host_online": "HOST ONLINE (INTERNET)", "coop_join_online": "JOIN ONLINE (INTERNET)",
"coop_join_invite": "JOIN INVITE LINK"}
for name, caption in captions.items():
require(name in nodes and nodes[name].get("text") == caption, "Missing campaign menu choice: " + caption)
for event in ("a", "start"):
item = nodes[name].find('on[@event="' + event + '"]')
require(item is not None and item.attrib == {"event": event, "run": "port coop begin"},
"Campaign choices must dispatch their named route without a static widget fallback.")
generator = read("tools/port_settings.py")
generated_coop = generator.split("def _coop() -> list:", 1)[1].split("def _item_options_extras", 1)[0]
require(all(name in generated_coop for name in captions) and "network_screen" in generated_coop
and "player_2_profile_screen" not in generated_coop,
"The menu generator must retain the network-only campaign choices.")
for script in ("tools/test_vr_sun_glow.py", "tools/test_online_coop_menu.py"):
require((source / script).is_file(), "Missing production C regression: " + script)
def check_nominal_projectile_reticle(source):
read = lambda path: (source / path).read_text(encoding="utf-8")
weapons = read("source/items/weapons.c")
firing = c_function(weapons, "trigger_create_projectiles")
require(re.search(r"trigger_adjust_projectile_ray\([^;]+&error,\s*FALSE,\s*NULL\)", firing),
"Real shots must retain the shared firing calculation with target recording.")
preview = c_function(weapons, "weapon_preview_projectile_ray")
require(re.search(r"trigger_adjust_projectile_ray\([^;]+&error,\s*TRUE,\s*pose\)", preview),
"The reticle must use the same pre-spread calculation in preview mode.")
require(not re.search(r"\b(?:random|projectile_new|weapon_trigger_fire)\w*\s*\(", preview),
"Reticle preview must not fire or consume shot randomness.")
assist = read("source/game/aim_assist.c")
require("player_aim_projectile_internal(player_index, position, direction, FALSE, pose)"
in c_function(assist, "player_preview_projectile_aim"),
"Reticle preview must suppress player targeting metadata writes.")
require("player_aim_projectile_internal(player_index, position, direction, TRUE, NULL)"
in c_function(assist, "player_aim_projectile"),
"Actual shots must preserve player targeting metadata writes.")
renderer = read("port/linux/game/vr_render.c")
crosshairs = c_function(renderer, "vr_render_crosshairs")
point = c_function(renderer, "vr_render_projectile_point")
require("vr_render_projectile_point(" in crosshairs and "vr_render_projectile_ray(" in point
and "_collision_test_for_projectiles_flags" in point,
"The rendered reticle must trace the game's nominal projectile ray.")
require("MAXIMUM_COLLISION_USER_STACK_DEPTH - 2" in point,
"The reticle must reserve capacity for UI, aim-assist and nested line-of-sight contexts.")
require("weapon_preview_projectile_ray(weapon_index, 0, player_index"
in c_function(renderer, "vr_render_projectile_ray"),
"The reticle must query the equipped primary weapon's actual firing ray.")
require("crosshair_inverse_distance" not in crosshairs and "CROSSHAIR_SMOOTHING" not in crosshairs,
"Reticle depth must not lag behind its current collision hit.")
require((source / "tools/test_vr_projectile_reticle.py").is_file(),
"The production projectile-reticle regression is missing.")
pose = c_function(renderer, "vr_render_projectile_pose")
for guard in ("local_player_get_player_index(0)", "_object_type_biped", "parent_object_index != NONE",
"gunner_object_index != NONE", "_director_perspective_first_person", "director_inhibited_facing(0)",
"aiming_velocity_maximum != 0.0f", "aiming_acceleration_maximum != 0.0f"):
require(guard in pose, "Missing guarded render-rate preview state: " + guard)
for integration in ("vr_render.view.hand_forward", "player_control_get_facing_direction(0",
"DEGREES_TO_RADIANS(85.0f)", "unit_clip_to_aiming_bounds(",
"render_interpolation_camera(0, observer_get_camera(0))"):
require(integration in pose, "Missing current-frame preview input: " + integration)
require("hand_position" not in pose and "camera_effects" not in pose,
"Preview origin must not add hand translation or apply camera effects again.")
ray = c_function(renderer, "vr_render_projectile_ray")
require("vr_render_projectile_pose(player_index, unit, &pose) ? &pose : NULL" in ray,
"The current-frame pose is not integrated into the primary reticle preview.")
units = read("source/units/units.c")
require("adjust_origin, use_aiming_vector, NULL, NULL" in c_function(units, "unit_adjust_projectile_ray"),
"Actual unit shots must not use presentation overrides.")
require("unit_adjust_projectile_ray_internal(" in c_function(units, "unit_preview_projectile_ray"),
"Unit preview must share the original camera/muzzle projection math.")
GRAPH_SMOKE = r'''
import io
from pathlib import Path
from types import SimpleNamespace
import sys
import re
sys.path.insert(0, str(Path.cwd()))
from tools import linux_arm64_build as build
from tools.ninja_syntax import Writer
headers = Path(".offline-check-headers")
(headers / "GLES3").mkdir(parents=True)
(headers / "GLES3/gl32.h").write_text("/* graph check only */")
build.GL_HEADERS = headers
build.is_linux_arm64 = lambda: True
build.fetch_third_party = lambda directory: None
sln = SimpleNamespace(port_release=True, port_vr=True, linux_arm64_cc="clang", port_pgo="off", port_lto="off")
output = io.StringIO()
build.generate_linux_arm64_build(Writer(output), sln)
graph = output.getvalue()
# pathlib follows the checker host even though this is a Linux build graph.
# Normalize only the inspected text; retain the actual generated graph below.
inspected_graph = re.sub(r"\$\r?\n[ \t]*", "", graph).replace("\\", "/")
for required in ("port/linux/game/network_coop.c", "port/linux/game/coop_scripts.c", "port/linux/game/coop_enemies.c", "port/linux/src/vr.c", "port/linux/arm64/host_vr.c", "-DHALO_VR=1", "-iquote port/linux/game", "build/linux_arm64/brokers.txt", "port/assets/network/brokers.txt"):
if required not in inspected_graph:
raise RuntimeError("The ARM64 graph omitted " + required)
if "-DHALO_GLES=1" in graph:
raise RuntimeError("The VR guest graph selected GLES instead of desktop OpenGL")
Path(".offline-check-build.ninja").write_text(graph, encoding="utf-8")
print("ARM64/OpenXR guest, host, co-op and broker graph generated without downloads or compilation.")
'''
def project_source(source, destination):
archive = git(source, "archive", "--format=tar", SOURCE_COMMIT).stdout
require(len(archive) <= 256 * 1024 * 1024, "The source archive exceeds the offline check limit.")
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as entries:
for entry in entries:
name = PurePosixPath(entry.name)
require(not name.is_absolute() and ".." not in name.parts and (entry.isdir() or entry.isfile()),
"The source projection contains an unexpected path or link.")
target = destination.joinpath(*name.parts)
if entry.isdir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with entries.extractfile(entry) as incoming, target.open("wb") as outgoing:
outgoing.write(incoming.read())
def check_source(source: Path):
source = source.resolve()
require(git(source, "rev-parse", "HEAD").stdout.decode().strip() == SOURCE_COMMIT,
"Use the exact pinned experimental engine commit; a moving branch is not accepted.")
patch = ROOT / "resources/frame-controls.patch"
changed = patch_files(patch.read_text(encoding="utf-8"))
dirty = git(source, "diff", "--name-only", "HEAD").stdout.decode().splitlines()
untracked = git(source, "ls-files", "--others", "--exclude-standard").stdout.decode().splitlines()
require(set(dirty + untracked) <= changed, "The engine checkout has changes outside the reviewed local patch.")
vanilla = git(source, "apply", "--check", str(patch), check=False).returncode == 0
patched = git(source, "apply", "--reverse", "--check", str(patch), check=False).returncode == 0
require(vanilla or patched, "The local patch neither applies cleanly nor exactly reverses in this checkout.")
require(not vanilla or not dirty + untracked, "A vanilla source input must have no local engine changes.")
# Always test a clean canonical projection, so a patched input is not trusted
# merely because its HEAD still names the selected revision.
(ROOT / "build").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="experimental-engine-check-", dir=ROOT / "build") as temporary:
projection = Path(temporary)
project_source(source, projection)
check_upstream(projection)
subprocess.run(["git", "init", "--quiet"], cwd=projection, check=True, capture_output=True, timeout=30)
for arguments in (("--check",), ()):
result = subprocess.run(["git", "apply", *arguments, str(patch)], cwd=projection,
capture_output=True, timeout=30)
require(result.returncode == 0, "The local patch does not apply to the canonical pinned engine source.")
if patched:
for path in changed:
require((source / path).read_bytes().replace(b"\r\n", b"\n")
== (projection / path).read_bytes().replace(b"\r\n", b"\n"),
"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)
result = subprocess.run([sys.executable, "-I", "-c", GRAPH_SMOKE], cwd=projection,
capture_output=True, text=True, timeout=30)
require(result.returncode == 0, "Offline ARM64 configuration generation failed: " + result.stderr[-2000:])
return {"ok": True, "sourceCommit": SOURCE_COMMIT, "workingTree": "vanilla" if vanilla else "patched",
"protocolVersion": 17, "rendererTwoPassBinding": True, "campaignCoopIntegration": True,
"headDirectedMovementIntegration": True, "xboxControlsPreserved": True,
"stereoSunGlowIntegration": True, "boundedSunGlowStrength": True, "vrInternetCampaignMenu": True,
"nominalProjectileReticleIntegration": True,
"renderRateProjectilePreviewIntegration": True,
"networkCampaignOnlyIntegration": True,
"offlineArm64NinjaGraph": True, "arm64LinkedBuild": False, "hardwareValidated": False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="Local exact-pinned vanilla or fully patched engine checkout")
arguments = parser.parse_args()
try:
result = check_source(arguments.source)
except (EngineCheckError, OSError, subprocess.SubprocessError) as error:
print(json.dumps({"ok": False, "error": str(error)}))
return 1
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
"""Prepare small Steam artwork renditions from separately obtained originals.
Requires Pillow only when regenerating these checked-in assets. The installer
does not download artwork or need Pillow at runtime. Original source attribution
is recorded in resources/artwork/README.md; originals are not duplicated in the
executable. No generative artwork is used.
"""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
from PIL import Image
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--hero-original", type=Path, required=True)
parser.add_argument("--logo-original", type=Path, required=True)
args = parser.parse_args()
originals = (
(args.hero_original,
"e5604c94c66d7a9740b247af4f10ccae674d4c53b44f01a0cec4433b2ca75b21"),
(args.logo_original,
"07afdc25f5fe0e11e966ff5e5af2f4adc0196c4ac89b0aeba7df99cc065ab76f"),
)
for path, expected in originals:
if sha256(path) != expected:
raise ValueError(f"Original source checksum differs: {path.name}")
repo = Path(__file__).resolve().parents[1]
destination = repo / "resources" / "artwork"
destination.mkdir(parents=True, exist_ok=True)
# Literal, unscaled crop of the original CE press image. This includes the
# Chief's helmet and upper body at right, leaving the tank at left for
# Steam's separate transparent title overlay.
with Image.open(args.hero_original) as source:
if source.size != (1920, 1440) or source.mode != "RGB":
raise ValueError("Expected the original 1920x1440 RGB Scorpion image")
hero = source.crop((0, 480, 1920, 1100))
hero.save(destination / "halo-ce-hero.jpg", quality=91, optimize=True,
progressive=True, subsampling=0)
with Image.open(args.logo_original) as source:
if source.size != (2459, 1061) or source.mode != "RGBA":
raise ValueError("Expected the original 2459x1061 RGBA CE title logo")
if source.getchannel("A").getextrema() != (0, 255):
raise ValueError("The title logo must retain its transparent background")
logo = source.resize((1000, 431), Image.Resampling.LANCZOS)
logo.save(destination / "halo-ce-logo.png", optimize=True)
# The library's original project drawing is independent of the Windows
# installer icon. Retain the checked-in rendition instead of copying a
# replacement installer icon into existing Steam library artwork.
if sha256(destination / "halo-ce-icon.png") != "184620356407e42a33877528c4f14b702df8b026415a99ed16976565fcfbce36":
raise ValueError("The existing project-drawn Steam shortcut icon differs")
for name in ("halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
path = destination / name
with Image.open(path) as image:
print(f"{sha256(path)} {name} {image.width}x{image.height} "
f"{image.mode} {path.stat().st_size} bytes")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,2 +1,2 @@
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
__version__ = "1.3.0"
__version__ = "1.4.0"
+19 -6
View File
@@ -119,6 +119,7 @@ def _cache_header(header: bytes, name: str, actual_size: int) -> str:
class _XboxImage:
def __init__(self, path: Path, cancel_event=None):
_cancel(cancel_event)
_regular_file(path)
self.path = path
self.image: BinaryIO = path.open("rb")
@@ -161,6 +162,7 @@ class _XboxImage:
ranges.append((offset, end))
self.image.seek(offset)
builds.add(_cache_header(self.image.read(SECTOR_SIZE), entry.name, entry.size))
_cancel(cancel_event)
self.info = _info(builds, sum(entry.size for entry in self.maps), self.partition)
except BaseException:
self.image.close()
@@ -170,6 +172,7 @@ class _XboxImage:
self.image.close()
def entries(self, sector: int, length: int) -> list[_Entry]:
_cancel(self.cancel_event)
if not 0 < length <= MAX_DIRECTORY_BYTES:
raise AssetError("Invalid Xbox directory length.")
offset = self.partition + sector * SECTOR_SIZE
@@ -224,15 +227,19 @@ def _info(builds: set[str], total: int, partition: int | None) -> ImageInfo:
return ImageInfo("original-xbox", next(iter(builds)), total, len(REQUIRED_MAPS), partition)
def inspect_image(path: str | Path) -> ImageInfo:
image = _XboxImage(Path(path))
def inspect_image(path: str | Path, cancel_event=None) -> ImageInfo:
"""Inspect image metadata, checking cancellation throughout validation."""
_cancel(cancel_event)
image = _XboxImage(Path(path), cancel_event)
try:
_cancel(cancel_event)
return image.info
finally:
image.close()
def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
def _maps_source(path: Path, cancel_event=None) -> tuple[Path, list[Path], ImageInfo]:
_cancel(cancel_event)
if not path.is_dir() or _is_link(path):
raise AssetError("Select a real maps directory, not a link.")
candidate = path / "maps"
@@ -242,6 +249,7 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
path = candidate
names: dict[str, Path] = {}
for count, entry in enumerate(path.iterdir(), 1):
_cancel(cancel_event)
if count > MAX_DIRECTORY_ENTRIES:
raise AssetError("The maps directory has too many entries.")
_safe_name(entry.name)
@@ -251,22 +259,27 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
if folded in names:
raise AssetError("Case-insensitive duplicate filenames in the maps directory.")
names[folded] = entry
_cancel(cancel_event)
missing = set(REQUIRED_MAPS) - names.keys()
if missing:
raise AssetError("Incomplete maps directory: missing " + ", ".join(sorted(missing)))
files = [names[name] for name in REQUIRED_MAPS]
builds, total = set(), 0
for file in files:
_cancel(cancel_event)
_regular_file(file)
with file.open("rb") as source:
size = os.fstat(source.fileno()).st_size
builds.add(_cache_header(source.read(SECTOR_SIZE), file.name, size))
total += size
_cancel(cancel_event)
return path, files, _info(builds, total, None)
def inspect_maps(path: str | Path) -> ImageInfo:
return _maps_source(Path(path))[2]
def inspect_maps(path: str | Path, cancel_event=None) -> ImageInfo:
"""Inspect extracted map headers without copying, with optional cancellation."""
_cancel(cancel_event)
return _maps_source(Path(path), cancel_event)[2]
def _new_staging(dest: Path) -> Path:
@@ -365,7 +378,7 @@ def copy_maps(source: str | Path, dest: str | Path, progress: Progress | None =
cancel_event=None) -> dict:
"""Prepare an already extracted retail maps folder using the same validation."""
_cancel(cancel_event)
source, source_files, info = _maps_source(Path(source))
source, source_files, info = _maps_source(Path(source), cancel_event)
dest = Path(dest).absolute()
stage = _new_staging(dest)
try:
+216 -55
View File
@@ -25,17 +25,26 @@ from .ui import (BG, SIDEBAR, SURFACE, TEXT, MUTED, BORDER,
SETUP_URL = "https://partner.steamgames.com/doc/steamhardware/steamframe/setup"
PLATFORM_TOOLS_URL = "https://developer.android.com/tools/releases/platform-tools"
SOURCE_URL = "https://github.com/OpenCommunityEdition/OpenCE/pull/85"
APP_TITLE = "Halo • Steam Frame VR Mod Installer"
EXPERIMENTAL_NOTICE = (
"LAN/online campaign, head-directed walking, and render-rate reticle.\n"
"Upgrades the native game; old saves are kept. Older checkpoints cannot be loaded.\n"
"Campaign uses one player per machine; co-op partners need matching builds."
)
CONTENT_DISCLOSURE = "No retail game executable, ISO, maps, product keys, or soundtrack is provided."
ARTWORK_CREDIT = "Halo artwork is included. Halo artwork and trademarks belong to Microsoft."
XBOX_REVISION_NOTE = "Original Xbox USA Rev 2 validated; other retail revisions are checked automatically."
GAME_CLOSE_NOTE = "Save and close running games before changing Halo files. Keep Steam Home and SteamVR running."
STEAM_ADD_NOTE = ("In Steam, choose Add a Game → Add a Non-Steam Game and select the native halo executable. "
"Use the displayed launch options, then click Add to Steam again to retry available library setup.")
STEAM_ART_NOTE = "Use Steam's custom artwork controls to add Halo box art, or click Add to Steam again to retry."
STEAM_ART_NOTE = "Use Steam's custom artwork controls to add Halo library art, or click Add to Steam again to retry."
STEAM_NOTES_NOTE = "Use Steam Notes to add the supplied game description, or click Add to Steam again to retry."
STEAM_REMOVE_NOTE = ("Use Steam's Remove Non-Steam Game option for Halo: Combat Evolved VR (Native). "
"Retry when library cleanup is available or Steam is already closed.")
CONTROLS = (
"A Jump / accept B Melee / back X Reload / use Y Change weapon\n"
"RT Fire LT Grenade LB Change grenade RB Flashlight\n"
"Left stick Move / click to crouch Right stick Turn / click to zoom\n"
"Left stick Move by head direction / click to crouch Right stick Turn / click to zoom\n"
"Menu Pause View Scoreboard Both grips Recenter"
)
@@ -49,7 +58,7 @@ class App(tk.Tk):
except (AttributeError, OSError):
pass
super().__init__()
self.title("Halo • Steam Frame Setup")
self.title(APP_TITLE)
width = min(1040, max(900, self.winfo_screenwidth() - 100))
height = min(720, max(620, self.winfo_screenheight() - 100))
self.geometry(f"{width}x{height}")
@@ -101,6 +110,8 @@ class App(tk.Tk):
self.entries = []
self.checks = []
self.last_result = None
self._asset_generation = 0
self._inspection_cancel = None
self._build()
self.source.trace_add("write", lambda *args:self._source_changed())
self.mode.trace_add("write", lambda *args:self._mode_changed())
@@ -164,7 +175,7 @@ class App(tk.Tk):
power_orb(icon, 85, 37, 29)
self._label(brand, "HALO", 25, "bold", color=ACCENT,
background=SIDEBAR).pack(anchor="w", pady=(6, 0))
self._label(brand, "STEAM FRAME SETUP", 9, "bold", color=TEXT,
self._label(brand, "STEAM FRAME VR MOD", 9, "bold", color=TEXT,
background=SIDEBAR).pack(anchor="w", pady=(2, 0))
self._label(brand, f"Version {__version__}", 9, color=MUTED, background=SIDEBAR).pack(anchor="w", pady=(9, 0))
self._label(sidebar, "INSTALLATION", 8, "bold", MUTED, SIDEBAR).pack(anchor="w", padx=24, pady=(26, 8))
@@ -183,7 +194,7 @@ class App(tk.Tk):
background=SIDEBAR, subtle=True, width=170).pack(anchor="w", pady=(2, 0))
self._label(help_area, textvariable=self.music_status, size=8, color=MUTED,
background=SIDEBAR, wraplength=170).pack(anchor="w", padx=9, pady=(4, 10))
self._label(help_area, "ORIGINAL XBOX · NATIVE VR", 8, color=MUTED,
self._label(help_area, "COMMUNITY MOD INSTALLER", 8, color=MUTED,
background=SIDEBAR).pack(anchor="w", padx=10)
main = tk.Frame(self, bg=BG)
@@ -212,7 +223,7 @@ class App(tk.Tk):
self.page_title = self._label(header, "GAME DATA", 24, "bold")
self.page_title.pack(anchor="w", pady=(6, 4))
self.page_subtitle = self._label(header,
"Install, repair, or uninstall native Halo VR.", 10, color=MUTED)
"Install, repair, or uninstall the community VR mod.", 10, color=MUTED)
self.page_subtitle.pack(anchor="w")
page_host = tk.Frame(main, bg=BG)
page_host.grid(row=1, column=0, sticky="nsew", padx=32)
@@ -310,9 +321,9 @@ class App(tk.Tk):
choices.columnconfigure((0, 1, 2), weight=1, uniform="operations")
self.mode_choices = []
for index, title, subtitle, value in (
(0, "Install Halo VR", "New native game", "install"),
(1, "Repair Halo VR", "Refresh. Keep saves.", "repair"),
(2, "Uninstall Halo VR", "Remove native game", "uninstall")):
(0, "Install", "Install or upgrade", "install"),
(1, "Repair", "Refresh files", "repair"),
(2, "Uninstall", "Remove game", "uninstall")):
choice = Choice(choices, title, subtitle, self.mode, value, self._mode_changed)
choice.grid(row=0, column=index, sticky="ew",
padx=(0, 6) if index == 0 else (6, 0) if index == 2 else (6, 6))
@@ -361,10 +372,12 @@ class App(tk.Tk):
consent = self._card(body)
self.data_consent = consent.master
self.data_authorization = self._check(consent,
"I am authorized to use this original Xbox game data.", self.authorized)
"I am authorized to use my original Xbox game data with this mod.", self.authorized)
self.data_authorization.pack(anchor="w")
self._label(consent, "Use your own game data. PC and Xbox 360 editions are not supported.",
9, color=MUTED, background=SURFACE, wraplength=630).pack(anchor="w", pady=(2, 0))
self.data_disclosure = self._label(consent,
CONTENT_DISCLOSURE + "\nUse your own authorized Xbox data. Included Halo artwork: Microsoft.",
9, color=MUTED, background=SURFACE, wraplength=630)
self.data_disclosure.pack(anchor="w", pady=(2, 0))
uninstall = self._card(body, "REMOVE NATIVE HALO VR")
self.uninstall_card = uninstall.master
self._label(uninstall,
@@ -494,6 +507,8 @@ class App(tk.Tk):
self.retry_button.pack(anchor="w", pady=(11, 0))
self.retry_button.configure(state="disabled")
self.retry_button.pack_forget()
self.notes_button = self._button(self.progress_card, "View game description", self._show_steam_note)
self.notes_button.pack_forget()
self.install_note = self._label(body,
"Keep this window open during setup. Allow 12 GB free on the Frame.\nThe first native build can take a while.",
9, color=MUTED, wraplength=620)
@@ -527,6 +542,13 @@ class App(tk.Tk):
pass
def _source_changed(self):
self._asset_generation += 1
if self._inspection_cancel is not None:
self._inspection_cancel.set()
self._inspection_cancel = None
if self.busy and getattr(self, "operation", None) == "data":
self._set_busy(False)
self.status.set("Game data selection changed. Choose an original Xbox image or maps folder to continue.")
source = self.source.get()
self.source_label.set(Path(source).name if source else "No game data selected")
if not source:
@@ -540,11 +562,13 @@ class App(tk.Tk):
if uninstall:
self.data_panel.pack_forget()
self.data_consent.pack_forget()
else:
self.data_panel.pack(fill="x", pady=(0, 10))
self.data_consent.pack(fill="x", pady=(0, 10))
if uninstall:
self.uninstall_card.pack(fill="x", pady=(0, 10))
else:
self.uninstall_card.pack_forget()
self.data_panel.pack(fill="x", pady=(0, 10))
self.data_consent.pack(fill="x", pady=(0, 10))
self.data_title.configure(text="Replacement game data (optional)" if repair else "Your original Xbox game data")
self.data_note.configure(text="Use valid installed Xbox maps, or choose data to replace them." if repair
else "Choose a disc image or an extracted maps folder.")
@@ -556,6 +580,7 @@ class App(tk.Tk):
self._update_navigation()
if uninstall:
self.retry_button.pack_forget()
self.notes_button.pack_forget()
self._show_page(self.current_page)
def _update_summary(self):
@@ -575,7 +600,7 @@ class App(tk.Tk):
else "Remove saves contained in the native game folder") if uninstall else
"A Jump · B Melee · X Reload / use · Y Change weapon")
self.summary_note.set("The native game files and its Steam entry will be removed." if uninstall else
"Motion aiming and the full Xbox-style layout are included.")
"Motion aiming and Xbox-style controls are included. Steam artwork and game Notes are added automatically.")
def _show_page(self, index):
if not 0 <= index < len(self.pages):
@@ -586,12 +611,13 @@ class App(tk.Tk):
self.canvas = self.pages[index].canvas
self.step_count.set(f"SETUP / 0{index + 1} OF 03")
uninstall = self.mode.get() == "uninstall"
titles = ("UNINSTALL HALO VR" if uninstall else "GAME DATA", "CONNECT FRAME", "UNINSTALL HALO VR" if uninstall else "INSTALL HALO VR")
titles = ("UNINSTALL HALO VR" if uninstall else "GAME DATA", "CONNECT FRAME",
"UNINSTALL HALO VR" if uninstall else "INSTALL HALO VR")
subtitles = ("Choose your save preference before removing the native game." if uninstall else
"Install, repair, or uninstall native Halo VR.",
"Upgrades the native game; older checkpoints cannot load.\nCo-op uses one player per machine and matching builds.",
"Prepare SSH, then connect to your Steam Frame.",
"Review the native game and saves before removing them." if uninstall else
"Native VR. Xbox controls. Your Steam library.")
"Community VR mod. Xbox controls. Your Steam library.")
self.page_title.configure(text=titles[index])
self.page_subtitle.configure(text=subtitles[index])
self._update_summary()
@@ -631,7 +657,7 @@ class App(tk.Tk):
messagebox.showinfo("Close games first", GAME_CLOSE_NOTE, parent=self)
return
self.status.set("Setup removes the native game and its Steam entry, with your selected save preference." if self.mode.get() == "uninstall" else
"Setup builds the native game, applies Xbox controls, and adds Halo to Steam.")
"Setup builds the native game, applies Xbox controls, and automatically adds Steam artwork and game Notes.")
self._show_page(2)
else:
if self.mode.get() == "uninstall":
@@ -680,6 +706,7 @@ class App(tk.Tk):
# The review summary has already served its purpose before installation.
self.install_summary.pack_forget()
self.retry_button.pack_forget()
self.notes_button.pack_forget()
self.pages[2].canvas.yview_moveto(0)
self._show_activity()
@@ -715,6 +742,8 @@ class App(tk.Tk):
self.status.set("Finishing removal; please wait until setup disconnects.")
return
self.cancel.set()
if self._inspection_cancel is not None:
self._inspection_cancel.set()
self.phase.set("Cancelling setup…")
self.status.set("Keep this window open until setup stops. Check activity for the uninstall result." if getattr(self, "operation", None) == "uninstall"
else "Keep this window open until setup stops. Existing saves will be kept.")
@@ -761,8 +790,40 @@ class App(tk.Tk):
self._label(body, "Hold the left grip near the foregrip for two-handed aiming.",
9, color=MUTED).pack(anchor="w", pady=(0, 8))
def _show_steam_note(self):
if self.busy or self.last_result is None:
return
notes = self.last_result.steam.get("notes", {})
content = self._redact(notes.get("content", ""))
if not content:
return
title = self._redact(notes.get("title", "Halo CE VR — About & controls"))
_, body = self._dialog("Game description", "Add this description in Steam Notes for Halo.")
card = self._card(body, "NOTE TITLE")
title_entry = ttk.Entry(card)
title_entry.insert(0, title)
title_entry.configure(state="readonly")
title_entry.pack(fill="x")
card = self._card(body, "NOTE CONTENT")
content_text = tk.Text(card, height=12, wrap="word", bg=INSET, fg=TEXT,
font=("Segoe UI", 10), relief="flat", padx=10, pady=10)
content_text.insert("1.0", content)
content_text.configure(state="disabled")
content_text.pack(fill="both", expand=True)
def copy_content():
self.clipboard_clear()
self.clipboard_append(content)
Button(body, "Copy description", copy_content, primary=True).pack(anchor="w", pady=(0, 8))
self._label(body, self._redact(notes.get("message", "")) or STEAM_NOTES_NOTE,
9, color=MUTED, wraplength=530).pack(anchor="w", pady=(0, 8))
def _show_help(self):
_, body = self._dialog("Help & sources", "Setup requirements and project references.")
_, body = self._dialog("Help & sources", "Community VR mod installer requirements and sources.")
disclosure = self._card(body, "COMMUNITY MOD INSTALLER")
self._label(disclosure,
"This installer builds the pinned Steam Frame VR port from public source using your authorized Xbox data.\n"
+ CONTENT_DISCLOSURE + "\n" + ARTWORK_CREDIT + " Full credits are in the project documentation.",
10, background=SURFACE, wraplength=530).pack(anchor="w")
card = self._card(body, "BEFORE YOU START")
self._label(card,
"• Use original Xbox Halo CE data you are authorized to use.\n"
@@ -777,7 +838,8 @@ class App(tk.Tk):
"• Save and close games. Steam Home and SteamVR stay running.\n"
"• Install SteamVR and allow at least 12 GB free on your Frame.\n"
"• Repair refreshes the program and controls while preserving saves.\n"
"• Online play uses the native port's own multiplayer protocol.",
"• Install and repair automatically add Steam artwork and a game description in Steam Notes.\n"
"• Online play uses the native port's own multiplayer protocol.\n\n" + EXPERIMENTAL_NOTICE,
10, background=SURFACE, wraplength=530).pack(anchor="w")
links = self._card(body, "Project references")
Button(links, "Steam Frame SSH guide", lambda:webbrowser.open(SETUP_URL),
@@ -787,7 +849,7 @@ class App(tk.Tk):
Button(links, "Native VR source", lambda:webbrowser.open(SOURCE_URL),
subtle=True, background=SURFACE).pack(anchor="w")
Button(links, "Installer project & documentation",
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloCE-SteamFrame"),
lambda:webbrowser.open("https://github.com/ClassicWasTaken/HaloSteamFrameMod"),
subtle=True, background=SURFACE).pack(anchor="w")
def _redact(self, text):
@@ -815,21 +877,51 @@ class App(tk.Tk):
self.adb_path.set(selected)
def _inspect(self, selected):
if self.busy:
if self.busy and getattr(self, "operation", None) != "data":
return
selected = Path(selected)
if self._inspection_cancel is not None:
self._inspection_cancel.set()
self._asset_generation += 1
generation = self._asset_generation
cancel = self._inspection_cancel = threading.Event()
self.operation = "data"
self.asset_status.set("Checking original Xbox game data…")
self.status.set("Checking the selected Xbox game data…")
self.music.load(None)
self._set_busy(True)
def worker():
summary = error = None
try:
info = inspect_maps(selected) if selected.is_dir() else inspect_image(selected)
self.events.put(("asset", str(selected), f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"))
info = (inspect_maps(selected, cancel_event=cancel) if selected.is_dir()
else inspect_image(selected, cancel_event=cancel))
summary = f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"
except Exception as exc:
self.events.put(("error", str(exc)))
error = str(exc)
finally:
self.events.put(("idle",))
# A single generation-bound result cannot clear the busy state
# or report an error for a different, newer selection.
self.events.put(("asset", generation, cancel, str(selected), summary, error))
threading.Thread(target=worker, daemon=True).start()
def _finish_asset_check(self, generation, cancel, selected, summary, error):
if generation != self._asset_generation or cancel is not self._inspection_cancel:
return
self._inspection_cancel = None
self._set_busy(False)
if cancel.is_set() or self.cancel.is_set():
self.asset_status.set("Game data check cancelled. Choose an original Xbox image or maps folder to retry.")
self.status.set("Game data check cancelled. Your previous selection was kept.")
self.phase.set("Game data check cancelled")
return
if error is not None:
self._show_setup_error(error)
return
self.source.set(selected)
self.asset_status.set(summary)
self.music.load(Path(selected))
self.status.set("Game data validated. Connect your Frame next.")
def _settings(self):
usb = self.transport.get() == "usb"
if usb:
@@ -870,12 +962,24 @@ class App(tk.Tk):
choice.configure(state="disabled" if value else "normal")
for choice in self.transport_choices:
choice.configure(state="disabled" if value else "normal")
allow_steam_retry = self.last_result is not None and self.mode.get() != "uninstall"
steam = self.last_result.steam if self.last_result is not None else {}
pending_steam = (bool(self.last_result and self.last_result.requires_manual_steam_step)
or steam.get("artwork", {}).get("status") == "manual"
or steam.get("artwork", {}).get("icon", {}).get("status") == "manual"
or steam.get("notes", {}).get("status") == "manual")
allow_steam_retry = self.last_result is not None and pending_steam and self.mode.get() != "uninstall"
self.retry_button.configure(state="normal" if not value and allow_steam_retry else "disabled")
if allow_steam_retry:
self.retry_button.pack(anchor="w", pady=(11, 0))
else:
self.retry_button.pack_forget()
notes_pending = (allow_steam_retry and self.last_result.steam.get("notes", {}).get("status") == "manual"
and bool(self.last_result.steam.get("notes", {}).get("content")))
self.notes_button.configure(state="normal" if not value and notes_pending else "disabled")
if notes_pending:
self.notes_button.pack(anchor="w", pady=(8, 0))
else:
self.notes_button.pack_forget()
self.cancel_button.configure(state="normal" if value else "disabled")
if value:
self.cancel.clear()
@@ -963,7 +1067,8 @@ class App(tk.Tk):
result = run(settings, None, report, self.cancel)
self.events.put(("complete", result))
return
info = inspect_maps(source) if source.is_dir() else inspect_image(source)
info = (inspect_maps(source, cancel_event=self.cancel) if source.is_dir()
else inspect_image(source, cancel_event=self.cancel))
if shutil.disk_usage(tempfile.gettempdir()).free < info.estimated_bytes + 512 * 1024**2:
raise ValueError("Not enough free temporary space on this computer to prepare the maps.")
with tempfile.TemporaryDirectory(prefix="halo-frame-") as temporary:
@@ -984,6 +1089,8 @@ class App(tk.Tk):
threading.Thread(target=worker, daemon=True).start()
def _retry_steam(self):
if self.busy:
return
if not self.steam_closed.get():
messagebox.showinfo("Close games first", GAME_CLOSE_NOTE, parent=self)
return
@@ -993,13 +1100,13 @@ class App(tk.Tk):
messagebox.showerror("Connection details", str(exc), parent=self)
return
self.password_to_redact = settings.password
self.operation = "install"
self.operation = "library"
self.installing = True
self._show_page(2)
self._set_busy(True)
self._begin_setup_progress()
self.phase.set("Adding Halo to Steam")
self.status.set("Checking the installed game and Steam library…")
self.phase.set("Updating Halo's Steam info")
self.status.set("Checking the installed game, artwork, and game notes…")
def worker():
from .install import Installer
try:
@@ -1072,9 +1179,7 @@ class App(tk.Tk):
event = self.events.get_nowait()
kind = event[0]
if kind == "asset":
self.source.set(event[1]); self.asset_status.set(event[2])
self.music.load(Path(event[1]))
self.status.set("Game data validated. Connect your Frame next.")
self._finish_asset_check(*event[1:])
elif kind == "music":
self.music_status.set(self._redact(event[1]))
elif kind == "fingerprint":
@@ -1112,45 +1217,95 @@ class App(tk.Tk):
elif kind == "complete":
result = event[1]
self.last_result = result
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
cleanup_pending = bool(cleanup_warning)
library_update = getattr(self, "operation", None) == "library"
action = "repaired" if getattr(result, "repaired", False) else "installed"
done = f"Native Halo VR is {action}. Open Steam and launch Halo: Combat Evolved VR (Native)."
done = ("Halo's Steam library info has been updated. Your native game and saves were kept." if library_update else
f"Native Halo VR is {action}. Open Steam and launch Halo: Combat Evolved VR (Native).")
if getattr(result, "backup_path", None):
self._append("Program-file backup: " + result.backup_path)
if result.requires_manual_steam_step:
done = "Native Halo VR is installed; its Steam entry needs one more step.\n" + result.steam.get("reason", "") + "\n" + result.steam.get("instructions", STEAM_ADD_NOTE)
done = ("Halo's Steam entry needs one more step. Your native game was kept.\n" if library_update else
"Native Halo VR is installed; its Steam entry needs one more step.\n")
done += result.steam.get("reason", "") + "\n" + result.steam.get("instructions", STEAM_ADD_NOTE)
self._append("Executable: " + result.game_path + "/halo")
self._append("Launch options: " + result.steam.get("launchOptions", "SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%"))
artwork = result.steam.get("artwork", {})
artwork_pending = artwork.get("status") == "manual"
self.progress_state.finish(pending=result.requires_manual_steam_step or artwork_pending)
icon = artwork.get("icon", {})
icon_pending = icon.get("status") == "manual"
notes = result.steam.get("notes", {})
if notes:
# The copyable fallback remains safe after the worker clears
# its password and the display-redaction token.
notes = {key: self._redact(value) if isinstance(value, str) else value
for key, value in notes.items()}
result.steam["notes"] = notes
notes_pending = notes.get("status") == "manual"
info_pending = artwork_pending or icon_pending or notes_pending
if library_update and info_pending and not result.requires_manual_steam_step:
done = "Native Halo VR was kept; some Steam library info needs one more step. Your game and saves are unchanged."
self.progress_state.finish(pending=result.requires_manual_steam_step or info_pending or cleanup_pending)
self._display_progress()
if artwork_pending:
done += "\n\nHalo box art needs one more step.\n" + artwork.get("reason", "")
done += "\n\nHalo library art needs one more step.\n" + artwork.get("reason", "")
done += "\n" + artwork.get("instructions", STEAM_ART_NOTE)
if icon_pending:
done += "\n\nHalo's library icon needs one more step.\n" + icon.get("reason", "")
done += "\n" + icon.get("instructions", "Click Add to Steam again to retry the library icon.")
if notes_pending:
done += "\n\nThe game description in Steam Notes needs one more step.\n"
done += notes.get("message", STEAM_NOTES_NOTE)
if notes.get("content"):
done += "\nClick View game description to copy the note."
elif notes.get("status") == "custom":
done += "\n\nYour existing custom game note was kept."
if cleanup_pending:
done += "\n\nThe game operation finished; connection cleanup needs attention.\n" + cleanup_warning
if info_pending:
self.retry_button.pack(anchor="w", pady=(11, 0))
done = self._redact(done)
self.phase.set("One more step in Steam" if result.requires_manual_steam_step
else "Ready to play · box art pending" if artwork_pending
else "Ready to play · Steam info pending" if info_pending
else "Connection cleanup needs attention" if cleanup_pending
else "Steam info updated" if library_update
else "You're ready to play")
self.status.set(done)
self._append(done)
messagebox.showinfo("Setup complete", done + "\n\nXbox controls and motion aiming are enabled. The native port uses its own multiplayer protocol; legacy PC servers and PC saves are incompatible.", parent=self)
extra = "" if library_update else "\n\nXbox controls and motion aiming are enabled. The native port uses its own multiplayer protocol; legacy PC servers and PC saves are incompatible."
messagebox.showinfo("Connection cleanup needs attention" if cleanup_pending
else "Steam info updated" if library_update and not info_pending and not result.requires_manual_steam_step
else "Steam info needs attention" if library_update else "Setup complete", done + extra, parent=self)
elif kind == "uninstall_complete":
result = event[1]
self.last_result = None
self.last_uninstall_result = result
pending = bool(getattr(result, "requires_manual_steam_step", False))
steam_pending = bool(getattr(result, "requires_manual_steam_step", False))
removal_pending = bool(getattr(result, "removal_pending", False))
cleanup_warning = self._redact(getattr(result, "cleanup_warning", None) or "")
pending = steam_pending or removal_pending or bool(cleanup_warning)
self.progress_state.finish(pending=pending)
self._display_progress()
if pending:
if steam_pending:
done = "Uninstall needs one more step in Steam.\n" + result.steam.get("reason", "")
done += "\n" + result.steam.get("instructions", STEAM_REMOVE_NOTE)
elif removal_pending:
done = "Some files from an earlier uninstall were kept for manual review. Removal is not complete."
elif result.already_absent:
done = "Native Halo VR was already absent. Uninstall checks are complete."
else:
done = "Native Halo VR has been uninstalled from your Frame."
if result.saved_backup_path:
done += "\n\nCampaign save backup: " + result.saved_backup_path
if getattr(result, "removal_warning", None):
done += "\n\n" + result.removal_warning
for backup in getattr(result, "recovered_save_backup_paths", ()):
done += "\nEarlier campaign save backup: " + backup
for retained in getattr(result, "retained_quarantine_paths", ()):
done += "\nPreserved uninstall folder: " + retained
if cleanup_warning:
done += "\n\nConnection cleanup needs attention.\n" + cleanup_warning
done = self._redact(done)
self.phase.set("Uninstall needs attention" if pending else "Halo VR uninstalled")
self.status.set(done)
@@ -1158,18 +1313,7 @@ class App(tk.Tk):
self.retry_button.pack_forget()
messagebox.showinfo("Uninstall needs attention" if pending else "Uninstall complete", done, parent=self)
elif kind == "error":
text = self._redact(event[1])
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":
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
self._append(text)
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
if len(summary) > 500 or "\n" in text:
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
messagebox.showerror("Setup needs attention", summary, parent=self)
self._show_setup_error(event[1])
elif kind == "clear_password":
self.password.set(""); self.password_to_redact = ""
elif kind == "idle":
@@ -1182,6 +1326,20 @@ class App(tk.Tk):
pass
self.after(10 if not self.events.empty() else 80, self._drain)
def _show_setup_error(self, text):
text = self._redact(text)
self.status.set("Setup stopped. See the message below; you can retry.")
self.phase.set("Let's try that again")
if getattr(self, "operation", None) == "connection":
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
elif getattr(self, "operation", None) == "data":
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
self._append(text)
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
if len(summary) > 500 or "\n" in text:
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
messagebox.showerror("Setup needs attention", summary, parent=self)
def _remember(self, host, port, value):
identity = host if port is None or host.startswith("usb:") else f"{host}:{port}"
self.fingerprints[identity] = value
@@ -1214,6 +1372,9 @@ class App(tk.Tk):
self.destroy()
def destroy(self):
self._asset_generation += 1
if self._inspection_cancel is not None:
self._inspection_cancel.set()
music = getattr(self, "music", None)
if music is not None:
music.close()
+205 -42
View File
@@ -9,13 +9,13 @@ import sys
import tempfile
import threading
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Callable
from .ssh import CancelledError, Settings, SSHConnection, SSHError
from .ssh import CancelledError, RemoteTimeoutError, Settings, SSHConnection, SSHError
SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
EXPECTED_MAPS = frozenset(name + ".map" for name in (
"a10", "a30", "a50", "b30", "b40", "c10", "c20", "c40", "d20", "d40",
"beavercreek", "bloodgulch", "boardingaction", "carousel", "chillout", "damnation",
@@ -98,6 +98,11 @@ class InstallResult:
source_commit: str = SOURCE_COMMIT
repaired: bool = False
backup_path: str | None = None
cleanup_warning: str | None = None
@property
def connection_cleanup_pending(self) -> bool:
return bool(self.cleanup_warning)
@property
def steam_appid(self) -> int | None:
@@ -116,12 +121,76 @@ class UninstallResult:
saved_backup_path: str | None
steam: dict
host_fingerprint: str | None
cleanup_warning: str | None = None
removal_pending: bool = False
removal_warning: str | None = None
recovered_save_backup_paths: tuple[str, ...] = ()
retained_quarantine_paths: tuple[str, ...] = ()
@property
def connection_cleanup_pending(self) -> bool:
return bool(self.cleanup_warning)
@property
def requires_manual_steam_step(self) -> bool:
return self.steam.get("status") not in ("removed", "already-absent")
def cleanup_activity(progress: ProgressCallback, message: str) -> None:
try:
progress("detail", message, None)
except Exception:
pass # A display callback cannot replace the operation outcome.
def close_connection(connection, progress: ProgressCallback) -> str | None:
"""Preserve the operation result, while keeping required cleanup visible."""
try:
connection.close()
except Exception as error:
settings = getattr(connection, "settings", None)
warning = (str(error)[:4000] if isinstance(error, SSHError) else
"Setup could not verify that its connection finished closing.")
password = getattr(settings, "password", "")
if password:
warning = warning.replace(password, "[redacted]")
action = ("Unplug the USB cable to end setup's temporary connection before retrying."
if getattr(settings, "transport", None) == "usb" else
"Close this installer before retrying the connection.")
warning = warning + "\n" + action
cleanup_activity(progress, warning)
return warning
return None
def uninstall_history(data: dict) -> tuple[tuple[str, ...], tuple[str, ...], str | None]:
"""Validate direct owned-path references before showing remote recovery history."""
root = "/home/steamos/Games/"
backups, retained = [], []
for key, target in (("recoveredSaveBackupPaths", backups),
("retainedQuarantinePaths", retained)):
values = data.get(key, [])
if not isinstance(values, list) or len(values) > 64:
raise SSHError("The Frame returned an invalid uninstall recovery history.")
for value in values:
if (not isinstance(value, str) or len(value) > 512
or len(value.rsplit("/", 1)[-1]) > 255 or value in target):
raise SSHError("The Frame returned an invalid uninstall recovery history.")
if key == "recoveredSaveBackupPaths":
valid = re.fullmatch(re.escape(root) + r"HaloCENativeVR-saves-[a-f0-9]{32}", value)
else:
prefix = root + ".HaloCENativeVR-uninstall-"
suffix = value[len(prefix):] if value.startswith(prefix) else ""
valid = suffix and not any(c in "/\\" or ord(c) < 32 or ord(c) == 127 for c in suffix)
if not valid:
raise SSHError("The Frame returned an invalid uninstall recovery history.")
target.append(value)
warning = data.get("warning")
if warning is not None and (not isinstance(warning, str) or not 0 < len(warning) <= 4096):
raise SSHError("The Frame returned an invalid uninstall warning.")
return tuple(backups), tuple(retained), warning
class Installer:
def __init__(self, connection_factory=SSHConnection, resource_dir: Path | None = None):
self.connection_factory = connection_factory
@@ -137,7 +206,10 @@ class Installer:
run_identifier = uuid.uuid4().hex
remote_helper: str | None = None
prepared = False
disconnected = False
cleanup_attempted = False
cancel_attempted = False
cancellation_warning = None
cancellation_unconfirmed = False
def check_cancel():
if cancel_event.is_set():
@@ -149,8 +221,55 @@ class Installer:
progress(*activity)
def cancel_remote():
if remote_helper is not None and prepared:
connection.run(["python3", remote_helper, "cancel", "--run-id", run_identifier], timeout=45)
nonlocal cancel_attempted, cancellation_warning, cancellation_unconfirmed
if remote_helper is None or not prepared or cancel_attempted:
return
cancel_attempted = True
argv = ["python3", remote_helper, "cancel", "--run-id", run_identifier]
def request(target):
response = parse_result(target.run(argv, timeout=45))
if response.get("cancelled") is not True:
raise SSHError("The Frame did not confirm this installer run's cancellation.")
try:
active = getattr(connection, "is_active", lambda: True)()
if active:
request(connection)
cleanup_activity(progress, "Remote build cancellation confirmed.")
return
except Exception:
pass
recovery = recovery_settings = None
try:
approved = (connection.host_fingerprint or settings.known_host_fingerprint
or settings.known_host_fingerprints.get(settings.host_identity))
if not isinstance(approved, str) or not approved.startswith("SHA256:"):
raise SSHError("No approved host fingerprint is available for recovery.")
recovery_settings = replace(settings, known_host_fingerprint=approved,
known_host_fingerprints={}, accept_host_key=None)
recovery = self.connection_factory(recovery_settings)
if recovery is connection:
recovery = None
raise SSHError("A fresh recovery connection could not be created.")
cleanup_activity(progress, "Reconnecting once to stop this installer run, using the approved SSH host key...")
recovery.connect(timeout=10)
request(recovery)
cleanup_activity(progress, "Remote build cancellation confirmed.")
except Exception:
cancellation_unconfirmed = True
cancellation_warning = (
"Remote stopping could not be confirmed. The installer build may still be running on the Frame. "
"Restore the connection and stop this run before retrying installation or uninstalling.\n"
"In the Frame's terminal, run: " + " ".join(argv))
cleanup_activity(progress, cancellation_warning)
finally:
if recovery is not None:
warning = close_connection(recovery, progress)
if warning:
cancellation_warning = (cancellation_warning + "\n\n" if cancellation_warning else "") + warning
if recovery_settings is not None:
recovery_settings.password = ""
repair = settings.reinstall_existing and not registration_only
@@ -172,9 +291,10 @@ class Installer:
progress("connect", "Connecting securely to your Steam Frame...", None)
connection.connect()
check_cancel()
progress("preflight", "Checking ARM64 SteamOS, SteamVR, Podman and free space...", None)
progress("preflight", "Checking the installed native game and Steam account..." if registration_only
else "Checking ARM64 SteamOS, SteamVR, Podman and free space...", None)
source = (self.resource_dir / "remote_install.py").read_text(encoding="utf-8")
preflight_args = ["python3", "-c", source, "preflight"]
preflight_args = ["python3", "-c", source, "preflight-library" if registration_only else "preflight"]
if repair:
preflight_args.append("--repair")
if repair and settings.adopt_existing_native:
@@ -183,15 +303,25 @@ class Installer:
progress=remote_progress, cancel_event=cancel_event, timeout=90))
if info.get("home") != "/home/steamos" or info.get("cachePath") != "/home/steamos/.cache/halo-frame-installer" or info.get("gamePath") != "/home/steamos/Games/HaloCENativeVR":
raise SSHError("The Frame reported an unexpected installation location.")
if registration_only and info.get("existing") is None:
raise SSHError("Install and verify native Halo VR before updating its Steam information.")
if (info.get("existing") or {}).get("needsUpgrade"):
if registration_only:
raise SSHError("Use Install to upgrade the existing native game to version 1.4 before updating its Steam information.")
repair = True
progress("upgrade", "Upgrading the existing native game to version 1.4. Previous checkpoints stay in place; new saves use save-v1.4...", None)
if info.get("pcVersionDetected"):
progress("detect", "Found an older Halo PC installation. The native VR build requires original Xbox maps.", None)
remote_resources = info["cachePath"] + "/resources"
for name in ("remote_install.py", "build-native.sh", "steam_shortcut.py", "steam_live.py", "frame-controls.patch"):
helpers = ("remote_install.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py")
if not registration_only:
helpers += ("build-native.sh", "frame-controls.patch")
for name in helpers:
check_cancel()
connection.put(self.resource_dir / name, remote_resources + "/" + name, cancel_event=cancel_event)
# Artwork ships inside the one-file installer; no metadata service or
# account API key is needed, including an Add to Steam again retry.
for name in ("halo-ce-cover.jpg", "halo-ce-landscape.png"):
for name in ("halo-ce-cover.jpg", "halo-ce-landscape.png", "halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
check_cancel()
connection.put(self.resource_dir / "artwork" / name,
remote_resources + "/artwork/" + name, cancel_event=cancel_event)
@@ -249,29 +379,39 @@ class Installer:
check_cancel()
installed = step("finalize", run_id=True, timeout=600)
check_cancel()
progress("steam", "Checking Halo's Steam entry while keeping Steam Home running...", None)
steam = step("shortcut", timeout=90)
progress("steam", "Updating Halo's Steam entry, artwork and game Notes while keeping Steam Home running...", None)
steam = step("shortcut", timeout=150)
# Keep the result's public fingerprint before releasing the client.
host_fingerprint = connection.host_fingerprint
progress("disconnect", "Closing the setup SSH connection...", None)
connection.close()
disconnected = True
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native VR game is ready. Launch Halo: Combat Evolved VR (Native) from your Steam library." if steam.get("status") == "added" else "Native VR game is installed. One Steam library step remains.", 100)
cleanup_warning = close_connection(connection, progress)
cleanup_attempted = True
if cleanup_warning:
progress("cleanup-pending", "Native Halo VR is installed; setup's connection cleanup needs attention.", 99)
else:
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native VR game is ready. Launch Halo: Combat Evolved VR (Native) from your Steam library." if steam.get("status") == "added" else "Native VR game is installed. One Steam library step remains.", 100)
return InstallResult(installed["gamePath"], bool(installed.get("reused")),
steam, host_fingerprint, repaired=bool(installed.get("repaired")),
backup_path=installed.get("backupPath"))
except CancelledError:
try:
cancel_remote()
except (SSHError, OSError):
pass
backup_path=installed.get("backupPath"), cleanup_warning=cleanup_warning)
except (CancelledError, RemoteTimeoutError, SSHError) as error:
cancel_remote()
if cancellation_warning:
message = str(error)
if cancellation_unconfirmed:
first, separator, rest = message.partition("\n")
message = first + " Remote stopping could not be confirmed; the build may still be running on the Frame." + (separator + rest if separator else "")
error.args = (message + "\n\n" + cancellation_warning,)
raise
finally:
if not disconnected:
progress("detail", "Closing the setup SSH connection...", None)
connection.close()
progress("detail", "Setup has disconnected from your Frame.", None)
if not cleanup_attempted:
cleanup_activity(progress, "Closing the setup SSH connection...")
warning = close_connection(connection, progress)
if warning is None:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
error.args = (str(error) + "\n\n" + warning,)
def add_to_steam(self, settings: Settings, progress_callback: ProgressCallback | None = None,
cancel_event: threading.Event | None = None) -> InstallResult:
@@ -285,7 +425,7 @@ class Installer:
cancel_event = cancel_event or threading.Event()
connection = self.connection_factory(settings)
run_identifier = uuid.uuid4().hex
disconnected = False
cleanup_attempted = False
def check_cancel():
if cancel_event.is_set():
@@ -326,31 +466,54 @@ class Installer:
steam = data.get("steam")
if not isinstance(steam, dict):
raise SSHError("The Frame returned an invalid uninstall result.")
recovered, retained, warning = uninstall_history(data)
for text in ([warning] if warning else []) + ["Recovered campaign save backup: " + path for path in recovered] + ["Preserved uninstall folder: " + path for path in retained]:
progress("detail", text, None)
if steam.get("status") == "manual":
raise SSHError(steam.get("reason", "Steam could not close safely; the game was kept.")
+ "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated."))
diagnostic = steam.get("reason", "Steam could not close safely; the game was kept.") + "\n" + steam.get("instructions", "Use Steam's own interface to remove the native Halo shortcut, then retry when its library can be safely updated.")
if warning:
diagnostic += "\n\n" + warning
if recovered:
diagnostic += "\n\nRecovered campaign save backups:\n" + "\n".join(recovered)
if retained:
diagnostic += "\n\nPreserved uninstall folders:\n" + "\n".join(retained)
raise SSHError(diagnostic)
uninstalled, absent = data.get("uninstalled"), data.get("alreadyAbsent", False)
pending = data.get("removalPending", False)
saved = data.get("savedBackupPath")
if (data.get("gamePath") != info["gamePath"] or type(uninstalled) is not bool
or type(absent) is not bool or not (uninstalled or absent)
or type(absent) is not bool or type(pending) is not bool
or (pending and (uninstalled or absent))
or (not pending and not (uninstalled or absent))
or (retained and not pending)
or steam.get("status") not in ("removed", "already-absent")
or (saved is not None and saved != "/home/steamos/Games/HaloCENativeVR-saves-" + run_identifier)):
raise SSHError("The Frame returned an invalid uninstall result.")
warning = data.get("warning")
if isinstance(warning, str) and 0 < len(warning) <= 4096:
progress("detail", warning, None)
host_fingerprint = connection.host_fingerprint
progress("disconnect", "Closing the setup SSH connection...", None)
connection.close()
disconnected = True
progress("disconnected", "Setup has disconnected from your Frame.", None)
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint)
cleanup_warning = close_connection(connection, progress)
cleanup_attempted = True
if cleanup_warning:
progress("cleanup-pending", "Uninstall results were saved; setup's connection cleanup needs attention.", 99)
else:
progress("disconnected", "Setup has disconnected from your Frame.", None)
if pending:
progress("removal-pending", "Uninstall preserved folders that need attention. See the setup activity and recovery paths.", 99)
elif not cleanup_warning:
progress("complete", "Native Halo VR has been uninstalled." if uninstalled else "Native Halo VR was already absent; its Steam entry has been checked.", 100)
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint,
cleanup_warning=cleanup_warning, removal_pending=pending,
removal_warning=warning, recovered_save_backup_paths=recovered,
retained_quarantine_paths=retained)
finally:
if not disconnected:
progress("detail", "Closing the setup SSH connection...", None)
connection.close()
progress("detail", "Setup has disconnected from your Frame.", None)
if not cleanup_attempted:
cleanup_activity(progress, "Closing the setup SSH connection...")
warning = close_connection(connection, progress)
if warning is None:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
error.args = (str(error) + "\n\n" + warning,)
def run(settings: Settings, maps_dir: Path | None, progress_callback=None,
+30 -8
View File
@@ -32,6 +32,19 @@ STEP = (7,8,9,10,11,12,13,14,16,17,19,21,23,25,28,31,34,37,41,45,
INDEX = (-1,-1,-1,-1,2,4,6,8,-1,-1,-1,-1,2,4,6,8)
# The Xbox predictor/index transition depends only on these 89 x 16 states.
# Calculate it once instead of doing shifts and clamps for every audio sample.
def _transition(index, nibble):
step = STEP[index]
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
return (-difference if nibble & 8 else difference), max(0, min(88, index+INDEX[nibble]))
TRANSITIONS = tuple(tuple(_transition(index, nibble) for nibble in range(16)) for index in range(89))
DECODE_BATCH_BLOCKS = 64
DECODE_YIELD_SECONDS = 0.001
class MusicError(ValueError):
pass
@@ -182,24 +195,33 @@ def decode_adpcm(source: bytes, channels: int, cancel_event=None, gain=QUIET_GAI
block_size = 36*channels
samples = array('h', [0]) * (len(source)//block_size*64*channels)
for block in range(len(source)//block_size):
if block % 128 == 0:
if block % DECODE_BATCH_BLOCKS == 0:
_cancel(cancel_event)
if block:
# A background Python thread still competes with Tk for the GIL.
# Give the window a regular scheduling opportunity while decoding
# long tracks, and promptly abandon a superseded ISO selection.
time.sleep(DECODE_YIELD_SECONDS)
_cancel(cancel_event)
position = block*block_size
for channel in range(channels):
predictor, index, _ = _fields(source, position+channel*4, '<hBB')
if index > 88:
raise MusicError('Invalid ADPCM predictor index.')
sample_position = block*64*channels+channel
for group in range(8):
base = position+4*channels+(group*channels+channel)*4
for byte in range(4):
packed = source[base+byte]
for half, nibble in enumerate((packed & 15, packed >> 4)):
step = STEP[index]
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
predictor = max(-32768, min(32767, predictor + (-difference if nibble & 8 else difference)))
index = max(0, min(88, index+INDEX[nibble]))
frame = block*64+group*8+byte*2+half
samples[frame*channels+channel] = int(predictor*gain)
for nibble in (packed & 15, packed >> 4):
difference, index = TRANSITIONS[index][nibble]
predictor += difference
if predictor < -32768:
predictor = -32768
elif predictor > 32767:
predictor = 32767
samples[sample_position] = int(predictor*gain)
sample_position += channels
if sys.byteorder != 'little':
samples.byteswap()
return samples.tobytes()
+3 -1
View File
@@ -27,9 +27,11 @@ PHASES = {
"compile": (73, 90, "Compiling Halo and VR support"),
"build-check": (90, 92, "Checking the native game build"),
"install": (92, 96, "Installing the native game and Xbox controls"),
"steam": (96, 99, "Adding Halo and its box art to Steam"),
"steam": (96, 99, "Updating Halo's Steam library information"),
"disconnect": (99, 99, "Closing the setup connection"),
"disconnected": (99, 99, "Disconnected from your Frame"),
"cleanup-pending": (99, 99, "Connection cleanup needs attention"),
"removal-pending": (99, 99, "Uninstall needs attention"),
"complete": (99, 99, "Finishing setup"),
}
+14 -3
View File
@@ -7,13 +7,14 @@ import subprocess
def smoke_test(resources: Path) -> dict:
from . import __version__
from .gui import App
from .gui import App, APP_TITLE, CONTENT_DISCLOSURE
from .install import SOURCE_COMMIT
from .ssh import Settings
names = ('remote_install.py', 'steam_shortcut.py', 'steam_live.py', 'build-native.sh',
names = ('remote_install.py', 'steam_shortcut.py', 'steam_live.py', 'steam_notes.py', 'build-native.sh',
'frame-controls.patch', 'ui/app-icon.png', 'ui/app-icon.ico',
'artwork/halo-ce-cover.jpg', 'artwork/halo-ce-landscape.png',
'artwork/halo-ce-thumbnail.png')
'artwork/halo-ce-thumbnail.png', 'artwork/halo-ce-hero.jpg',
'artwork/halo-ce-logo.png', 'artwork/halo-ce-icon.png')
hashes = {}
usb_bundled = getattr(sys, 'frozen', False)
if usb_bundled:
@@ -48,6 +49,8 @@ def smoke_test(resources: Path) -> dict:
pages_initialized.append(name)
valid = (bool(app.asset_status.get()) and app.log.winfo_reqheight() > 1
and hasattr(app, '_game_cover') and hasattr(app, 'music'))
if app.title() != APP_TITLE or CONTENT_DISCLOSURE not in app.data_disclosure.cget('text'):
raise RuntimeError('Packaged community mod installer title or content disclosure is missing')
if str(app.progress['mode']) != 'determinate':
raise RuntimeError('Packaged progress bar is not determinate')
if {choice.value for choice in app.mode_choices} != {'install', 'repair', 'uninstall'}:
@@ -71,6 +74,12 @@ def smoke_test(resources: Path) -> dict:
raise RuntimeError('Packaged USB setup must keep Steam Home running')
settings.password = ''
app.password.set('')
for mode in ('install', 'repair'):
app.mode.set(mode)
guidance = app.summary_note.get()
if ('Steam artwork and game Notes are added automatically' not in guidance
or hasattr(app, 'library_card')):
raise RuntimeError('Packaged install/repair lost automatic Steam info setup')
finally:
for callback in app.tk.call('after', 'info'):
app.after_cancel(callback)
@@ -84,5 +93,7 @@ def smoke_test(resources: Path) -> dict:
'automaticMusicWithoutToggle': True,
'keepsSteamSessionRunning': True,
'usbTransferAvailable': True,
'steamInfoAutomaticOnInstall': True,
'steamDescriptionUsesNotes': True,
'usbToolsBundled': usb_bundled,
'passwordReprRedacted': True, 'networkConnections': 0}
+87 -26
View File
@@ -21,6 +21,10 @@ class SSHError(RuntimeError):
pass
class RemoteTimeoutError(SSHError):
"""A started remote operation exceeded its bounded host wait."""
class CancelledError(RuntimeError):
pass
@@ -116,7 +120,7 @@ class SSHConnection:
def host_fingerprint(self) -> str | None:
return self.policy.accepted_fingerprint
def connect(self) -> None:
def connect(self, *, timeout: float = 15) -> None:
from .usb import USBError, USBForward
try:
host, port = self.settings.host, self.settings.port
@@ -127,7 +131,11 @@ class SSHConnection:
self.client.connect(host, port=port,
username="steamos", password=self.settings.password,
allow_agent=False, look_for_keys=False,
timeout=15, auth_timeout=20, banner_timeout=20)
timeout=timeout, auth_timeout=min(20, timeout + 5),
banner_timeout=min(20, timeout + 5))
transport = self.client.get_transport()
if transport is not None:
transport.set_keepalive(15)
except (paramiko.AuthenticationException, paramiko.PasswordRequiredException) as exc:
self._close_after_failure()
raise SSHError("SSH authentication failed. Check the Frame's steamos password.") from None
@@ -137,16 +145,17 @@ class SSHConnection:
except SSHError:
self._close_after_failure()
raise
except (paramiko.SSHException, OSError, socket.timeout) as exc:
except (paramiko.SSHException, OSError, EOFError) as exc:
self._close_after_failure()
# Avoid displaying a library error that might include submitted credentials.
message = ("Could not connect to the Frame over USB SSH. Check Developer Mode, the USB cable and the Frame's SSH service."
if self.settings.transport == "usb" else
"Could not connect to the Frame over SSH. Check its address, Wi-Fi / Ethernet connection, and SSH service.")
raise SSHError(message) from None
def is_active(self) -> bool:
transport = self.client.get_transport()
if transport is not None:
transport.set_keepalive(15)
return bool(not self._closed and transport is not None and transport.is_active())
def _close_after_failure(self) -> None:
try:
@@ -168,23 +177,43 @@ class SSHConnection:
pass
self._sftp_clients.clear()
self._command_channels.clear()
transport = self.client.get_transport()
failures = []
try:
transport = self.client.get_transport()
except Exception:
transport = None
failures.append("The SSH transport could not be checked during disconnection.")
try:
if transport is not None:
try:
transport.close()
except Exception:
failures.append("The SSH transport could not finish closing.")
finally:
# Transport.close() may return early after a failed or
# already inactive session. Release its socket as well.
sock = getattr(transport, "sock", None)
if sock is not None:
sock.close()
try:
sock.close()
except Exception:
failures.append("The setup connection's socket could not finish closing.")
finally:
try:
self.client.close()
except Exception:
failures.append("The SSH client could not finish closing.")
finally:
self._closed = True
self._close_usb_forward()
try:
self._close_usb_forward()
except SSHError as exc:
failures.append(str(exc))
if failures:
message = "\n".join(failures)
if self.settings.password:
message = message.replace(self.settings.password, "[redacted]")
raise SSHError(message) from None
def _close_usb_forward(self) -> None:
if self._usb_forward is not None:
@@ -200,28 +229,42 @@ class SSHConnection:
on_cancel: Callable[[], None] | None = None,
timeout_message: str | None = None) -> str:
"""Run an argument vector; no secrets are put into a remote command."""
def notify_cancelled():
if on_cancel is not None:
try:
on_cancel()
except Exception:
pass # The primary cancellation/timeout must always survive.
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
command = shlex.join([str(arg) for arg in argv])
_, stdout, _ = self.client.exec_command(command, timeout=30)
started = time.monotonic()
try:
_, stdout, _ = self.client.exec_command(command, timeout=min(30, timeout))
except (paramiko.SSHException, OSError, EOFError):
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The remote command could not be started. Check the Frame's connection and retry.") from None
channel = stdout.channel
self._command_channels.add(channel)
channel.set_combine_stderr(True)
# 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
pending = bytearray()
started = time.monotonic()
try:
channel.set_combine_stderr(True)
while True:
if cancel_event is not None and cancel_event.is_set():
if on_cancel is not None:
on_cancel()
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if time.monotonic() - started > timeout:
if on_cancel is not None:
on_cancel()
raise SSHError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
notify_cancelled()
raise RemoteTimeoutError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
while channel.recv_ready():
data = channel.recv(65536)
if not data:
@@ -260,10 +303,19 @@ class SSHConnection:
safe = safe.replace(self.settings.password, "[redacted]")
raise SSHError(safe[-12000:].strip() or f"Remote step failed (exit {status}).")
return result
except (paramiko.SSHException, OSError, EOFError):
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The remote connection stopped before this step could be verified. Check the Frame's connection and retry.") from None
finally:
try:
channel.close()
finally:
except Exception:
# Final connection teardown will retry this resource and close
# its transport. A channel-close error cannot erase the result.
pass
else:
self._command_channels.discard(channel)
def put(self, local: Path, remote: str, *, callback=None,
@@ -273,14 +325,23 @@ class SSHConnection:
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if callback:
callback(done, total)
with self.client.open_sftp() as sftp:
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
sftp = None
try:
sftp = self.client.open_sftp()
self._sftp_clients.add(sftp)
sftp.get_channel().settimeout(20)
try:
sftp.put(str(local), remote, callback=update, confirm=True)
except (OSError, EOFError, paramiko.SSHException):
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
finally:
self._sftp_clients.discard(sftp)
sftp.put(str(local), remote, callback=update, confirm=True)
except (OSError, EOFError, paramiko.SSHException):
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise SSHError("The encrypted file transfer stopped. Check the Frame's connection and retry.") from None
finally:
if sftp is not None:
try:
sftp.close()
except Exception:
pass # Connection teardown still owns and retries it.
else:
self._sftp_clients.discard(sftp)
+145 -3
View File
@@ -1,6 +1,7 @@
"""Artwork registration must preserve custom images and unrelated accounts."""
import hashlib
import importlib.util
import json
import os
import sys
from pathlib import Path
@@ -39,8 +40,15 @@ def test_bundled_artwork_integrity_and_png_dimensions(steam):
import struct
wide = (RESOURCES / "artwork/halo-ce-landscape.png").read_bytes()
thumbnail = (RESOURCES / "artwork/halo-ce-thumbnail.png").read_bytes()
logo = (RESOURCES / "artwork/halo-ce-logo.png").read_bytes()
icon = (RESOURCES / "artwork" / steam.ICON_NAME).read_bytes()
assert struct.unpack(">II", wide[16:24]) == (920, 430)
assert struct.unpack(">II", thumbnail[16:24]) == (180, 270)
assert struct.unpack(">II", logo[16:24]) == (1000, 431)
assert logo[25] == 6 # RGBA, preserving the logo's transparent background.
assert struct.unpack(">II", icon[16:24]) == (256, 256)
assert hashlib.sha256(icon).hexdigest() == steam.ICON_SHA256
assert steam.ARTWORK_TYPES == {"p": 0, "_hero": 1, "_logo": 2, "": 3}
def test_new_shortcut_gets_box_art_in_only_active_account(steam, tmp_path):
@@ -52,6 +60,8 @@ def test_new_shortcut_gets_box_art_in_only_active_account(steam, tmp_path):
assert (active / "grid" / f"{appid}p.jpg").read_bytes() == (RESOURCES / "artwork/halo-ce-cover.jpg").read_bytes()
assert (active / "grid" / f"{appid}.png").read_bytes() == (RESOURCES / "artwork/halo-ce-landscape.png").read_bytes()
assert not (directory / "userdata/10/config/grid").exists()
assert (active / "grid" / f"{appid}_hero.jpg").read_bytes() == (RESOURCES / "artwork/halo-ce-hero.jpg").read_bytes()
assert (active / "grid" / f"{appid}_logo.png").read_bytes() == (RESOURCES / "artwork/halo-ce-logo.png").read_bytes()
assert steam.loads((active / "shortcuts.vdf").read_bytes())["shortcuts"].value["0"].value["appid"].value == appid
@@ -66,7 +76,8 @@ def test_retry_fills_missing_art_for_unchanged_existing_shortcut(steam, tmp_path
assert (active / "shortcuts.vdf").read_bytes() == raw
again = steam.add_native_shortcut(home, game)
assert again["artwork"]["status"] == "unchanged"
assert sorted(path.name for path in (active / "grid").iterdir()) == [f"{appid}.png", f"{appid}p.jpg"]
assert sorted(path.name for path in (active / "grid").iterdir()) == sorted(
str(appid) + suffix + extension for suffix, extension, _, _ in steam.ARTWORK)
def test_existing_unsigned_shortcut_id_is_used_even_if_crc_differs(steam, tmp_path):
@@ -89,7 +100,8 @@ def test_custom_art_with_other_extensions_and_other_games_is_kept(steam, tmp_pat
grid = config / "grid"
grid.mkdir(parents=True)
appid = 3732925724
contents = {f"{appid}p.png": b"user portrait", f"{appid}.jpeg": b"user landscape", "10p.png": b"other game"}
contents = {str(appid) + suffix + ".jpeg": b"custom " + suffix.encode() for suffix, _, _, _ in steam.ARTWORK}
contents["10p.png"] = b"other game"
for name, content in contents.items():
(grid / name).write_bytes(content)
result = steam.install_artwork(config, appid)
@@ -104,7 +116,7 @@ def test_custom_portrait_is_kept_and_missing_wide_tile_is_added(steam, tmp_path)
grid.mkdir(parents=True)
(grid / "3732925724p.jpg").write_bytes(b"custom portrait")
result = steam.install_artwork(config, 3732925724)
assert result["installed"] == ["3732925724.png"]
assert result["installed"] == ["3732925724" + suffix + extension for suffix, extension, _, _ in steam.ARTWORK if suffix != "p"]
assert result["preserved"] == ["3732925724p.jpg"]
assert (grid / "3732925724p.jpg").read_bytes() == b"custom portrait"
@@ -193,3 +205,133 @@ def test_hardlinked_custom_art_is_rejected_before_writing_other_tile(steam, tmp_
steam.install_artwork(config, 3732925724)
assert foreign.read_bytes() == b"keep"
assert sorted(path.name for path in grid.iterdir()) == ["3732925724p.jpg"]
def owned_game(tmp_path):
game = tmp_path / "Games/HaloCENativeVR"
game.mkdir(parents=True)
program = bytearray(64)
program[:6] = b"\x7fELF\x02\x01"
program[18:20] = (183).to_bytes(2, "little")
(game / "halo").write_bytes(program)
(game / ".halo-frame-installer.json").write_text(json.dumps({
"owner": "halo-frame-installer", "sourceCommit": "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4",
"files": {"halo": hashlib.sha256(program).hexdigest()}}))
return game
def test_closed_shortcut_icon_uses_persistent_owned_sidecar(steam, tmp_path):
game = owned_game(tmp_path)
raw, appid = steam.update_shortcut(None, str(game / "halo"), str(game))
updated, result = steam._closed_shortcut_icon(raw, game, appid)
target = game / ".installer-artwork" / steam.ICON_NAME
assert result == {"status": "added", "path": str(target)}
assert target.read_bytes() == (RESOURCES / "artwork" / steam.ICON_NAME).read_bytes()
_, _, selected = steam._shortcut_icon_field(updated, appid, str(game / "halo"))
assert selected == str(target)
unchanged, retry = steam._closed_shortcut_icon(updated, game, appid)
assert unchanged == updated and retry["status"] == "unchanged"
def test_closed_shortcut_custom_icon_path_is_preserved_without_sidecar(steam, tmp_path):
game = owned_game(tmp_path)
raw, appid = steam.update_shortcut(None, str(game / "halo"), str(game))
root = steam.loads(raw)
root["shortcuts"].value["0"].value["icon"] = steam.Value(1, "/custom/chief.png")
custom = steam.dumps(root)
unchanged, result = steam._closed_shortcut_icon(custom, game, appid)
assert unchanged == custom and result["status"] == "preserved"
assert not (game / ".installer-artwork").exists()
def test_changed_managed_icon_is_kept_in_both_empty_and_selected_shortcut(steam, tmp_path):
game = owned_game(tmp_path)
raw, appid = steam.update_shortcut(None, str(game / "halo"), str(game))
updated, result = steam._closed_shortcut_icon(raw, game, appid)
icon = Path(result["path"])
icon.write_bytes(b"my custom Chief icon")
for content in (raw, updated):
unchanged, retry = steam._closed_shortcut_icon(content, game, appid)
assert unchanged == content and retry["status"] == "preserved"
assert icon.read_bytes() == b"my custom Chief icon"
def test_icon_publication_requires_installer_owned_game(steam, tmp_path):
game = owned_game(tmp_path)
(game / ".halo-frame-installer.json").write_text('{"owner":"another-application"}')
with pytest.raises(ValueError, match="ownership"):
steam._publish_shortcut_icon(game)
assert not (game / ".installer-artwork").exists()
def test_icon_source_integrity_failure_does_not_create_sidecar(steam, tmp_path):
game = owned_game(tmp_path)
broken = tmp_path / "artwork"
broken.mkdir()
(broken / steam.ICON_NAME).write_bytes(b"changed bundle")
with pytest.raises(ValueError, match="integrity"):
steam._publish_shortcut_icon(game, broken)
assert not (game / ".installer-artwork").exists()
def test_later_corrupt_logo_prevents_all_artwork_writes(steam, tmp_path):
sources = tmp_path / "artwork"
sources.mkdir()
for _, _, name, _ in steam.ARTWORK:
(sources / name).write_bytes((RESOURCES / "artwork" / name).read_bytes())
(sources / "halo-ce-logo.png").write_bytes(b"corrupted last category")
config = tmp_path / "config"
config.mkdir()
with pytest.raises(ValueError, match="integrity"):
steam.install_artwork(config, 3000000001, sources)
assert not (config / "grid").exists()
def test_corrupt_optional_icon_prevents_any_artwork_publication(steam, tmp_path):
source = tmp_path / "corrupt-icon"
source.mkdir()
for _, _, filename, _ in steam.ARTWORK:
(source / filename).write_bytes((RESOURCES / "artwork" / filename).read_bytes())
(source / steam.ICON_NAME).write_bytes(b"corrupted optional icon")
config = tmp_path / "config"
config.mkdir()
with pytest.raises(ValueError, match="icon.*integrity"):
steam.install_artwork(config, 3000000001, source)
assert not (config / "grid").exists()
def test_original_guided_native_install_gets_icon_without_marker_adoption(steam, tmp_path):
game = owned_game(tmp_path)
metadata = json.loads((game / ".halo-frame-installer.json").read_text())
(game / ".halo-frame-installer.json").unlink()
marker = game / ".codex-halo-native-install.json"
metadata["owner"] = "codex-halo-native-frame-20261005"
metadata["build"] = {"sourceCommit": metadata.pop("sourceCommit")}
before = json.dumps(metadata).encode()
marker.write_bytes(before)
program_before = (game / "halo").read_bytes()
result = steam._publish_shortcut_icon(game)
assert result["status"] == "added"
assert marker.read_bytes() == before and not (game / ".halo-frame-installer.json").exists()
assert (game / "halo").read_bytes() == program_before
assert hashlib.sha256(Path(result["path"]).read_bytes()).hexdigest() == steam.ICON_SHA256
@pytest.mark.parametrize("change", ["source", "program", "architecture"])
def test_icon_sidecar_needs_pinned_source_and_verified_native_identity(steam, tmp_path, change):
game = owned_game(tmp_path)
marker = game / ".halo-frame-installer.json"
metadata = json.loads(marker.read_text())
if change == "source":
metadata["sourceCommit"] = "f" * 40
else:
program = bytearray((game / "halo").read_bytes())
program[18:20] = (62).to_bytes(2, "little") if change == "architecture" else program[18:20]
program.extend(b"modified")
(game / "halo").write_bytes(program)
if change == "architecture":
metadata["files"]["halo"] = hashlib.sha256(program).hexdigest()
marker.write_text(json.dumps(metadata))
with pytest.raises(ValueError, match="verified|revision"):
steam._publish_shortcut_icon(game)
assert not (game / ".installer-artwork").exists()
+112
View File
@@ -198,6 +198,118 @@ class AssetTests(unittest.TestCase):
self.assertFalse(dest.exists())
self.assertFalse(list(self.root.glob(".halo-assets-*")))
def test_cancelled_inspection_does_not_access_source_paths(self):
event = threading.Event()
event.set()
with patch.object(assets, "_XboxImage") as image_reader, \
patch.object(assets, "_maps_source") as maps_reader:
for inspect in (assets.inspect_image, assets.inspect_maps):
with self.subTest(inspect=inspect.__name__):
with self.assertRaises(assets.ExtractionCancelled):
inspect(self.root / "does-not-exist", cancel_event=event)
image_reader.assert_not_called()
maps_reader.assert_not_called()
def test_image_directory_inspection_cancels_and_closes_its_file(self):
event = threading.Event()
opened, inspected_names = [], []
original_open, original_name = Path.open, assets._safe_name
def capture_open(path, *args, **kwargs):
stream = original_open(path, *args, **kwargs)
opened.append(stream)
return stream
def cancel_after_first_name(name):
original_name(name)
inspected_names.append(name)
event.set()
with patch.object(Path, "open", capture_open), \
patch.object(assets, "_safe_name", cancel_after_first_name):
with self.assertRaises(assets.ExtractionCancelled):
assets.inspect_image(self.image, cancel_event=event)
self.assertEqual(inspected_names, ["maps"])
self.assertEqual(len(opened), 1)
self.assertTrue(opened[0].closed)
def test_image_header_inspection_cancels_including_after_last_header(self):
original_header, original_open = assets._cache_header, Path.open
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
with self.subTest(cancel_after=cancel_after):
event = threading.Event()
opened, inspected = [], []
def capture_open(path, *args, **kwargs):
stream = original_open(path, *args, **kwargs)
opened.append(stream)
return stream
def cancel_during_headers(header, name, size):
value = original_header(header, name, size)
inspected.append(name)
if len(inspected) == cancel_after:
event.set()
return value
with patch.object(Path, "open", capture_open), \
patch.object(assets, "_cache_header", cancel_during_headers):
with self.assertRaises(assets.ExtractionCancelled):
assets.inspect_image(self.image, cancel_event=event)
self.assertEqual(len(inspected), cancel_after)
self.assertTrue(all(stream.closed for stream in opened))
def test_maps_inspection_cancels_during_directory_enumeration(self):
maps = self.root / "synthetic-maps"
maps.mkdir()
for name in assets.REQUIRED_MAPS:
(maps / name).write_bytes(cache_header())
event = threading.Event()
inspected_names = []
original_name = assets._safe_name
def cancel_after_first_name(name):
original_name(name)
inspected_names.append(name)
event.set()
with patch.object(assets, "_safe_name", cancel_after_first_name), \
patch.object(assets, "_cache_header") as header_reader:
with self.assertRaises(assets.ExtractionCancelled):
assets.inspect_maps(maps, cancel_event=event)
header_reader.assert_not_called()
self.assertEqual(len(inspected_names), 1)
def test_maps_inspection_and_copy_cancel_during_source_header_validation(self):
maps = self.root / "synthetic-maps"
maps.mkdir()
for name in assets.REQUIRED_MAPS:
(maps / name).write_bytes(cache_header())
original_header = assets._cache_header
for operation in ("inspect", "copy"):
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
with self.subTest(operation=operation, cancel_after=cancel_after):
event = threading.Event()
inspected = []
def cancel_during_headers(header, name, size):
value = original_header(header, name, size)
inspected.append(name)
if len(inspected) == cancel_after:
event.set()
return value
dest = self.root / "not-published"
with patch.object(assets, "_cache_header", cancel_during_headers):
with self.assertRaises(assets.ExtractionCancelled):
if operation == "inspect":
assets.inspect_maps(maps, cancel_event=event)
else:
assets.copy_maps(maps, dest, cancel_event=event)
self.assertEqual(len(inspected), cancel_after)
self.assertFalse(dest.exists())
self.assertFalse(list(self.root.glob(".halo-assets-*")))
def test_write_error_removes_owned_staging_and_does_not_publish(self):
dest = self.root / "failed"
with patch.object(assets.os, "fsync", side_effect=OSError("synthetic disk full")):
+103
View File
@@ -0,0 +1,103 @@
"""Offline source-integration regressions; no game data or headset connection."""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("experimental_engine_check", ROOT / "scripts/check-experimental-engine.py")
engine = importlib.util.module_from_spec(spec)
spec.loader.exec_module(engine)
TWO_PASS = """
static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale[4][4])
{
GLenum gl_targets[D3DTSS_MAXSTAGES];
GLuint gl_textures[D3DTSS_MAXSTAGES];
int stage;
for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)
{
gl_textures[stage] = xgpu_texture_get(texture, palette, &gl_targets[stage], &description);
}
for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)
state_texture(stage, gl_targets[stage], gl_textures[stage]);
}
"""
def literal_assignment(path, name):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
return ast.literal_eval(node.value)
raise AssertionError("Missing " + name + " in " + str(path))
def test_installer_and_remote_metadata_share_the_exact_experimental_engine_pin():
for path, name in ((ROOT / "src/halo_frame_installer/install.py", "SOURCE_COMMIT"),
(ROOT / "resources/remote_install.py", "SOURCE_COMMIT"),
(ROOT / "resources/steam_shortcut.py", "ICON_SOURCE_COMMIT")):
assert literal_assignment(path, name) == engine.SOURCE_COMMIT
def test_two_pass_texture_binding_is_accepted():
engine.check_texture_binding(TWO_PASS)
@pytest.mark.parametrize("broken", [
TWO_PASS.replace("gl_textures[stage] = xgpu_texture_get", "state_texture(stage, gl_targets[stage], gl_textures[stage]); gl_textures[stage] = xgpu_texture_get"),
TWO_PASS.replace("for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)\n state_texture", "state_texture"),
TWO_PASS.replace("state_texture(stage, gl_targets[stage], gl_textures[stage])", "state_texture(stage, GL_TEXTURE_2D, 0)"),
])
def test_shader_upload_binding_regressions_are_rejected(broken):
with pytest.raises(engine.EngineCheckError, match="Texture binding|Final texture bindings"):
engine.check_texture_binding(broken)
@pytest.mark.parametrize("path", ["../outside.c", "/outside.c", "source/network/network_messages.c", "resources/remote_install.py"])
def test_engine_patch_cannot_hide_unreviewed_paths(path):
with pytest.raises(engine.EngineCheckError, match="outside the reviewed"):
engine.patch_files("diff --git a/" + path + " b/" + path + "\n")
def test_patch_path_allowlist_contains_both_new_header_and_existing_input_hooks():
text = (ROOT / "resources/frame-controls.patch").read_text(encoding="utf-8")
paths = engine.patch_files(text)
assert {"port/linux/src/vr_locomotion.h", "port/linux/src/vr.c", "port/linux/src/port_config.c", "source/game/player_control.c"} <= paths
def test_protocol_11_engine_cannot_be_mislabeled_as_matching_experimental_coop(tmp_path):
path = tmp_path / "port/linux/include/halo_port_limits.h"
path.parent.mkdir(parents=True)
path.write_text("#define HALO_PORT_NETWORK_VERSION 11\n")
with pytest.raises(engine.EngineCheckError, match="protocol 17"):
engine.check_upstream(tmp_path)
def test_public_release_documentation_names_the_single_installer_download():
for name in ("README.md", "docs/SETUP.md", "docs/RELEASE_NOTES.md"):
text = (ROOT / name).read_text(encoding="utf-8")
assert "1.4.0" in text and "Halo-Steam-Frame-Mod-Setup-1.4.0.exe" in text
assert "has not been published" not in text and "local experimental preview" not in text
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert "https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.0/Halo-Steam-Frame-Mod-Setup-1.4.0.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
def test_actual_exact_pinned_engine_and_shipped_patch_when_checkout_is_supplied():
source = os.environ.get("HALO_EXPERIMENTAL_ENGINE_SOURCE")
if not source:
pytest.skip("Set HALO_EXPERIMENTAL_ENGINE_SOURCE for the local offline engine integration check")
result = engine.check_source(Path(source))
assert result["ok"] and result["protocolVersion"] == 17
assert result["offlineArm64NinjaGraph"] and result["headDirectedMovementIntegration"]
assert result["stereoSunGlowIntegration"] and result["vrInternetCampaignMenu"]
assert result["boundedSunGlowStrength"]
assert result["nominalProjectileReticleIntegration"]
assert result["renderRateProjectilePreviewIntegration"]
assert result["networkCampaignOnlyIntegration"]
assert not result["arm64LinkedBuild"] and not result["hardwareValidated"]
+480 -2
View File
@@ -122,6 +122,29 @@ def test_control_guide_matches_expected_layout():
assert action in CONTROLS
@WINDOWS_GUI
def test_community_mod_title_and_content_disclosure_are_visible_and_accurate(app):
import tkinter as tk
from halo_frame_installer.gui import APP_TITLE, CONTENT_DISCLOSURE, ARTWORK_CREDIT
assert app.title() == APP_TITLE == 'Halo • Steam Frame VR Mod Installer'
visible = '\n'.join(_display_text(app))
assert 'STEAM FRAME VR MOD' in visible and 'COMMUNITY MOD INSTALLER' in visible
assert CONTENT_DISCLOSURE in app.data_disclosure.cget('text')
assert 'my original Xbox game data with this mod' in app.data_authorization.cget('text')
app._show_help()
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
try:
assert len(dialogs) == 1
text = '\n'.join(_display_text(dialogs[0]))
assert CONTENT_DISCLOSURE in text and ARTWORK_CREDIT in text
assert 'public source using your authorized Xbox data' in text
for claim in ('copyright-free', 'copyright free', 'unstolen', 'vimm', 'archive.org'):
assert claim not in text.lower()
finally:
for dialog in dialogs:
dialog.destroy()
@WINDOWS_GUI
def test_long_build_failure_has_readable_dialog_and_complete_redacted_log(app):
app.password_to_redact = 'offline-secret'
@@ -149,6 +172,7 @@ def test_gui_offline_smoke():
from halo_frame_installer import __version__
assert result['version'] == __version__
assert result['guiPagesInitialized'] == ['gameData', 'connection', 'install']
assert result['steamInfoAutomaticOnInstall'] and result['steamDescriptionUsesNotes']
assert result['networkConnections'] == 0
@@ -596,7 +620,7 @@ def test_box_art_pending_stays_at_99_with_retry_action(app):
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert 'box art pending' in app.phase.get()
assert 'Steam info pending' in app.phase.get()
assert not _disabled(app.retry_button)
@@ -859,7 +883,7 @@ def test_default_window_shows_all_operations_and_game_data_authorization(app):
assert top <= control.winfo_rooty()
assert control.winfo_rooty() + control.winfo_height() <= bottom
assert len({control.winfo_rooty() for control in app.mode_choices}) == 1
assert 'uninstall' in app.page_subtitle.cget('text').lower()
assert 'older checkpoints cannot load' in app.page_subtitle.cget('text').lower()
@WINDOWS_GUI
@@ -936,3 +960,457 @@ def test_manual_library_fallback_uses_steam_ui_without_teardown_instructions(app
assert float(app.progress['value']) == 99
assert expected in app.status.get()
assert 'Quit Steam' not in app.status.get() and 'restart Steam' not in app.status.get()
@WINDOWS_GUI
@pytest.mark.parametrize('mode', ['install', 'repair'])
def test_install_and_repair_describe_automatic_steam_info_without_separate_choice(app, mode):
import tkinter as tk
app.mode.set(mode)
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
assert not hasattr(app, 'library_card')
assert 'Steam artwork and game Notes are added automatically' in app.summary_note.get()
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
app._show_page(2)
assert app.primary_button.cget('text') == ('Install Halo VR' if mode == 'install' else 'Repair Halo VR')
app._show_help()
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
try:
text = '\n'.join(_display_text(dialogs[0]))
assert 'Install and repair automatically add Steam artwork and a game description in Steam Notes' in text
assert 'Steam info updates' not in text
finally:
for dialog in dialogs:
dialog.destroy()
@WINDOWS_GUI
@pytest.mark.parametrize('transport', ['network', 'usb'])
def test_pending_steam_retry_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
from halo_frame_installer import gui, install
calls = []
def fake_library(self, settings, progress, cancel):
calls.append((settings, settings.password))
assert settings.transport == transport
assert not settings.close_steam_for_shortcut
progress('steam', 'Updating library info.', 100)
progress('disconnected', 'Setup has disconnected from your Frame.', None)
return install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
'notes': {'status': 'added', 'title': 'About Halo', 'content': 'Campaign and controls.', 'message': 'Added.'}}, None)
monkeypatch.setattr(install.Installer, 'add_to_steam', fake_library)
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Steam info rebuilt the game.'))
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
for name in ('inspect_image', 'inspect_maps', 'extract_image', 'copy_maps'):
monkeypatch.setattr(gui, name, lambda *args, **kwargs: pytest.fail('Steam info accessed game data.'))
monkeypatch.setattr(app.music, 'load', lambda *args: pytest.fail('Steam info read game music.'))
app.transport.set(transport)
app.steam_closed.set(True)
app.password.set('offline-secret')
app.last_result = install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'notes': {'status': 'manual', 'title': 'About Halo', 'content': 'Campaign.'}}, None)
app._show_page(2)
app._set_busy(False)
assert app.retry_button.winfo_manager() and not _disabled(app.retry_button)
app.retry_button.invoke()
_drain_events(app)
assert len(calls) == 1
settings, password = calls[0]
assert password == 'offline-secret'
assert not settings.password and not app.password.get()
assert not app.busy and not app.installing
assert float(app.progress['value']) == 100
assert app.phase.get() == 'Steam info updated'
assert 'native game and saves were kept' in app.status.get()
assert 'installed.' not in app.status.get()
assert app._test_dialogs[-1][0] == 'Steam info updated'
assert not app.retry_button.winfo_manager()
@WINDOWS_GUI
@pytest.mark.parametrize('pending', ['icon', 'notes', 'artwork'])
def test_steam_info_partial_results_stay_actionable_without_claiming_complete_update(app, pending):
from halo_frame_installer.install import InstallResult
app.operation = 'library'
app.password_to_redact = 'offline-secret'
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
'notes': {'status': 'added', 'title': 'About Halo', 'content': 'Campaign and controls.'}}
if pending == 'icon':
steam['artwork']['icon'] = {'status': 'manual', 'reason': 'Icon not persisted. offline-secret'}
elif pending == 'notes':
steam['notes'].update(status='manual', message='Open Steam Notes. offline-secret')
else:
steam['artwork']['status'] = 'manual'
steam['artwork']['reason'] = 'Banner unavailable. offline-secret'
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False, steam, None)))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert app.phase.get() == 'Ready to play · Steam info pending'
assert 'some Steam library info needs one more step' in app.status.get()
assert 'has been updated' not in app.status.get()
assert not _disabled(app.retry_button)
assert 'offline-secret' not in app.status.get()
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert app._test_dialogs[-1][0] == 'Steam info needs attention'
assert bool(app.notes_button.winfo_manager()) is (pending == 'notes')
@WINDOWS_GUI
def test_manual_steam_description_is_copyable_and_never_overrides_about_game(app):
import tkinter as tk
from halo_frame_installer.gui import Button
from halo_frame_installer.install import InstallResult
app.operation = 'library'
app.password_to_redact = 'offline-secret'
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'notes': {'status': 'manual', 'title': 'Halo CE VR — About & controls',
'content': 'Explore Halo.\nA: Jump. B: Melee. offline-secret', 'message': 'Steam Notes needs a manual step.'}}, None)))
app.events.put(('clear_password',))
app.events.put(('idle',))
_drain_events(app)
assert app.notes_button.winfo_manager() and not _disabled(app.notes_button)
assert 'description in Steam Notes' in app.status.get()
app._show_steam_note()
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
try:
assert len(dialogs) == 1
visible = '\n'.join(_display_text(dialogs[0]))
assert 'Add this description in Steam Notes' in visible
assert 'About this game' not in visible
def descendants(widget):
for child in widget.winfo_children():
yield child
yield from descendants(child)
fields = [widget for widget in descendants(dialogs[0]) if isinstance(widget, tk.Text)]
assert len(fields) == 1
assert fields[0].get('1.0', 'end-1c') == 'Explore Halo.\nA: Jump. B: Melee. [redacted]'
copy = next(widget for widget in descendants(dialogs[0])
if isinstance(widget, Button) and widget.cget('text') == 'Copy description')
copy.invoke()
assert app.clipboard_get() == 'Explore Halo.\nA: Jump. B: Melee. [redacted]'
app.clipboard_clear()
finally:
for dialog in dialogs:
dialog.destroy()
@WINDOWS_GUI
@pytest.mark.parametrize('icon_status', ['existing', 'custom', 'unchanged', 'preserved'])
def test_preserved_steam_info_is_successful_without_manual_pending(app, icon_status):
from halo_frame_installer.install import InstallResult
app.operation = 'library'
app.events.put(('complete', InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'artwork': {'status': 'unchanged', 'icon': {'status': icon_status}},
'notes': {'status': 'custom', 'title': 'Halo CE VR — About & controls', 'content': 'Player notes.'}}, None)))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 100
assert app.phase.get() == 'Steam info updated'
assert 'existing custom game note was kept' in app.status.get()
assert not app.notes_button.winfo_manager()
@WINDOWS_GUI
def test_all_operation_tile_captions_fit_minimum_width(app):
app.geometry('900x620')
app.deiconify()
app._show_page(0)
app.update()
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
for choice in app.mode_choices:
for item in choice.find_all():
if choice.type(item) == 'text':
left, top, right, bottom = choice.bbox(item)
assert 0 <= left < right <= choice.winfo_width()
assert 0 <= top < bottom <= choice.winfo_height()
@WINDOWS_GUI
def test_iso_inspection_runs_off_tk_thread_and_window_remains_responsive(app, monkeypatch, tmp_path):
import threading
from types import SimpleNamespace
from halo_frame_installer import gui
real_thread = threading.Thread
started, release = threading.Event(), threading.Event()
workers, inspected_threads, music_loads = [], [], []
def inspect(path, *, cancel_event):
inspected_threads.append(threading.get_ident())
assert path == tmp_path / 'Halo.iso'
assert not cancel_event.is_set()
started.set()
assert release.wait(5)
return SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276')
def schedule(**kwargs):
thread = real_thread(**kwargs)
workers.append(thread)
return thread
monkeypatch.setattr(gui.threading, 'Thread', schedule)
monkeypatch.setattr(gui, 'inspect_image', inspect)
monkeypatch.setattr(app.music, 'load', music_loads.append)
try:
app._inspect(tmp_path / 'Halo.iso')
assert started.wait(2)
assert app.busy and not app.source.get()
assert inspected_threads == [workers[0].ident]
assert workers[0].ident != threading.get_ident()
heartbeat = []
app.after(0, lambda: heartbeat.append(True))
app.update()
assert heartbeat == [True] # Tk processed a timer while validation was blocked.
assert music_loads == [None] # The old preview stops before reading the new ISO.
finally:
release.set()
for worker in workers:
worker.join(3)
_drain_events(app)
assert not app.busy
assert app.source.get() == str(tmp_path / 'Halo.iso')
assert 'Validated 24 original Xbox maps' in app.asset_status.get()
assert music_loads == [None, tmp_path / 'Halo.iso']
@pytest.fixture
def queued_inspection(app, monkeypatch):
from types import SimpleNamespace
from halo_frame_installer import gui
workers, music_loads = [], []
class DeferredThread:
def __init__(self, target, **kwargs):
self.target = target
workers.append(self)
def start(self):
pass
monkeypatch.setattr(gui.threading, 'Thread', DeferredThread)
monkeypatch.setattr(app.music, 'load', music_loads.append)
return SimpleNamespace(workers=workers, music_loads=music_loads,
info=SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276'))
@WINDOWS_GUI
@pytest.mark.parametrize('old_error', [False, True])
@pytest.mark.parametrize('old_finishes_first', [False, True])
def test_late_inspection_results_do_not_replace_new_selection_or_clear_its_busy_state(app, monkeypatch, tmp_path, queued_inspection, old_error, old_finishes_first):
from halo_frame_installer import gui
old, new = tmp_path / 'old.iso', tmp_path / 'new.iso'
cancellations = {}
def inspect(path, *, cancel_event):
cancellations[path] = cancel_event
if path == old and old_error:
raise ValueError('The old image was invalid.')
return queued_inspection.info
monkeypatch.setattr(gui, 'inspect_image', inspect)
app._inspect(old)
old_cancel = app._inspection_cancel
app._inspect(new)
assert old_cancel.is_set()
if old_finishes_first:
queued_inspection.workers[0].target()
_drain_events(app)
assert app.busy and not app.source.get()
assert app.asset_status.get() == 'Checking original Xbox game data…'
queued_inspection.workers[1].target()
_drain_events(app)
if not old_finishes_first:
queued_inspection.workers[0].target()
_drain_events(app)
assert app.source.get() == str(new)
assert app.status.get() == 'Game data validated. Connect your Frame next.'
assert not app.busy
assert cancellations[old].is_set() and not cancellations[new].is_set()
assert queued_inspection.music_loads == [None, None, new]
assert not app._test_dialogs
assert 'old image' not in '\n'.join(app.log_lines)
@WINDOWS_GUI
def test_clearing_source_cancels_inspection_and_ignores_its_late_success(app, monkeypatch, tmp_path, queued_inspection):
from halo_frame_installer import gui
monkeypatch.setattr(gui, 'inspect_image', lambda path, **kwargs: queued_inspection.info)
app._inspect(tmp_path / 'Halo.iso')
cancel = app._inspection_cancel
app.source.set('')
assert cancel.is_set() and not app.busy
queued_inspection.workers[0].target()
_drain_events(app)
assert not app.source.get()
assert app.asset_status.get() == gui.XBOX_REVISION_NOTE
assert queued_inspection.music_loads == [None, None]
assert not app._test_dialogs
@WINDOWS_GUI
@pytest.mark.parametrize('folder', [False, True])
def test_cancelled_source_inspection_keeps_previous_selection_and_does_not_start_music(app, monkeypatch, tmp_path, queued_inspection, folder):
from halo_frame_installer import gui
selected = tmp_path / ('maps' if folder else 'Halo.iso')
if folder:
selected.mkdir()
calls = []
def inspect(path, *, cancel_event):
calls.append((path, cancel_event.is_set()))
# Even a reader finishing after cancellation cannot accept its result.
return queued_inspection.info
monkeypatch.setattr(gui, 'inspect_maps' if folder else 'inspect_image', inspect)
app.source.set('previous.iso')
app._inspect(selected)
app._cancel_setup()
queued_inspection.workers[0].target()
_drain_events(app)
assert calls == [(selected, True)]
assert app.source.get() == 'previous.iso'
assert not app.busy
assert app.phase.get() == 'Game data check cancelled'
assert 'previous selection was kept' in app.status.get()
assert queued_inspection.music_loads == [None]
assert not app._test_dialogs
@WINDOWS_GUI
def test_current_inspection_failure_preserves_selection_and_readable_redacted_error(app, monkeypatch, tmp_path, queued_inspection):
from halo_frame_installer import gui
def invalid(path, *, cancel_event):
raise ValueError('Invalid original Xbox image. offline-secret\nHeader validation failed.')
monkeypatch.setattr(gui, 'inspect_image', invalid)
app.source.set('previous.iso')
app.password_to_redact = 'offline-secret'
app._inspect(tmp_path / 'invalid.iso')
queued_inspection.workers[0].target()
_drain_events(app)
assert not app.busy
assert app.source.get() == 'previous.iso'
assert "Couldn't validate this data" in app.asset_status.get()
assert len(app._test_dialogs) == 1
assert 'Header validation failed.' in '\n'.join(app.log_lines)
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert 'offline-secret' not in app._test_dialogs[0][1]
assert queued_inspection.music_loads == [None]
@WINDOWS_GUI
@pytest.mark.parametrize('folder', [False, True])
def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_credentials(app, monkeypatch, tmp_path, folder):
from halo_frame_installer import gui, install
selected = tmp_path / ('maps' if folder else 'Halo.iso')
if folder:
selected.mkdir()
name = 'inspect_maps' if folder else 'inspect_image'
inspect_source = getattr(gui, name)
inspections, settings_used = [], []
get_settings = app._settings
def remember_settings():
settings = get_settings()
settings_used.append(settings)
return settings
def cancel_validation(path, *, cancel_event):
assert cancel_event is app.cancel
inspections.append(path)
# Model Cancel arriving as the worker starts its second validation.
cancel_event.set()
return inspect_source(path, cancel_event=cancel_event)
monkeypatch.setattr(app, '_settings', remember_settings)
monkeypatch.setattr(gui, name, cancel_validation)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
for action in ('extract_image', 'copy_maps'):
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Cancelled validation began installation.'))
app.source.set(str(selected))
app.authorized.set(True)
app.steam_closed.set(True)
app.password.set('offline-secret')
app._install()
_drain_events(app)
assert inspections == [selected]
assert app.cancel.is_set()
assert not app.busy and not app.installing
assert not app.password.get() and not settings_used[0].password
assert not app.password_to_redact
assert app.source.get() == str(selected)
assert 'cancelled' in '\n'.join(app.log_lines).lower()
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert float(app.progress['value']) == 0
assert not _disabled(app.primary_button)
@WINDOWS_GUI
@pytest.mark.parametrize('operation', ['install', 'library'])
def test_completed_game_operation_reports_pending_connection_cleanup(app, operation):
from halo_frame_installer.install import InstallResult
app.operation = operation
app.password_to_redact = 'offline-secret'
result = InstallResult('/home/steamos/Games/HaloCENativeVR', False,
{'status': 'added', 'notes': {'status': 'added'}}, None,
cleanup_warning='Unplug the USB cable; cleanup is pending. offline-secret')
app.events.put(('complete', result))
app.events.put(('clear_password',))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert app.phase.get() == 'Connection cleanup needs attention'
assert 'game operation finished' in app.status.get()
assert 'Unplug the USB cable' in app.status.get()
assert not app.retry_button.winfo_manager()
assert 'offline-secret' not in app.status.get()
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert app._test_dialogs[-1][0] == 'Connection cleanup needs attention'
assert not app.busy and not app.installing
@WINDOWS_GUI
@pytest.mark.parametrize('pending', [False, True])
def test_uninstall_recovery_reports_retained_files_and_earlier_save_backups(app, pending):
from halo_frame_installer.install import UninstallResult
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a'*32
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unverified'
app.password_to_redact = 'offline-secret'
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', not pending, False, None,
{'status': 'removed'}, None, removal_pending=pending,
removal_warning='Earlier uninstall files ' + ('were retained' if pending else 'were removed') + '. offline-secret',
recovered_save_backup_paths=(backup,), retained_quarantine_paths=(retained,) if pending else ())
app.events.put(('uninstall_complete', result))
app.events.put(('clear_password',))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == (99 if pending else 100)
assert 'Earlier campaign save backup: ' + backup in app.status.get()
assert 'already absent' not in app.status.get()
assert 'offline-secret' not in app.status.get()
assert 'offline-secret' not in '\n'.join(app.log_lines)
if pending:
assert 'Preserved uninstall folder: ' + retained in app.status.get()
assert 'Removal is not complete' in app.status.get()
assert 'has been uninstalled' not in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
else:
assert 'has been uninstalled' in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall complete'
@WINDOWS_GUI
def test_uninstalled_game_reports_pending_connection_cleanup(app):
from halo_frame_installer.install import UninstallResult
result = UninstallResult('/home/steamos/Games/HaloCENativeVR', True, False, None,
{'status': 'removed'}, None, cleanup_warning='Unplug the USB cable before retrying setup.')
app.events.put(('uninstall_complete', result))
app.events.put(('idle',))
_drain_events(app)
assert float(app.progress['value']) == 99
assert 'has been uninstalled' in app.status.get()
assert 'Connection cleanup needs attention' in app.status.get()
assert 'Unplug the USB cable' in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
+620 -8
View File
@@ -10,8 +10,8 @@ from unittest.mock import Mock
import pytest
from halo_frame_installer.install import EXPECTED_MAPS, Installer, SOURCE_COMMIT, map_manifest, parse_result
from halo_frame_installer.ssh import CancelledError, Settings, SSHError
from halo_frame_installer.install import EXPECTED_MAPS, Installer, SOURCE_COMMIT, close_connection, map_manifest, parse_result
from halo_frame_installer.ssh import CancelledError, RemoteTimeoutError, Settings, SSHError
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@@ -136,7 +136,7 @@ class FakeConnection:
raise AssertionError(argv)
def test_add_to_steam_retry_reuses_files_and_no_build_or_asset_upload():
def test_add_to_steam_retry_reuses_files_without_build_or_map_upload():
fake = FakeConnection(Settings("frame", "private", close_steam_for_shortcut=True))
result = Installer(lambda settings: fake, RESOURCES).add_to_steam(fake.settings)
assert result.reused and result.steam_appid == 3732925724
@@ -146,6 +146,18 @@ def test_add_to_steam_retry_reuses_files_and_no_build_or_asset_upload():
assert any(remote.endswith("/artwork/halo-ce-landscape.png") for _, remote in fake.uploads)
assert not any("build" in command[2:] for command in fake.commands)
assert not any("--close-steam" in command for command in fake.commands)
assert fake.commands[0][-1] == 'preflight-library'
for name in ('halo-ce-hero.jpg', 'halo-ce-logo.png', 'halo-ce-icon.png', 'steam_notes.py'):
assert any(remote.endswith('/' + name) for _, remote in fake.uploads)
assert not any(Path(local).name in ('build-native.sh', 'frame-controls.patch') for local, _ in fake.uploads)
def test_library_update_requires_existing_game_and_cannot_start_build():
fake = FakeConnection(Settings('frame', 'private'), existing=False)
with pytest.raises(SSHError, match='before updating'):
Installer(lambda settings: fake, RESOURCES).add_to_steam(fake.settings)
assert fake.closed and not fake.uploads
assert len(fake.commands) == 1 and fake.commands[0][-1] == 'preflight-library'
def test_setup_reports_success_only_after_ssh_disconnects():
@@ -161,6 +173,60 @@ def test_setup_reports_success_only_after_ssh_disconnects():
assert seen[-3:] == ['disconnect', 'disconnected', 'complete']
@pytest.mark.parametrize("mode", ["fresh", "repair"])
def test_normal_install_and_repair_automatically_publish_steam_information(tmp_path, mode):
steam_info = {"status": "added", "appid": 3732925724, "libraryAdded": True,
"artwork": {"status": "added", "installed": ["cover", "header", "hero", "logo"],
"icon": {"status": "added"}},
"notes": {"ok": True, "status": "added", "title": "About and controls"}}
class SuccessfulConnection(FakeConnection):
def __init__(self, settings):
super().__init__(settings, existing=mode == "repair")
self.events = []
def put(self, local, remote, **kwargs):
self.events.append(("upload", Path(local).name))
super().put(local, remote, **kwargs)
def run(self, argv, **kwargs):
if argv[1] != "-c" and argv[2] in ("build", "finalize", "shortcut"):
self.commands.append(argv)
self.events.append(("step", argv[2]))
responses = {"build": {"built": True},
"finalize": {"gamePath": "/home/steamos/Games/HaloCENativeVR",
"reused": False, "repaired": mode == "repair"},
"shortcut": steam_info}
return "HFI_RESULT " + json.dumps(responses[argv[2]])
return super().run(argv, **kwargs)
settings = Settings("frame", "private", reinstall_existing=mode == "repair",
close_steam_for_shortcut=True)
fake = SuccessfulConnection(settings)
maps = None
if mode == "fresh":
maps = tmp_path / "maps"
maps.mkdir()
for name in EXPECTED_MAPS:
(maps / name).write_bytes(name.encode())
result = Installer(lambda _: fake, RESOURCES).run(settings, maps)
steps = [argv[2] for argv in fake.commands if argv[1] != "-c"]
assert steps == ["prepare", "build", "finalize", "shortcut"]
assert fake.commands[0][3] == "preflight"
assert steps.count("shortcut") == 1
assert fake.events.index(("step", "finalize")) < fake.events.index(("step", "shortcut"))
for filename in ("steam_notes.py", "halo-ce-cover.jpg", "halo-ce-landscape.png",
"halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
assert fake.events.index(("upload", filename)) < fake.events.index(("step", "build"))
assert result.steam == steam_info
assert result.repaired is (mode == "repair")
assert fake.closed
assert not any("--close-steam" in argv for argv in fake.commands)
uploaded_maps = [remote for _, remote in fake.uploads if remote.endswith(".map")]
assert len(uploaded_maps) == (len(EXPECTED_MAPS) if mode == "fresh" else 0)
def test_default_existing_install_rebuilds_without_uploading_maps():
fake = FakeConnection(Settings("frame", "private"))
with pytest.raises(SSHError, match="build failed"):
@@ -182,6 +248,158 @@ def test_failed_build_does_not_finalize_or_modify_game(tmp_path):
assert fake.closed
def test_step_failure_is_not_replaced_by_a_failed_disconnect():
class ForwardCleanupFailure(FakeConnection):
def close(self):
raise SSHError("USB forward cleanup failed")
fake = ForwardCleanupFailure(Settings("frame", "private"))
with pytest.raises(SSHError, match="build failed"):
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None)
def test_cancelled_install_is_not_replaced_by_a_failed_disconnect():
class ForwardCleanupFailure(FakeConnection):
def close(self):
raise SSHError("USB forward cleanup failed")
event = threading.Event()
event.set()
fake = ForwardCleanupFailure(Settings("frame", "private"))
with pytest.raises(CancelledError):
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, cancel_event=event)
def test_close_connection_reports_a_swallowed_cleanup_failure():
class FailingClose:
settings = Settings('', 'private', transport='usb')
def close(self):
raise SSHError("SSH has closed, but the temporary USB forward could not be removed.")
seen = []
warning = close_connection(FailingClose(), lambda *args: seen.append(args))
assert warning.startswith('SSH has closed, but the temporary USB forward could not be removed.')
assert 'Unplug the USB cable' in warning
assert seen == [('detail', warning, None)]
def test_successful_install_with_failed_cleanup_retains_result_and_needs_attention():
class ForwardCleanupFailure(FakeConnection):
def close(self):
self.closed = True
raise SSHError('USB forward cleanup failed private')
fake = ForwardCleanupFailure(Settings('', 'private', transport='usb', reinstall_existing=False))
events = []
result = Installer(lambda settings: fake, RESOURCES).run(fake.settings, None,
lambda *event: events.append(event))
assert result.steam_appid == 3732925724 and result.reused
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
assert 'private' not in result.cleanup_warning
assert 'disconnected' not in [event[0] for event in events]
assert 'complete' not in [event[0] for event in events]
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
@pytest.mark.parametrize('primary_error', [CancelledError, RemoteTimeoutError, SSHError])
@pytest.mark.parametrize('original_active', [False, True])
def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, original_active):
class BrokenConnection(FakeConnection):
def is_active(self):
return original_active
def run(self, argv, **kwargs):
if argv[1] != '-c' and argv[2] == 'build':
self.commands.append(argv)
if primary_error is not SSHError:
kwargs['on_cancel']()
messages = {CancelledError: 'Installation cancelled.',
RemoteTimeoutError: 'The remote step timed out.',
SSHError: 'The remote connection stopped before this step could be verified.'}
raise primary_error(messages[primary_error])
if argv[1] != '-c' and argv[2] == 'cancel':
self.commands.append(argv)
raise SSHError('Connection stopped')
return super().run(argv, **kwargs)
class RecoveryConnection(FakeConnection):
def connect(self, *, timeout):
self.connect_timeout = timeout
def run(self, argv, **kwargs):
self.commands.append(argv)
assert kwargs['timeout'] == 45 and 'cancel_event' not in kwargs
return 'HFI_RESULT {"cancelled":true}'
settings = Settings('frame', 'private', accept_host_key=Mock(side_effect=AssertionError('No new approval')))
original = BrokenConnection(settings)
created = []
def factory(current):
if not created:
created.append(original)
return original
recovery = RecoveryConnection(current)
created.append(recovery)
return recovery
events = []
with pytest.raises(primary_error) as error:
Installer(factory, RESOURCES).run(settings, None, lambda *event: events.append(event))
assert len(created) == 2
recovery = created[1]
build = next(command for command in original.commands if command[1] != '-c' and command[2] == 'build')
expected_id = build[build.index('--run-id') + 1]
assert recovery.commands == [['python3', '/home/steamos/.cache/halo-frame-installer/resources/remote_install.py', 'cancel', '--run-id', expected_id]]
assert recovery.connect_timeout == 10
assert recovery.settings.known_host_fingerprint == 'SHA256:public'
assert recovery.settings.accept_host_key is None and recovery.settings.known_host_fingerprints == {}
assert recovery.settings.password == '' and settings.password == 'private'
assert original.closed and recovery.closed
assert 'could not be confirmed' not in str(error.value)
assert not any(command[2] in ('finalize', 'shortcut') for command in original.commands if command[1] != '-c')
assert any(event[1] == 'Remote build cancellation confirmed.' for event in events)
@pytest.mark.parametrize('failure', ['unapproved', 'changed-key', 'unconfirmed-response'])
def test_unconfirmed_remote_stop_is_visible_without_masking_cancel(failure):
class BrokenConnection(FakeConnection):
def is_active(self):
return False
def run(self, argv, **kwargs):
if argv[1] != '-c' and argv[2] == 'build':
self.commands.append(argv)
raise CancelledError('Installation cancelled. Existing games and saves were kept.')
return super().run(argv, **kwargs)
settings = Settings('frame', 'private')
original = BrokenConnection(settings)
if failure == 'unapproved':
original.host_fingerprint = None
created = []
def factory(current):
if not created:
created.append(original)
return original
recovery = FakeConnection(current)
recovery.connect = Mock(side_effect=SSHError('SSH host key has changed.')) if failure == 'changed-key' else Mock()
recovery.run = Mock(return_value='HFI_RESULT {"cancelled":"true"}')
created.append(recovery)
return recovery
with pytest.raises(CancelledError) as error:
Installer(factory, RESOURCES).run(settings, None)
first_line = str(error.value).splitlines()[0]
assert 'cancelled' in first_line and 'Remote stopping could not be confirmed' in first_line
assert 'In the Frame\'s terminal, run: python3 /home/steamos/.cache/' in str(error.value)
assert 'private' not in str(error.value) and original.closed
assert len(created) == (1 if failure == 'unapproved' else 2)
if len(created) == 2:
assert created[1].closed and created[1].settings.password == ''
def test_cleanup_display_failure_cannot_replace_the_primary_error():
fake = FakeConnection(Settings('frame', 'private'))
fake.close = Mock(side_effect=EOFError('private'))
def progress(stage, message, percent=None):
if stage == 'detail':
raise ValueError('Display failed')
with pytest.raises(SSHError, match='build failed') as error:
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, progress)
assert 'private' not in str(error.value)
def test_cancelled_before_connect_has_no_remote_mutation():
event = threading.Event()
event.set()
@@ -234,6 +452,7 @@ def remote(tmp_path, monkeypatch):
module.HOME = home
module.CACHE = home / "cache"
module.GAME = home / "Games/HaloCENativeVR"
monkeypatch.setattr(module.os, "getuid", lambda: home.stat().st_uid, raising=False)
monkeypatch.setattr(module, "game_closed", lambda: None)
return module
@@ -253,6 +472,51 @@ def xbox_maps(directory, remote):
return {"files": files, "totalBytes": len(header) * len(files)}
def test_library_preflight_does_not_require_compiler_or_build_space(remote, monkeypatch):
owner_uid = remote.HOME.stat().st_uid
monkeypatch.setattr(remote.os, 'getuid', lambda: owner_uid, raising=False)
monkeypatch.setattr(remote.pwd, 'getpwuid', lambda uid: types.SimpleNamespace(pw_name='steamos'))
monkeypatch.setattr(remote.pathlib.Path, 'home', lambda: remote.HOME)
monkeypatch.setattr(remote.platform, 'machine', lambda: 'aarch64')
monkeypatch.setattr(remote, 'os_release', lambda: {'ID': 'steamos'})
build_tools = Mock(side_effect=AssertionError('Library info must not require build tools'))
monkeypatch.setattr(remote.shutil, 'which', build_tools)
monkeypatch.setattr(remote.shutil, 'disk_usage', build_tools)
monkeypatch.setattr(remote, 'command', build_tools)
verified = {'gamePath': str(remote.GAME), 'mapsVerified': True, 'sourceCommit': SOURCE_COMMIT}
monkeypatch.setattr(remote, 'existing_install', lambda: verified)
result = remote.preflight_library()
assert result['existing'] == verified and 'uninstallSupported' not in result
assert result['cachePath'] == str(remote.CACHE)
build_tools.assert_not_called()
def test_library_preflight_requires_verified_game(remote, monkeypatch):
monkeypatch.setattr(remote, 'preflight_uninstall', lambda: {'home': str(remote.HOME)})
monkeypatch.setattr(remote, 'existing_install', lambda: None)
with pytest.raises(ValueError, match='before updating'):
remote.preflight_library()
@pytest.mark.parametrize('shortcut_status', ['added', 'manual'])
def test_remote_notes_enrichment_is_independent_of_verified_shortcut_status(remote, monkeypatch, shortcut_status):
monkeypatch.setattr(remote, 'existing_install', lambda: {'mapsVerified': True})
monkeypatch.setattr(sys, 'path', list(sys.path))
register = Mock(return_value={'status': shortcut_status, 'appid': 3000000001})
notes = Mock(return_value={'status': 'manual', 'content': 'Description to copy'})
monkeypatch.setitem(sys.modules, 'steam_shortcut', types.SimpleNamespace(add_native_shortcut=register))
monkeypatch.setitem(sys.modules, 'steam_notes', types.SimpleNamespace(add_notes=notes))
result = remote.shortcut()
assert result['status'] == shortcut_status
register.assert_called_once_with(remote.HOME, remote.GAME, close_steam=False)
if shortcut_status == 'added':
notes.assert_called_once_with(remote.HOME, remote.GAME, 3000000001)
assert result['notes']['status'] == 'manual'
else:
notes.assert_not_called()
assert 'notes' not in result
def test_remote_rejects_bogus_maps_and_traversal(remote, tmp_path):
manifest = xbox_maps(tmp_path / "data", remote)
assert remote.verify_maps(tmp_path / "data", manifest) == 24 * 2048
@@ -286,10 +550,10 @@ def test_remote_detects_changed_original_xbox_header(remote, tmp_path):
remote.verify_maps(directory, manifest)
def test_config_repair_preserves_save_location_and_user_preferences(remote):
old = '[paths]\nsaves = "/another/save/location"\n\n[audio]\nvolume = 0.4\n\n[vr]\naim="head"\nturn="snap"\nrefresh_rate=90.0\n\n[[plugins]]\nname="custom"\n'
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))
assert result["paths"]["saves"] == "/another/save/location"
assert result["paths"]["saves"] == str(remote.save_root())
assert result["audio"]["volume"] == 0.4
assert result["vr"]["aim"] == "controller"
assert result["vr"]["turn"] == "snap" and result["vr"]["refresh_rate"] == 90.0
@@ -302,6 +566,71 @@ def test_config_repair_refuses_semantic_changes_inside_multiline_string(remote):
remote.merge_config(old)
@pytest.mark.parametrize('value', ['/another/save/location', '../HaloCENativeVR/save', 'save', '', False])
def test_new_config_refuses_legacy_external_or_ambiguous_save_paths(remote, value):
original = '[paths]\nsaves = ' + json.dumps(value) + '\n'
with pytest.raises(ValueError, match='owned|own game folder|version 1.4'):
remote.merge_config(original)
def test_new_config_missing_paths_gets_versioned_defaults(remote):
updated = remote.tomllib.loads(remote.merge_config('[audio]\nvolume = 0.4\n'))
assert updated['paths'] == {'data': str(remote.GAME), 'saves': str(remote.save_root())}
assert updated['audio']['volume'] == 0.4
assert updated['vr']['aim'] == 'controller' and updated['vr']['movement'] == 'head'
assert updated['vr']['sun_glow_strength'] == 0.5
assert updated['network']['coop_enemies_mode'] == 'none'
def test_repair_preserves_custom_sun_glow_strength(remote):
updated = remote.tomllib.loads(remote.merge_config('[vr]\nsun_glow_strength=0.25\n'))
assert updated['vr']['sun_glow_strength'] == 0.25
@pytest.mark.parametrize('repair', [False, True])
@pytest.mark.parametrize('unsafe', ['legacy-save-config', 'copied-experimental-config', 'symlinked-save-directory', 'missing-paths'])
def test_new_reuse_and_repair_guard_save_format_and_keep_experimental_instance(remote, repair, unsafe):
experimental = remote.GAME.parent / 'HaloCENativeVRExperimental'
(experimental / 'save').mkdir(parents=True)
(experimental / 'halo').write_bytes(b'experimental executable')
(experimental / 'save/checkpoint').write_bytes(b'experimental checkpoint')
game = remote.GAME
game.mkdir()
header = bytearray(64)
header[:6] = b'\x7fELF\x02\x01'
struct.pack_into('<H', header, 18, 183)
for name in ('halo', 'libSDL3.so.0'):
(game / name).write_bytes(header)
maps = xbox_maps(game, remote)
(game / 'xbox-data-manifest.json').write_text(json.dumps(maps))
(game / remote.MARKER).write_text(json.dumps({'owner': remote.OWNER,
'sourceCommit': remote.SOURCE_COMMIT,
'files': {name: remote.digest(game / name) for name in ('halo', 'libSDL3.so.0')}}))
if unsafe == 'legacy-save-config':
config = {'paths': {'data': str(game), 'saves': str(game / 'save')}}
elif unsafe == 'copied-experimental-config':
config = {'paths': {'data': str(experimental), 'saves': str(experimental / 'save')}}
elif unsafe == 'missing-paths':
config = {'audio': {'volume': 0.4}}
else:
try:
remote.save_root().symlink_to(experimental / 'save', target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip('This host cannot create directory symlinks')
config = {'paths': {'data': str(game), 'saves': str(remote.save_root())}}
original = '\n'.join('[' + section + ']\n' + '\n'.join(key + ' = ' + json.dumps(value)
for key, value in entries.items()) for section, entries in config.items()) + '\n'
(game / 'config.toml').write_text(original)
if unsafe == 'missing-paths' and repair:
assert remote.existing_install(repair=True)['mapsVerified']
else:
with pytest.raises(ValueError, match='version 1.4|owned|own game folder|Symbolic links'):
remote.existing_install(repair=repair)
assert (game / 'config.toml').read_text() == original
assert (experimental / 'halo').read_bytes() == b'experimental executable'
assert (experimental / 'save/checkpoint').read_bytes() == b'experimental checkpoint'
def test_repair_backup_and_replacements_preserve_saves_and_unrelated_files(remote):
game = remote.GAME
game.mkdir(parents=True)
@@ -378,7 +707,7 @@ def test_full_repair_rebuilds_damaged_native_files_preserves_maps_saves_and_conf
(game / "xbox-data-manifest.json").write_text(json.dumps(maps))
(game / "halo").write_bytes(b"damaged executable")
(game / "libSDL3.so.0").write_bytes(b"old library")
(game / "config.toml").write_text('[paths]\nsaves="/my/saves"\n[audio]\nvolume=0.3\n[vr]\naim="head"\nturn="snap"\n')
(game / "config.toml").write_text('[paths]\nsaves=' + json.dumps(str(game / "save")) + '\n[audio]\nvolume=0.3\n[vr]\naim="head"\nturn="snap"\n')
(game / "save").mkdir()
(game / "save/checkpoint").write_bytes(b"campaign progress")
(game / "maps/custom.map").write_bytes(b"user mod")
@@ -405,15 +734,298 @@ def test_full_repair_rebuilds_damaged_native_files_preserves_maps_saves_and_conf
struct.pack_into("<H", header, 18, 183)
(build / "halo").write_bytes(header + b"new native game")
(build / "libSDL3.so.0").write_bytes(header + b"new native library")
(build / "brokers.txt").write_text("broker.example.org:1883\n")
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
response = remote.finalize(identifier, repair=True)
assert response["repaired"] is True and response["mapsReinstalled"] is False
assert (game / "halo").read_bytes() == header + b"new native game"
assert (game / "save/checkpoint").read_bytes() == b"campaign progress"
assert (game / "brokers.txt").read_text() == "broker.example.org:1883\n"
assert remote.read_marker(game / remote.MARKER)["files"]["brokers.txt"] == remote.digest(game / "brokers.txt")
assert (game / "maps/custom.map").read_bytes() == b"user mod"
merged = remote.tomllib.loads((game / "config.toml").read_text())
assert merged["paths"]["saves"] == "/my/saves"
assert merged["paths"]["saves"] == str(remote.save_root())
assert remote.save_root().is_dir() and not any(remote.save_root().iterdir())
assert merged["audio"]["volume"] == 0.3
assert merged["vr"]["aim"] == "controller" and merged["vr"]["turn"] == "snap"
assert remote.read_marker(game / remote.MARKER)["sourceCommit"] == remote.SOURCE_COMMIT
assert remote.existing_install()["mapsVerified"] is True
def native_install(remote, *, source=None, save_path=None):
game = remote.GAME
game.mkdir(parents=True)
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
struct.pack_into("<H", header, 18, 183)
for name in ("halo", "libSDL3.so.0"):
(game / name).write_bytes(header + b"previous native program")
manifest = xbox_maps(game, remote)
(game / "xbox-data-manifest.json").write_text(json.dumps(manifest))
save_path = save_path or game / "save"
save_path.mkdir(parents=True)
(save_path / "checkpoint").write_bytes(b"incompatible previous checkpoint")
(game / "config.toml").write_text('[paths]\ndata = ' + json.dumps(str(game))
+ '\nsaves = ' + json.dumps(str(save_path)) + '\n[audio]\nvolume = 0.3\n[vr]\nturn = "snap"\n')
marker = {"owner": remote.OWNER, "sourceCommit": source or remote.LEGACY_SOURCE_COMMIT,
"files": {name: remote.digest(game / name) for name in ("halo", "libSDL3.so.0")}}
(game / remote.MARKER).write_text(json.dumps(marker))
return manifest, save_path
def built_run(remote, manifest, identifier, *, existing=True):
remote.owned(remote.CACHE)
resources = remote.CACHE / "resources"
resources.mkdir(exist_ok=True)
(resources / "frame-controls.patch").write_bytes(b"tested gameplay patch")
directory = remote.CACHE / "runs" / identifier
remote.owned(directory)
metadata = remote.read_marker(directory / remote.MARKER)
metadata["mapsOrigin"] = "existing" if existing else "upload"
(directory / remote.MARKER).write_text(json.dumps(metadata))
(directory / "stage").mkdir()
upload = directory / "upload"
upload.mkdir()
if not existing:
manifest = xbox_maps(upload, remote)
(upload / "xbox-data-manifest.json").write_text(json.dumps(manifest))
build = directory / "src/build/linux_arm64"
build.mkdir(parents=True)
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
struct.pack_into("<H", header, 18, 183)
for name in ("halo", "libSDL3.so.0"):
(build / name).write_bytes(header + b"new native program")
(build / "brokers.txt").write_text("broker.example.org:1883\n")
return directory
@pytest.mark.parametrize("kind", ["default", "custom-internal", "external"])
def test_verified_stable_upgrade_keeps_old_checkpoints_and_backs_up_config(remote, monkeypatch, kind):
path = {"default": remote.GAME / "save", "custom-internal": remote.GAME / "profiles/custom",
"external": remote.HOME / "external-checkpoints"}[kind]
manifest, previous = native_install(remote, save_path=path)
checkpoint = previous / "checkpoint"
before = (checkpoint.read_bytes(), checkpoint.stat().st_mtime_ns)
old_config = (remote.GAME / "config.toml").read_bytes()
old_program = (remote.GAME / "halo").read_bytes()
closed = Mock()
monkeypatch.setattr(remote, "game_closed", closed)
verified = remote.existing_install()
assert verified["needsUpgrade"] and verified["mapsVerified"]
assert verified["previousSavePaths"] == [str(previous)]
closed.assert_called_once()
identifier = "e" * 32
built_run(remote, manifest, identifier)
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
result = remote.finalize(identifier, repair=True)
assert result["repaired"] and not result["mapsReinstalled"]
assert result["previousSavePaths"] == [str(previous)]
assert (checkpoint.read_bytes(), checkpoint.stat().st_mtime_ns) == before
assert (Path(result["backupPath"]) / "config.toml").read_bytes() == old_config
assert (Path(result["backupPath"]) / "halo").read_bytes() == old_program
settings = remote.tomllib.loads((remote.GAME / "config.toml").read_text())
assert settings["paths"] == {"data": str(remote.GAME), "saves": str(remote.save_root())}
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.0" 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.
(remote.save_root() / "checkpoint").write_bytes(b"new compatible checkpoint")
built_run(remote, manifest, "f" * 32)
remote.finalize("f" * 32, repair=True)
assert remote.read_marker(remote.GAME / remote.MARKER)["previousSavePaths"] == [str(previous)]
assert checkpoint.read_bytes() == before[0]
assert (remote.save_root() / "checkpoint").read_bytes() == b"new compatible checkpoint"
inventory = remote.uninstall_inventory(remote.GAME)
selected, external = remote.uninstall_save_files(inventory)
assert str(Path("save-v1.4/checkpoint")) in selected
if kind == "external":
assert external == [str(previous)] and not any("external-checkpoints" in item for item in selected)
else:
assert str(checkpoint.relative_to(remote.GAME)) in selected
@pytest.mark.parametrize("trigger", ["marker-failure", "cancel-after-marker"])
def test_failed_upgrade_restores_original_program_config_marker_and_all_checkpoints(remote, monkeypatch, trigger):
manifest, previous = native_install(remote)
old = {name: (remote.GAME / name).read_bytes() for name in ("halo", "libSDL3.so.0", "config.toml", remote.MARKER)}
checkpoint = previous / "checkpoint"
checkpoint_signature = (checkpoint.read_bytes(), checkpoint.stat().st_mtime_ns)
identifier = "a" * 32
directory = built_run(remote, manifest, identifier)
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
replace = remote.os.replace
def fail_marker(source, target):
if Path(source) == directory / "stage" / remote.MARKER:
if trigger == "marker-failure":
raise OSError("injected final marker failure")
replace(source, target)
(directory / "cancelled").write_text("cancel immediately after marker publication")
return
return replace(source, target)
monkeypatch.setattr(remote.os, "replace", fail_marker)
with pytest.raises((OSError, ValueError), match="marker failure|cancelled"):
remote.finalize(identifier, repair=True)
assert {name: (remote.GAME / name).read_bytes() for name in old} == old
assert (checkpoint.read_bytes(), checkpoint.stat().st_mtime_ns) == checkpoint_signature
assert remote.save_root().is_dir() and not any(remote.save_root().iterdir())
assert remote.existing_install()["needsUpgrade"] # Empty failed-run directory permits retry.
@pytest.mark.parametrize("damage", ["program-hash", "map", "unknown-revision", "occupied-new-save"])
def test_automatic_upgrade_refuses_unverified_or_unknown_state(remote, damage):
_, previous = native_install(remote)
config = (remote.GAME / "config.toml").read_bytes()
if damage == "program-hash":
with (remote.GAME / "halo").open("ab") as stream:
stream.write(b"modified")
elif damage == "map":
(remote.GAME / "maps/a10.map").write_bytes(b"invalid map")
elif damage == "unknown-revision":
marker = remote.read_marker(remote.GAME / remote.MARKER)
marker["sourceCommit"] = "unknown source"
(remote.GAME / remote.MARKER).write_text(json.dumps(marker))
else:
remote.save_root().mkdir()
(remote.save_root() / "checkpoint").write_bytes(b"unknown save format")
with pytest.raises(ValueError):
remote.existing_install()
assert (remote.GAME / "config.toml").read_bytes() == config
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
def test_legacy_library_and_finalize_cannot_shortcut_the_upgrade(remote, monkeypatch):
manifest, _ = native_install(remote)
monkeypatch.setattr(remote, "preflight_uninstall", lambda: {"home": str(remote.HOME)})
with pytest.raises(ValueError, match="Use Install"):
remote.preflight_library()
with pytest.raises(ValueError, match="Use Install"):
remote.shortcut()
built_run(remote, manifest, "b" * 32)
with pytest.raises(ValueError, match="needs a version 1.4 upgrade"):
remote.build("b" * 32)
with pytest.raises(ValueError, match="needs a version 1.4 upgrade"):
remote.finalize("b" * 32)
def test_install_automatically_promotes_verified_legacy_source_to_repair_without_map_upload():
class UpgradeConnection(FakeConnection):
def run(self, argv, **kwargs):
if argv[1] == "-c":
reply = json.loads(super().run(argv, **kwargs).removeprefix("HFI_RESULT "))
reply["existing"]["needsUpgrade"] = True
return "HFI_RESULT " + json.dumps(reply)
if argv[2] in ("build", "finalize"):
self.commands.append(argv)
return "HFI_RESULT " + json.dumps({"gamePath": "/home/steamos/Games/HaloCENativeVR", "repaired": True})
return super().run(argv, **kwargs)
settings = Settings("frame", "private", reinstall_existing=False)
fake = UpgradeConnection(settings)
progress = []
result = Installer(lambda _: fake, RESOURCES).run(settings, None, lambda *args: progress.append(args))
assert result.repaired and not result.reused and fake.closed
steps = [argv for argv in fake.commands if argv[1] != "-c"]
assert [argv[2] for argv in steps] == ["prepare", "build", "finalize", "shortcut"]
assert all("--repair" in argv for argv in steps)
assert "--use-existing-maps" in steps[0]
assert all(not remote.endswith(".map") for _, remote in fake.uploads)
assert any(args[0] == "upgrade" and "save-v1.4" in args[1] for args in progress)
def test_library_registration_rejects_legacy_preflight_response_without_upload_or_build():
class LegacyConnection(FakeConnection):
def run(self, argv, **kwargs):
reply = json.loads(super().run(argv, **kwargs).removeprefix("HFI_RESULT "))
reply["existing"]["needsUpgrade"] = True
return "HFI_RESULT " + json.dumps(reply)
fake = LegacyConnection(Settings("frame", "private"))
with pytest.raises(SSHError, match="Use Install"):
Installer(lambda _: fake, RESOURCES).add_to_steam(fake.settings)
assert fake.closed and not fake.uploads and len(fake.commands) == 1
def test_keep_saves_retains_both_known_roots_even_without_config(remote):
native_install(remote)
(remote.GAME / "config.toml").unlink()
remote.save_root().mkdir()
(remote.save_root() / "checkpoint").write_bytes(b"new checkpoint")
selected, external = remote.uninstall_save_files(remote.uninstall_inventory(remote.GAME))
assert selected == [str(Path("save-v1.4/checkpoint")), str(Path("save/checkpoint"))] and not external
def frame_preflight(remote, monkeypatch, free):
monkeypatch.setattr(remote.pwd, "getpwuid", lambda uid: types.SimpleNamespace(pw_name="steamos"))
monkeypatch.setattr(remote.pathlib.Path, "home", lambda: remote.HOME)
monkeypatch.setattr(remote.platform, "machine", lambda: "aarch64")
monkeypatch.setattr(remote, "os_release", lambda: {"ID": "steamos"})
monkeypatch.setattr(remote.shutil, "which", lambda name: name)
monkeypatch.setattr(remote.shutil, "disk_usage", lambda path: types.SimpleNamespace(free=free))
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: '{"host":{"security":{"rootless":true}}}')
library = remote.HOME / "steamvr/linuxarm64/runtime.so"
library.parent.mkdir(parents=True)
library.write_bytes(b"test runtime")
runtime = remote.HOME / ".config/openxr/1/active_runtime.json"
runtime.parent.mkdir(parents=True)
runtime.write_text(json.dumps({"runtime": {"library_path": str(library)}}))
def test_automatic_upgrade_requires_build_space_and_closed_game(remote, monkeypatch):
native_install(remote)
frame_preflight(remote, monkeypatch, free=remote.MIN_FREE_BYTES - 1)
with pytest.raises(ValueError, match="12 GiB"):
remote.preflight()
monkeypatch.setattr(remote.shutil, "disk_usage", lambda path: types.SimpleNamespace(free=remote.MIN_FREE_BYTES))
closed = Mock(side_effect=ValueError("game is running"))
monkeypatch.setattr(remote, "game_closed", closed)
with pytest.raises(ValueError, match="game is running"):
remote.preflight()
closed.assert_called_once()
monkeypatch.setattr(remote, "game_closed", lambda: None)
assert remote.preflight()["existing"]["needsUpgrade"]
def test_fresh_install_publishes_only_new_save_format(remote, monkeypatch):
identifier = "c" * 32
built_run(remote, {}, identifier, existing=False)
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
monkeypatch.setattr(remote, "rename_noreplace", lambda source, target: source.rename(target))
result = remote.finalize(identifier)
assert not result["reused"] and remote.save_root().is_dir()
assert not (remote.GAME / "save").exists()
settings = remote.tomllib.loads((remote.GAME / "config.toml").read_text())
assert settings["paths"]["saves"] == str(remote.save_root())
assert remote.read_marker(remote.GAME / remote.MARKER)["previousSavePaths"] == []
assert not remote.existing_install()["needsUpgrade"]
def test_legacy_upgrade_refuses_previous_save_metadata_overflow_before_any_replacement(remote):
native_install(remote)
marker = remote.read_marker(remote.GAME / remote.MARKER)
marker["previousSavePaths"] = [str(remote.GAME / ("previous-" + str(index))) for index in range(32)]
(remote.GAME / remote.MARKER).write_text(json.dumps(marker))
config = (remote.GAME / "config.toml").read_bytes()
with pytest.raises(ValueError, match="save-location count"):
remote.existing_install()
assert (remote.GAME / "config.toml").read_bytes() == config and not remote.save_root().exists()
def test_recognized_codex_legacy_marker_upgrades_without_changing_old_checkpoint(remote, monkeypatch):
manifest, previous = native_install(remote)
marker = remote.read_marker(remote.GAME / remote.MARKER)
marker["owner"] = "codex-halo-native-frame-20261005"
marker["build"] = {"sourceCommit": marker.pop("sourceCommit")}
(remote.GAME / remote.MARKER).unlink()
codex = remote.GAME / ".codex-halo-native-install.json"
codex.write_text(json.dumps(marker))
before = codex.read_bytes()
assert remote.existing_install()["needsUpgrade"]
built_run(remote, manifest, "d" * 32)
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
remote.finalize("d" * 32, repair=True)
assert codex.read_bytes() == before
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
assert remote.read_marker(remote.GAME / remote.MARKER)["sourceCommit"] == remote.SOURCE_COMMIT
assert not remote.existing_install()["needsUpgrade"]
+39
View File
@@ -1,6 +1,8 @@
"""Synthetic cache/audio tests; no Halo recordings or game data are fixtures."""
from pathlib import Path
import hashlib
import io
import random
import struct
import sys
import threading
@@ -89,6 +91,43 @@ def test_stereo_channel_headers_and_interleaving_remain_distinct():
assert samples[1::2] == (-120,) * 128
@pytest.mark.parametrize("channels,expected", [
(1, "08af960a705a06d131940dc54952955876058e449f7f37f92727a9ddf37188df"),
(2, "62a98442266869a1c78911817c0ab5f4685fc01d5ad42649f60151675baf9d83"),
])
def test_batched_decoder_preserves_pcm_for_every_predictor_index(channels, expected):
# Golden hashes from the original decoder: varied nibbles, all valid index
# states, both clipping limits, and mono/stereo sample ordering.
randomizer = random.Random(516)
blocks = []
for index in range(89):
headers = b"".join(struct.pack("<hBB", -32768 if channel % 2 else 32767, index, 0)
for channel in range(channels))
blocks.append(headers + bytes(randomizer.randrange(256) for _ in range(32*channels)))
output = music.decode_adpcm(b"".join(blocks), channels)
assert hashlib.sha256(output).hexdigest() == expected
def test_long_decode_yields_regularly_and_checks_cancellation_after_yield(monkeypatch):
block = struct.pack("<hBB", 1000, 0, 0) + bytes(32)
batches = 3
pauses = []
monkeypatch.setattr(music.time, "sleep", pauses.append)
output = music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS*batches+1), 1)
assert len(output) == (music.DECODE_BATCH_BLOCKS*batches+1)*128
assert pauses == [music.DECODE_YIELD_SECONDS]*batches
cancel = threading.Event()
def cancel_while_yielded(seconds):
assert seconds == music.DECODE_YIELD_SECONDS
cancel.set()
monkeypatch.setattr(music.time, "sleep", cancel_while_yielded)
with pytest.raises(music.MusicError, match="cancelled"):
music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS+1), 1, cancel)
def test_quiet_gain_is_bounded_and_zero_gain_is_silent():
block = struct.pack("<hBB", 32767, 88, 0) + b"\x77" * 32
samples = struct.unpack("<64h", music.decode_adpcm(block, 1))
+100
View File
@@ -0,0 +1,100 @@
"""Keep uninstall atomic when a native Steam record cannot be identified."""
from collections import OrderedDict
import pytest
from test_uninstall import RUN_ID, game_bytes, installation
def config_bytes(configs):
return {
str(path): path.read_bytes()
for config in configs
for path in config.rglob("*")
if path.is_file()
}
@pytest.mark.parametrize("invalid", ["missing", "string", "zero", "below-shortcut-range"])
@pytest.mark.parametrize("only_malformed", [False, True])
@pytest.mark.parametrize("quoted", [False, True])
def test_invalid_native_id_keeps_game_all_accounts_and_artwork(
installation, invalid, only_malformed, quoted):
remote, steam, configs, _, _ = installation
executable = str(remote.GAME / "halo")
if only_malformed:
for config in configs:
path = config / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
del root["shortcuts"].value["1"]
path.write_bytes(steam.dumps(root))
# Put the malformed record in the second account so the first account's
# otherwise valid removal is planned before validation must refuse it.
path = configs[1] / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
fields = OrderedDict([
("AppName", steam.Value(1, "Renamed native Halo")),
("Exe", steam.Value(1, f'"{executable}"' if quoted else executable)),
])
if invalid != "missing":
kind, value = {
"string": (1, "not-a-number"),
"zero": (2, 0),
"below-shortcut-range": (2, 0x7FFFFFFF),
}[invalid]
fields["appid"] = steam.Value(kind, value)
root["shortcuts"].value["3"] = steam.Value(0, fields)
path.write_bytes(steam.dumps(root))
before_game = game_bytes(remote)
before_configs = config_bytes(configs)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"]
assert result["steam"]["status"] == "manual"
assert "invalid app ID" in result["steam"]["reason"]
assert result["savedBackupPath"] is None
assert game_bytes(remote) == before_game
# This also proves no shortcut backups were published and no managed or
# custom artwork was deleted before the second account failed validation.
assert config_bytes(configs) == before_configs
assert not list(remote.GAME.parent.glob("*HaloCENativeVR-saves-*"))
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
@pytest.mark.parametrize("variant", ["leading-quote", "trailing-quote", "double-quotes", "suffix", "arguments"])
def test_nonexact_executable_is_preserved_even_with_bad_id_and_native_title(installation, variant):
remote, steam, configs, appids, _ = installation
executable = str(remote.GAME / "halo")
candidate = {
"leading-quote": '"' + executable,
"trailing-quote": executable + '"',
"double-quotes": '""' + executable + '""',
"suffix": executable + "-backup",
"arguments": executable + " --custom",
}[variant]
path = configs[0] / "shortcuts.vdf"
root = steam.loads(path.read_bytes())
unrelated = OrderedDict([
("appid", steam.Value(1, "not-a-number")),
("AppName", steam.Value(1, steam.NAME)),
("Exe", steam.Value(1, candidate)),
("unknown", steam.Value(7, b"KEEPTHIS")),
])
root["shortcuts"].value["3"] = steam.Value(0, unrelated)
path.write_bytes(steam.dumps(root))
custom_art = (configs[1] / "grid" / f"{appids[1]}.png").read_bytes()
foreign_art = (configs[0] / "grid/10p.jpg").read_bytes()
result = remote.uninstall(RUN_ID)
assert result["uninstalled"]
assert result["steam"]["status"] == "removed"
assert result["steam"]["accounts"] == ["10", "20"]
remaining = steam.loads(path.read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "2", "3"}
assert remaining["3"].value == unrelated
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == custom_art
assert (configs[0] / "grid/10p.jpg").read_bytes() == foreign_art
+157 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import Mock
import paramiko
import pytest
from halo_frame_installer.ssh import Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
from halo_frame_installer.ssh import CancelledError, Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
@pytest.mark.parametrize("host", ["192.168.1.12", "frame.local", "frame", "::1"])
@@ -201,3 +201,159 @@ def test_uninstall_timeout_reports_uncertain_removal_and_closes_channel(monkeypa
assert 'were kept' not in str(error.value)
channel.close.assert_called_once()
assert not connection._command_channels
@pytest.mark.parametrize('failure', [paramiko.SSHException('SSH session not active'), SSHError('cancel command failed'), EOFError('private'), ValueError('private')])
def test_failed_cancel_notice_does_not_mask_the_cancellation(monkeypatch, failure):
channel = Mock()
channel.recv_ready.return_value = False
channel.exit_status_ready.return_value = False
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
cancel = threading.Event()
cancel.set()
on_cancel = Mock(side_effect=failure)
with pytest.raises(CancelledError, match='cancelled'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'],
cancel_event=cancel, on_cancel=on_cancel)
on_cancel.assert_called_once()
def test_failed_command_start_reports_a_connection_problem(monkeypatch):
client = Mock()
client.exec_command.side_effect = paramiko.SSHException('SSH session not active')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='could not be started'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
@pytest.mark.parametrize('cancelled', [False, True])
def test_failed_sftp_open_reports_connection_problem_or_cancellation(monkeypatch, cancelled):
client = Mock()
client.open_sftp.side_effect = paramiko.SSHException('SSH session not active')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
cancel = threading.Event()
if cancelled:
cancel.set()
with pytest.raises(CancelledError if cancelled else SSHError) as error:
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
if not cancelled:
assert 'transfer stopped' in str(error.value)
def test_failed_cancel_notice_does_not_mask_the_timeout_error(monkeypatch):
from halo_frame_installer import ssh
channel = Mock()
channel.recv_ready.return_value = False
channel.exit_status_ready.return_value = False
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
on_cancel = Mock(side_effect=paramiko.SSHException('SSH session not active'))
with pytest.raises(SSHError, match='timed out'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], timeout=600, on_cancel=on_cancel)
on_cancel.assert_called_once()
@pytest.mark.parametrize('failure', [EOFError('private'), paramiko.SSHException('private'), OSError('private')])
def test_command_start_errors_are_redacted_without_exposing_a_cause(monkeypatch, failure):
client = Mock()
client.exec_command.side_effect = failure
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='could not be started') as error:
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'])
assert 'private' not in str(error.value)
assert error.value.__suppress_context__ and error.value.__cause__ is None
def test_cancel_during_failed_command_start_remains_a_cancellation(monkeypatch):
cancel = threading.Event()
client = Mock()
def fail(*args, **kwargs):
cancel.set()
raise EOFError('private')
client.exec_command.side_effect = fail
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
on_cancel = Mock(side_effect=EOFError('private'))
with pytest.raises(CancelledError, match='cancelled') as error:
SSHConnection(Settings('frame', 'private')).run(['helper'], cancel_event=cancel, on_cancel=on_cancel)
on_cancel.assert_called_once()
assert 'private' not in str(error.value)
@pytest.mark.parametrize('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):
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')
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:
SSHConnection(Settings('frame', 'private')).run(['helper'])
assert 'private' not in str(error.value) and error.value.__suppress_context__
channel.close.assert_called_once()
def test_channel_close_failure_cannot_replace_the_timeout(monkeypatch):
from halo_frame_installer import ssh
channel = Mock()
channel.close.side_effect = paramiko.SSHException('private')
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(ssh.RemoteTimeoutError, match='timed out'):
connection.run(['helper'], timeout=600)
assert channel in connection._command_channels
connection.close()
assert not connection._command_channels
client.close.assert_called_once()
@pytest.mark.parametrize('failure_step', ['timeout', 'put'])
def test_sftp_setup_and_transfer_failure_survive_a_failed_close(monkeypatch, failure_step):
client, sftp = Mock(), Mock()
client.open_sftp.return_value = sftp
sftp.close.side_effect = EOFError('private')
target = sftp.get_channel.return_value.settimeout if failure_step == 'timeout' else sftp.put
target.side_effect = paramiko.SSHException('private')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(SSHError, match='transfer stopped') as error:
connection.put('local.txt', 'remote.txt')
assert 'private' not in str(error.value)
assert sftp in connection._sftp_clients
connection.close()
assert not connection._sftp_clients
def test_sftp_callback_cancellation_survives_a_failed_close(monkeypatch):
client, sftp = Mock(), Mock()
client.open_sftp.return_value = sftp
sftp.close.side_effect = EOFError('private')
cancel = threading.Event()
def transfer(*args, **kwargs):
cancel.set()
kwargs['callback'](1, 2)
sftp.put.side_effect = transfer
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(CancelledError, match='cancelled') as error:
SSHConnection(Settings('frame', 'private')).put('local.txt', 'remote.txt', cancel_event=cancel)
assert 'private' not in str(error.value)
@pytest.mark.parametrize('failure_step', ['connect', 'keepalive'])
def test_eof_connect_or_keepalive_failure_is_redacted_and_tears_down(monkeypatch, failure_step):
client = Mock()
target = client.connect if failure_step == 'connect' else client.get_transport.return_value.set_keepalive
target.side_effect = EOFError('private')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(SSHError, match='Could not connect') as error:
SSHConnection(Settings('frame', 'private')).connect()
assert 'private' not in str(error.value)
client.close.assert_called_once()
+208 -10
View File
@@ -31,7 +31,8 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const calls = [], store = new Map(), data = new Map();
const id = 3000000001;
for (const entry of input.entries || []) {
store.set(entry.appid, {appid:entry.appid, app_type:1073741824, display_name:entry.name});
store.set(entry.appid, {appid:entry.appid, app_type:1073741824, display_name:entry.name,
icon_data:input.iconData === undefined ? (input.iconUnloaded ? undefined : '') : input.iconData});
data.set(entry.appid, {unAppID:entry.appid, strShortcutExe:entry.exe,
strShortcutStartDir:entry.directory || input.spec.directory,
strLaunchOptions:entry.options || '', strShortcutLaunchOptions:entry.options || '',
@@ -46,12 +47,12 @@ async function setter(method, appid, value, field) {
if (method === 'SetShortcutName') store.get(appid).display_name = value;
active = false;
}
globalThis.appStore = {m_mapApps:store};
globalThis.appStore = {m_mapApps:store,GetAppOverviewByAppID:appid => store.get(appid)};
globalThis.SteamClient = {Apps:{
AddShortcut:async(name, exe, arguments_, cmdline) => {
calls.push(['AddShortcut',name,exe,arguments_,cmdline]);
const result = input.returnId || id;
store.set(result,{appid:result,app_type:1073741824,display_name:'halo'});
store.set(result,{appid:result,app_type:1073741824,display_name:'halo',icon_data:''});
data.set(result,{unAppID:result,strShortcutExe:exe,strShortcutStartDir:input.spec.directory+'/',
strLaunchOptions:arguments_,strShortcutLaunchOptions:arguments_,bShortcutIsVR:false,strCompatToolName:''});
return result;
@@ -69,6 +70,11 @@ globalThis.SteamClient = {Apps:{
SetAppLaunchOptions:(appid,value) => setter('SetAppLaunchOptions',appid,value,'strLaunchOptions'),
SpecifyCompatTool:(appid,value) => setter('SpecifyCompatTool',appid,value,'strCompatToolName'),
SetShortcutIsVR:(appid,value) => setter('SetShortcutIsVR',appid,value,'bShortcutIsVR'),
SetShortcutIcon:(appid,value) => setter('SetShortcutIcon',appid,value),
RequestIconDataForApp:appid => {
calls.push(['RequestIconDataForApp',appid]);
if (input.loadedIconData !== undefined) store.get(appid).icon_data = input.loadedIconData;
},
SetCustomArtworkForApp:async(appid,base64Data,extension,assetType) => {
calls.push(['SetCustomArtworkForApp',appid,base64Data,extension,assetType]);
await new Promise(resolve => setTimeout(resolve,1));
@@ -165,7 +171,7 @@ def test_remove_renamed_native_entry_uses_exact_path(live):
def test_remove_exact_identity_and_leave_foreign_same_name_entry(live):
result = run_client(live, "remove", entries=[entry(live), entry(live, appid=3000000002, exe="/foreign")])
assert result["result"] == {"ok": True, "status": "removed", "appid": 3000000001}
assert result["entries"] == [{"appid": 3000000002, "app_type": 1073741824, "display_name": live.NAME}]
assert result["entries"] == [{"appid": 3000000002, "app_type": 1073741824, "display_name": live.NAME, "icon_data": ""}]
assert [call for call in result["calls"] if call[0] == "RemoveShortcut"] == [["RemoveShortcut", 3000000001]]
@@ -317,16 +323,33 @@ def artwork_client(live, tmp_path, monkeypatch):
login = config / "loginusers.vdf"
login.write_text('"users" { "76561197960265738" { "AccountName" "active-user" "MostRecent" "0" } '
'"76561197960265748" { "AccountName" "another-user" "MostRecent" "1" } }')
game = live.GAME
game.mkdir(parents=True)
program = bytearray(64)
program[:6] = b"\x7fELF\x02\x01"
program[18:20] = (183).to_bytes(2, "little")
(game / "halo").write_bytes(program)
(game / ".halo-frame-installer.json").write_text(json.dumps({
"owner": "halo-frame-installer", "sourceCommit": shortcut.ICON_SOURCE_COMMIT,
"files": {"halo": hashlib.sha256(program).hexdigest()}}))
record, _ = shortcut.update_shortcut(None, str(game / "halo"), str(game))
root = shortcut.loads(record)
root["shortcuts"].value["0"].value["appid"].value = 3000000001
persisted = grid.parent / "shortcuts.vdf"
persisted.write_bytes(shortcut.dumps(root))
class ArtworkClient:
deadline = time.monotonic() + 30
account = "active-user"
writes = []
icon_writes = []
calls = 0
fail_write = False
switch_before_write = False
corrupt_write = False
switch_after_write = False
icon_state = "empty"
load_icon = False
def __enter__(self):
return self
@@ -338,12 +361,22 @@ def artwork_client(live, tmp_path, monkeypatch):
self.calls += 1
encoded = expression.removeprefix("(() => {const S = ").split(";\nreturn ", 1)[0]
request = json.loads(encoded)
if "iconPath" in request:
self.icon_writes.append(request)
if self.fail_write:
raise live.LiveSteamError("Steam shortcut icon API did not finish")
record = shortcut.loads(persisted.read_bytes())
record["shortcuts"].value["0"].value["icon"].value = "/custom/concurrent.png" if self.corrupt_write else request["iconPath"]
persisted.write_bytes(shortcut.dumps(record)) # Simulate Steam's own path setter.
if self.switch_after_write:
self.account = "another-user"
return {"ok": True, "accountName": self.account, "appid": request["appid"]}
if "base64Data" in request:
self.writes.append(request)
if self.fail_write:
raise live.LiveSteamError("Steam artwork API did not finish")
payload = b"unexpected client image" if self.corrupt_write else base64.b64decode(request["base64Data"])
suffix = "p" if request["assetType"] == 0 else ""
suffix = {0: "p", 1: "_hero", 2: "_logo", 3: ""}[request["assetType"]]
target = grid / (str(request["appid"]) + suffix + "." + request["extension"])
target.write_bytes(payload) # Simulate only Steam's own API filesystem write.
if self.switch_after_write:
@@ -351,7 +384,12 @@ def artwork_client(live, tmp_path, monkeypatch):
return {"ok": True, "accountName": self.account, "appid": request["appid"]}
if self.switch_before_write and self.calls >= 2:
self.account = "another-user"
return {"accountName": self.account, "appid": request["appid"], "exe": request["exe"]}
state = self.icon_state
requested = state == "unknown" and request.get("requestIcon") is True and self.load_icon
if requested:
self.icon_state = "empty"
return {"accountName": self.account, "appid": request["appid"], "exe": request["exe"],
"iconState": state, "iconRequested": requested}
client = ArtworkClient()
monkeypatch.setattr(live, "Client", lambda: client)
@@ -362,8 +400,9 @@ def test_live_artwork_uses_api_types_and_active_account_not_most_recent(live, ar
home, game, grid, foreign, login, client, shortcut = artwork_client
result = live._live_artwork(home, game, 3000000001)
assert result["status"] == "added"
assert result["installed"] == ["3000000001p.jpg", "3000000001.png"]
assert [(item["extension"], item["assetType"]) for item in client.writes] == [("jpg", 0), ("png", 3)]
assert result["installed"] == ["3000000001" + suffix + extension for suffix, extension, _, _ in shortcut.ARTWORK]
assert [(item["extension"], item["assetType"]) for item in client.writes] == [
(extension.lstrip("."), shortcut.ARTWORK_TYPES[suffix]) for suffix, extension, _, _ in shortcut.ARTWORK]
for suffix, extension, filename, expected in shortcut.ARTWORK:
assert hashlib.sha256((grid / ("3000000001" + suffix + extension)).read_bytes()).hexdigest() == expected
assert list(foreign.iterdir()) == [foreign / "unrelated.png"]
@@ -374,9 +413,13 @@ def test_live_artwork_preserves_custom_extensions_and_reuses_managed_hash(live,
home, game, grid, foreign, login, client, shortcut = artwork_client
(grid / "3000000001p.webp").write_bytes(b"custom portrait")
(grid / "3000000001.png").write_bytes((RESOURCE.parent / "artwork/halo-ce-landscape.png").read_bytes())
for suffix, extension, filename, _ in shortcut.ARTWORK:
if suffix not in ("p", ""):
(grid / ("3000000001" + suffix + extension)).write_bytes((RESOURCE.parent / "artwork" / filename).read_bytes())
before = {path.name: path.read_bytes() for path in grid.iterdir()}
result = live._live_artwork(home, game, 3000000001)
assert result == {"status": "preserved", "installed": [], "preserved": ["3000000001p.webp"], "unchanged": ["3000000001.png"]}
unchanged = ["3000000001" + suffix + extension for suffix, extension, _, _ in shortcut.ARTWORK if suffix != "p"]
assert result == {"status": "preserved", "installed": [], "preserved": ["3000000001p.webp"], "unchanged": unchanged}
assert not client.writes
assert {path.name: path.read_bytes() for path in grid.iterdir()} == before
@@ -409,7 +452,7 @@ def test_live_artwork_source_integrity_is_checked_before_any_client_request(live
home, game, grid, foreign, login, client, shortcut = artwork_client
sources = tmp_path / "corrupt-art"
sources.mkdir()
for name in ("halo-ce-cover.jpg", "halo-ce-landscape.png"):
for _, _, name, _ in shortcut.ARTWORK:
(sources / name).write_bytes(b"bad source")
with pytest.raises(live.LiveSteamError, match="integrity"):
live._live_artwork(home, game, 3000000001, sources)
@@ -461,3 +504,158 @@ def test_actual_artwork_expression_rechecks_account_before_mutation(live):
result = run_client(live, source=live.ART_WRITE, spec_values=values, entries=[entry(live)])
assert "account changed" in result["error"]
assert not any(call[0] == "SetCustomArtworkForApp" for call in result["calls"])
def test_live_icon_persists_validated_game_sidecar_and_uses_path_api(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
foreign_before = {path.name: path.read_bytes() for path in foreign.iterdir()}
result = live._live_icon(home, game, 3000000001)
target = game / ".installer-artwork" / shortcut.ICON_NAME
assert result == {"status": "added", "path": str(target)}
assert hashlib.sha256(target.read_bytes()).hexdigest() == shortcut.ICON_SHA256
assert len(client.icon_writes) == 1 and not client.writes
assert client.icon_writes[0]["iconPath"] == str(target)
persisted = (grid.parent / "shortcuts.vdf").read_bytes()
_, _, selected = shortcut._shortcut_icon_field(persisted, 3000000001, str(game / "halo"))
assert selected == str(target)
assert {path.name: path.read_bytes() for path in foreign.iterdir()} == foreign_before
again = live._live_icon(home, game, 3000000001)
assert again["status"] == "unchanged" and len(client.icon_writes) == 1
def test_live_icon_preserves_existing_selection_without_publishing_sidecar(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
persisted = grid.parent / "shortcuts.vdf"
root = shortcut.loads(persisted.read_bytes())
root["shortcuts"].value["0"].value["icon"].value = "/my/custom/icon.png"
before = shortcut.dumps(root)
persisted.write_bytes(before)
result = live._live_icon(home, game, 3000000001)
assert result["status"] == "preserved"
assert persisted.read_bytes() == before and not client.icon_writes
assert not (game / ".installer-artwork").exists()
def test_live_icon_account_change_before_api_never_changes_selection(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
before = (grid.parent / "shortcuts.vdf").read_bytes()
client.switch_before_write = True
with pytest.raises(live.LiveSteamError, match="account changed"):
live._live_icon(home, game, 3000000001)
assert not client.icon_writes and (grid.parent / "shortcuts.vdf").read_bytes() == before
def test_live_icon_corrupt_source_is_rejected_before_client_requests(live, artwork_client, tmp_path):
home, game, grid, foreign, login, client, shortcut = artwork_client
source = tmp_path / "corrupt-icon"
source.mkdir()
(source / shortcut.ICON_NAME).write_bytes(b"bad bundled icon")
with pytest.raises(live.LiveSteamError, match="integrity"):
live._live_icon(home, game, 3000000001, source)
assert not client.calls and not client.icon_writes and not (game / ".installer-artwork").exists()
def test_live_icon_foreign_persisted_executable_is_never_changed(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
persisted = grid.parent / "shortcuts.vdf"
root = shortcut.loads(persisted.read_bytes())
root["shortcuts"].value["0"].value["Exe"].value = '"/other/game"'
before = shortcut.dumps(root)
persisted.write_bytes(before)
with pytest.raises(ValueError, match="identity"):
live._live_icon(home, game, 3000000001)
assert persisted.read_bytes() == before and not client.icon_writes
assert not (game / ".installer-artwork").exists()
def test_live_icon_unverified_client_selection_is_kept_and_reported_manual(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
client.corrupt_write = True
with pytest.raises(live.LiveSteamError, match="persisted"):
live._live_icon(home, game, 3000000001)
_, _, selected = shortcut._shortcut_icon_field((grid.parent / "shortcuts.vdf").read_bytes(), 3000000001, str(game / "halo"))
assert selected == "/custom/concurrent.png"
def test_unpersisted_icon_record_does_not_block_library_or_banner_completion(live, artwork_client, monkeypatch):
home, game, grid, foreign, login, client, shortcut = artwork_client
(grid.parent / "shortcuts.vdf").unlink()
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False})
result = live.add_native(home, game)
assert result["status"] == "added" and result["libraryAdded"] is True
assert result["artwork"]["status"] == "added"
assert result["artwork"]["icon"]["status"] == "manual"
assert not client.icon_writes
def test_actual_icon_expression_uses_confirmed_path_setter_without_art_type_four(live):
values = {"appid": 3000000001, "accountName": "active-user", "iconPath": str(live.GAME / ".installer-artwork/halo-ce-icon.png")}
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)])
assert result["result"] == {"ok": True, "accountName": "active-user", "appid": 3000000001}
assert [call for call in result["calls"] if call[0] == "SetShortcutIcon"] == [
["SetShortcutIcon", 3000000001, values["iconPath"]]]
assert not any(call[0] == "SetCustomArtworkForApp" for call in result["calls"])
def test_actual_icon_expression_requires_capability_and_checks_account(live):
values = {"appid": 3000000001, "accountName": "another-user", "iconPath": "/owned/icon.png"}
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)])
assert "account changed" in result["error"]
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)], missing=["SetShortcutIcon"])
assert "unavailable" in result["error"]
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
@pytest.mark.parametrize("icon_data", [None, "resident-custom-icon"])
def test_actual_icon_expression_keeps_unloaded_or_resident_custom_icon(live, icon_data):
values = {"appid": 3000000001, "accountName": "active-user", "iconPath": "/owned/icon.png"}
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)], iconData=icon_data)
assert "confirmed empty" in result["error"]
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
def test_live_icon_resident_custom_icon_wins_over_stale_empty_vdf(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
client.icon_state = "present"
before = (grid.parent / "shortcuts.vdf").read_bytes()
result = live._live_icon(home, game, 3000000001)
assert result["status"] == "preserved"
assert not client.icon_writes and (grid.parent / "shortcuts.vdf").read_bytes() == before
assert not (game / ".installer-artwork").exists()
def test_live_icon_unknown_resident_state_defers_without_sidecar_or_setter(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
client.icon_state = "unknown"
with pytest.raises(live.LiveSteamError, match="has not loaded"):
live._live_icon(home, game, 3000000001)
assert not client.icon_writes and not (game / ".installer-artwork").exists()
def test_live_icon_requests_missing_data_once_and_waits_for_confirmed_empty(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
client.icon_state = "unknown"
client.load_icon = True
result = live._live_icon(home, game, 3000000001)
assert result["status"] == "added" and len(client.icon_writes) == 1
def test_actual_icon_session_requests_undefined_data_without_assuming_empty(live):
values = {"appid": 3000000001, "accountName": "active-user", "requestIcon": True}
result = run_client(live, source=live.ICON_SESSION, spec_values=values, entries=[entry(live)], iconUnloaded=True, loadedIconData="")
assert result["result"]["iconState"] == "unknown" and result["result"]["iconRequested"] is True
assert [call for call in result["calls"] if call[0] == "RequestIconDataForApp"] == [["RequestIconDataForApp", 3000000001]]
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
def test_corrupt_icon_prevents_all_live_artwork_calls(live, artwork_client, tmp_path):
home, game, grid, foreign, login, client, shortcut = artwork_client
source = tmp_path / "corrupt-icon-with-valid-art"
source.mkdir()
for _, _, filename, _ in shortcut.ARTWORK:
(source / filename).write_bytes((RESOURCE.parent / "artwork" / filename).read_bytes())
(source / shortcut.ICON_NAME).write_bytes(b"corrupt final icon")
with pytest.raises(live.LiveSteamError, match="icon.*integrity"):
live._live_artwork(home, game, 3000000001, source)
assert not client.calls and not client.writes and not list(grid.iterdir())
+330
View File
@@ -0,0 +1,330 @@
"""Exercise the actual Notes JavaScript against a bounded native API contract."""
import importlib.util
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@pytest.fixture
def notes(monkeypatch):
for name in ("steam_live", "steam_notes"):
spec = importlib.util.spec_from_file_location(name, RESOURCES / (name + ".py"))
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, name, module)
spec.loader.exec_module(module)
return sys.modules["steam_notes"]
NODE_CLIENT = r"""
const input=JSON.parse(require('fs').readFileSync(0,'utf8'));
const calls=[];
const spec=input.spec;
const store=new Map([[spec.appid,{appid:spec.appid,app_type:1073741824,display_name:spec.name}]]);
if (input.foreignName) store.set(spec.appid+1,{appid:spec.appid+1,app_type:1073741824,display_name:spec.name});
globalThis.appStore={m_mapApps:store};
globalThis.loginStore={currentUser:{accountName:'current-account'}};
let doc=input.document ?? null,reads=0;
function call(method,...args) {
calls.push([method,...args]);
if (input.changeAccountAt===method) loginStore.currentUser.accountName='other-account';
}
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,callback)=>{
call('RegisterForAppDetails',id);
callback({unAppID:input.wrongId?id+1:id,strShortcutExe:input.foreignExe?'/foreign/game':spec.exe});
return {unregister:()=>call('unregister')};
}},GameNotes:{
SyncToClient:async()=>{call('SyncToClient');return input.syncClientResult ?? 1;},
SyncToServer:async()=>{call('SyncToServer');return input.syncServerResult ?? 1;},
GetNotes:async(filename,images)=>{
call('GetNotes',filename,images);reads++;
if (input.changeDocumentAtRead===reads) doc=input.changedDocument;
if (input.invalidJSON) return {result:1,notes:'{bad json'};
return doc===null?{result:input.missingResult ?? 9,notes:''}:
{result:input.readResult ?? 1,notes:JSON.stringify(doc),images:[]};
},
SaveNotes:async(filename,text)=>{
call('SaveNotes',filename);
if ((input.saveResult ?? 1)===1) doc=JSON.parse(text);
return input.saveResult ?? 1;
}
}};
if (input.missingMethod) delete SteamClient.GameNotes[input.missingMethod];
(async()=>{
try {
const result=await eval(input.expression);
process.stdout.write(JSON.stringify({result,calls,document:doc}));
} catch(error) {process.stdout.write(JSON.stringify({error:String(error),calls,document:doc}));}
})();
"""
def run_notes(notes, **options):
node = shutil.which("node")
if not node:
pytest.skip("Node is required to exercise the real Steam Notes expression")
spec = notes.native_spec(notes.HOME, notes.GAME, notes.NAME)
spec.update({"appid": 3000000001, "noteId": notes.NOTE_ID,
"title": notes.NOTE_TITLE, "content": notes.NOTE_CONTENT,
"maxBytes": notes.MAX_DOCUMENT_BYTES, "maxNotes": notes.MAX_NOTES,
"previousContents": list(notes.PREVIOUS_NOTE_CONTENTS)})
spec.update(options.pop("spec_overrides", {}))
body = {"spec": spec, "expression": notes.expression(notes.NOTES, spec), **options}
output = subprocess.run([node, "-e", NODE_CLIENT], input=json.dumps(body),
capture_output=True, text=True, encoding="utf-8", check=True, timeout=12)
return json.loads(output.stdout)
def document(notes, existing=None, **fields):
return {"notes": existing or [], "shortcut_name": notes.NAME, **fields}
def managed(notes, **fields):
return {"id": notes.NOTE_ID, "shortcut_name": notes.NAME, "title": notes.NOTE_TITLE,
"content": notes.NOTE_CONTENT, "ordinal": 0, "time_created": 1,
"time_modified": 2, **fields}
def methods(result):
return [call[0] for call in result["calls"]]
def test_missing_file_adds_verified_shortcut_note_with_native_filename(notes):
result = run_notes(notes)
assert result["result"]["status"] == "added"
assert methods(result).count("SaveNotes") == 1
assert methods(result).index("SyncToClient") < methods(result).index("GetNotes")
assert methods(result).index("SaveNotes") < methods(result).index("SyncToServer")
reads = [call for call in result["calls"] if call[0] == "GetNotes"]
expected = "notes_shortcut_Halo__Combat_Evolved_VR__Native_"
assert len(reads) == 3
assert all(call[1:] == [expected, expected + "_images/"] for call in reads)
saved = result["document"]
assert saved["shortcut_name"] == notes.NAME
assert saved["notes"][0]["id"] == notes.NOTE_ID
assert saved["notes"][0]["content"] == notes.NOTE_CONTENT
assert saved["notes"][0]["time_created"] == saved["notes"][0]["time_modified"]
assert "appid" not in saved["notes"][0]
assert methods(result).count("unregister") == methods(result).count("RegisterForAppDetails")
def test_existing_notes_and_unknown_document_fields_are_preserved(notes):
existing = [{"id": "user-note", "title": "My campaign", "content": "Leave untouched",
"other": {"key": [1, 2, 3]}}]
original = document(notes, existing, unknown_metadata={"preserve": True})
result = run_notes(notes, document=original)
assert result["result"]["status"] == "added"
assert result["document"]["notes"][:-1] == existing
assert result["document"]["unknown_metadata"] == original["unknown_metadata"]
def test_matching_managed_note_is_idempotent(notes):
original = document(notes, [managed(notes, extra="preserved")])
result = run_notes(notes, document=original)
assert result["result"]["status"] == "existing"
assert result["document"] == original
assert "SaveNotes" not in methods(result)
def test_only_exact_shipped_notes_are_allowed_previous_installer_content(notes):
assert notes.PREVIOUS_NOTE_CONTENTS == (notes.A2_NOTE_CONTENT, notes.A4_NOTE_CONTENT, notes.STABLE_NOTE_CONTENT)
assert hashlib.sha256(notes.A2_NOTE_CONTENT.encode("utf-8")).hexdigest() == \
"a4e1776caa60548620e0510acbe194aa414645be8d042771d9cbe1ec49edc05e"
assert hashlib.sha256(notes.A4_NOTE_CONTENT.encode("utf-8")).hexdigest() == \
"55785ec794857e844722ba758a4ce47fdb607a6bba2c30f23a437dd8f320ae30"
assert hashlib.sha256(notes.STABLE_NOTE_CONTENT.encode("utf-8")).hexdigest() == "ceed0d2d403fa18904c5d529ae409c8bfedad733fa1f1a7d93a6e2c2fdb8d32a"
assert "save-v1.4" in notes.NOTE_CONTENT and "old save folder is retained" in notes.NOTE_CONTENT
assert notes.NOTE_CONTENT != notes.A2_NOTE_CONTENT
assert "LISTING to PUBLIC" in notes.NOTE_CONTENT and "PUBLIC lets anyone" in notes.NOTE_CONTENT
assert "HOST LOCAL (LAN)" in notes.NOTE_CONTENT and "JOIN ONLINE (INTERNET)" in notes.NOTE_CONTENT
assert "Flat-screen CO-OP CAMPAIGN retains local split-screen" not in notes.NOTE_CONTENT
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
def test_exact_unchanged_previous_note_is_upgraded_with_every_other_field_preserved(notes, previous):
custom = {"id": "my-note", "title": "My campaign", "content": "Leave untouched", "extra": [1, 2]}
old = managed(notes, content=getattr(notes, previous), ordinal=8, time_created=123, unknown={"keep": True})
original = document(notes, [custom, old], unknown_document={"keep": [3]})
result = run_notes(notes, document=original)
assert result["result"]["status"] == "updated"
assert methods(result).count("SaveNotes") == 1
updated = result["document"]
assert updated["unknown_document"] == original["unknown_document"]
assert updated["notes"][0] == custom
assert {key: value for key, value in updated["notes"][1].items() if key not in ("content", "time_modified")} == \
{key: value for key, value in old.items() if key not in ("content", "time_modified")}
assert updated["notes"][1]["content"] == notes.NOTE_CONTENT
assert updated["notes"][1]["time_modified"] > old["time_modified"]
assert run_notes(notes, document=updated)["result"]["status"] == "existing"
@pytest.mark.parametrize("field", ["content", "title", "shortcut_name"])
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
def test_previous_note_with_any_user_edit_or_identity_mismatch_is_preserved(notes, field, previous):
old = getattr(notes, previous)
changes = {"content": old}
changes[field] = {"content": old + " ", "title": "My title", "shortcut_name": "Other game"}[field]
original = document(notes, [managed(notes, **changes)])
result = run_notes(notes, document=original)
assert result["result"]["status"] == "custom"
assert result["document"] == original
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
def test_concurrent_edit_during_previous_note_upgrade_is_kept_without_write(notes, previous):
old = getattr(notes, previous)
original = document(notes, [managed(notes, content=old)])
changed = document(notes, [managed(notes, content=old + " edited")])
result = run_notes(notes, document=original, changeDocumentAtRead=2, changedDocument=changed)
assert result["result"]["status"] == "manual"
assert result["document"] == changed
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("previous", [None, [None], ["old"] * 5, ["é" * 8193]])
def test_malformed_or_excessive_previous_content_list_never_writes(notes, previous):
original = document(notes, [managed(notes, content=notes.A2_NOTE_CONTENT)])
result = run_notes(notes, document=original, spec_overrides={"previousContents": previous})
assert result["result"]["status"] == "manual"
assert result["document"] == original
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("previous", ["A2_NOTE_CONTENT", "A4_NOTE_CONTENT", "STABLE_NOTE_CONTENT"])
def test_exact_previous_note_upgrade_does_not_need_an_extra_note_slot(notes, previous):
others = [{"id": str(index), "content": "Keep"} for index in range(notes.MAX_NOTES - 1)]
result = run_notes(notes, document=document(notes, [*others, managed(notes, content=getattr(notes, previous))]))
assert result["result"]["status"] == "updated"
assert len(result["document"]["notes"]) == notes.MAX_NOTES
assert result["document"]["notes"][:-1] == others
@pytest.mark.parametrize("changes", [{"content": "User edited"}, {"title": "User title"},
{"shortcut_name": "Other game"}])
def test_edited_or_colliding_note_is_never_overwritten(notes, changes):
original = document(notes, [managed(notes, **changes)])
result = run_notes(notes, document=original)
assert result["result"]["status"] == "custom"
assert result["document"] == original
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("options", [
{"document": {"notes": [], "shortcut_name": "Other game"}},
{"document": {"notes": []}},
{"document": {"notes": "not a list"}},
{"document": {"notes": [None]}},
{"invalidJSON": True}, {"missingResult": 2},
{"readResult": 2, "document": {"notes": [], "shortcut_name": "Halo: Combat Evolved VR (Native)"}},
{"foreignName": True}, {"foreignExe": True}, {"wrongId": True},
{"syncClientResult": 2}, {"missingMethod": "SaveNotes"},
{"changeAccountAt": "SyncToClient"}, {"changeAccountAt": "GetNotes"},
])
def test_unsupported_corrupt_foreign_or_changed_context_refuses_save(notes, options):
result = run_notes(notes, **options)
assert result["result"]["status"] == "manual"
assert result["result"]["ok"] is False
assert "SaveNotes" not in methods(result)
def test_duplicate_managed_id_is_manual_and_preserved(notes):
original = document(notes, [managed(notes), managed(notes)])
result = run_notes(notes, document=original)
assert result["result"]["status"] == "manual"
assert result["document"] == original
assert "SaveNotes" not in methods(result)
def test_concurrent_note_edit_is_preserved_without_save(notes):
original = document(notes)
changed = document(notes, [{"id": "recent-edit", "content": "User typed this"}])
result = run_notes(notes, document=original, changeDocumentAtRead=2, changedDocument=changed)
assert result["result"]["status"] == "manual"
assert result["document"] == changed
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("options", [{"saveResult": 2}, {"syncServerResult": 2},
{"changeAccountAt": "SaveNotes"},
{"changeDocumentAtRead": 3,
"changedDocument": {"notes": [], "shortcut_name": "Other game"}}])
def test_failed_save_sync_or_readback_never_claims_success(notes, options):
result = run_notes(notes, **options)
assert result["result"]["status"] == "manual"
assert result["result"]["ok"] is False
def test_document_count_and_utf8_byte_bounds_refuse_save(notes):
full = document(notes, [{"id": str(index)} for index in range(notes.MAX_NOTES)])
excessive = document(notes, [{"id": "user", "content": "é" * notes.MAX_DOCUMENT_BYTES}])
for doc in (full, excessive):
result = run_notes(notes, document=doc)
assert result["result"]["status"] == "manual"
assert "SaveNotes" not in methods(result)
@pytest.mark.parametrize("appid", [True, -1, 5, 2 ** 32, "3000000001"])
def test_wrapper_rejects_invalid_appid_without_client(notes, monkeypatch, appid):
def unexpected_client():
pytest.fail("An invalid app ID must not open the Steam client")
monkeypatch.setattr(notes, "Client", unexpected_client)
result = notes.add_notes(notes.HOME, notes.GAME, appid)
assert result["status"] == "manual"
assert result["title"] == notes.NOTE_TITLE and result["content"] == notes.NOTE_CONTENT
def test_wrapper_rejects_unmanaged_paths_without_client(notes, monkeypatch):
monkeypatch.setattr(notes, "Client", lambda: pytest.fail("Unmanaged game must not connect"))
assert notes.add_notes(notes.HOME, Path("/other/game"), 3000000001)["status"] == "manual"
def test_wrapper_client_failure_keeps_optional_manual_instructions(notes, monkeypatch):
def unavailable():
raise OSError("private endpoint detail")
monkeypatch.setattr(notes, "Client", unavailable)
result = notes.add_notes(notes.HOME, notes.GAME, 3000000001)
assert result["status"] == "manual"
assert "private endpoint" not in result["message"]
def test_wrapper_reports_verified_a2_upgrade_as_updated(notes, monkeypatch):
class UpdatedClient:
def __enter__(self):
return self
def __exit__(self, *arguments):
pass
def evaluate(self, expression):
assert "previousContents" in expression
return {"ok": True, "status": "updated"}
monkeypatch.setattr(notes, "Client", UpdatedClient)
result = notes.add_notes(notes.HOME, notes.GAME, 3000000001)
assert result["ok"] is True and result["status"] == "updated"
assert "online co-op instructions" in result["message"]
assert result["content"] == notes.NOTE_CONTENT
def test_module_has_no_file_or_session_mutation_routes(notes):
source = (RESOURCES / "steam_notes.py").read_text(encoding="utf-8")
assert "SetAppDescription" not in source and "steam_appid.txt" not in source
assert "write_text(" not in source and "write_bytes(" not in source
assert "subprocess" not in source and "Shutdown" not in source
assert "SetShortcutName" not in source and "SetShortcutExe" not in source
def test_authored_note_matches_current_controls_and_protocol_scope(notes):
assert "Right trigger: fire" in notes.NOTE_CONTENT
assert "Left trigger: throw grenade" in notes.NOTE_CONTENT
assert "Left bumper: change grenade type" in notes.NOTE_CONTENT
assert "Right bumper: toggle flashlight" in notes.NOTE_CONTENT
assert "compatible native/OpenCE protocol builds" in notes.NOTE_CONTENT
assert "native ARM64 Linux/OpenXR" in notes.NOTE_CONTENT
+457 -5
View File
@@ -91,6 +91,26 @@ def game_bytes(remote):
if path.is_file() and not path.is_symlink()}
def record_quarantine(remote, identifier, quarantine, saved=None, *, legacy=False):
"""Model the journal made by an actual interrupted uninstall, not a label."""
directory = remote.CACHE / "runs" / identifier
metadata = remote.owned(directory)
metadata.update({"operation": "uninstall", "state": "removing", "quarantinePath": str(quarantine),
"savedBackupPath": str(saved) if saved else None})
if not legacy:
metadata["quarantineRoot"] = remote.uninstall_root_identity(quarantine)
if saved:
backup = {"owner": remote.OWNER + "-save-backup", "gamePath": str(remote.GAME)}
if not legacy:
backup["files"] = {str(path.relative_to(saved)): {"size": path.stat().st_size,
"sha256": remote.digest(path)}
for path in saved.rglob("*") if path.is_file() and path.name != remote.MARKER}
metadata["savedBackupRoot"] = remote.uninstall_root_identity(saved)
(saved / remote.MARKER).write_text(json.dumps(backup))
remote.write_uninstall_record(directory, metadata)
return directory / remote.MARKER
def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(installation, capsys):
remote, steam, configs, appids, foreign = installation
original_config = (remote.GAME / "config.toml").read_bytes()
@@ -124,6 +144,55 @@ def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
def test_uninstall_removes_a_native_entry_the_user_renamed(installation):
remote, steam, configs, appids, _ = installation
config = configs[0]
executable = str(remote.GAME / "halo")
root = steam.loads((config / "shortcuts.vdf").read_bytes())
for entry in root["shortcuts"].value.values():
path = entry.value.get("Exe")
if path is not None and path.value in (executable, f'"{executable}"'):
entry.value["AppName"] = steam.Value(1, "My favorite Halo")
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] and result["steam"]["status"] == "removed"
assert result["steam"]["accounts"] == ["10", "20"]
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "2"}
assert "My favorite Halo" not in {entry.value["AppName"].value for entry in remaining.values()}
assert result["steam"]["appids"] == sorted(set(appids))
assert not (config / "grid" / f"{appids[0]}p.jpg").exists()
assert not (config / "grid" / f"{appids[0]}.png").exists()
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile"
assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art"
backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak"))
assert len(backups) == 1 and b"My favorite Halo" in backups[0].read_bytes()
def test_uninstall_refuses_exact_native_malformed_ids_without_changing_files_or_art(installation):
remote, steam, configs, _, _ = installation
config = configs[0]
executable = str(remote.GAME / "halo")
root = steam.loads((config / "shortcuts.vdf").read_bytes())
root["shortcuts"].value["3"] = steam.Value(0, OrderedDict([
("appid", steam.Value(1, "not-a-number")), ("AppName", steam.Value(1, "Broken id")),
("Exe", steam.Value(1, executable))]))
root["shortcuts"].value["4"] = steam.Value(0, OrderedDict([
("appid", steam.Value(2, 0xF2233445)), ("AppName", steam.Value(1, "Odd quotes")),
("Exe", steam.Value(1, executable + '"'))]))
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
before = game_bytes(remote)
shortcut_bytes = [(path / "shortcuts.vdf").read_bytes() for path in configs]
artwork = {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()}
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
assert game_bytes(remote) == before
assert [(path / "shortcuts.vdf").read_bytes() for path in configs] == shortcut_bytes
assert {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()} == artwork
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
assert set(remaining) == {"0", "1", "2", "3", "4"}
def test_uninstall_without_keep_saves_deletes_contained_saves_only(installation):
remote, _, _, _, _ = installation
outside = remote.HOME / "external-saves"
@@ -275,6 +344,389 @@ def test_partial_file_removal_reports_quarantine_and_save_backup_honestly(instal
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
assert str(saved) in str(error.value)
assert "game was kept" not in str(error.value).lower()
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
monkeypatch.setattr(Path, "unlink", original_unlink)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] and not result["alreadyAbsent"] and not quarantine.exists()
assert result["savedBackupPath"] == str(saved)
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
@pytest.mark.parametrize("legacy", [False, True])
def test_interrupted_uninstall_leftovers_are_finished_by_the_next_run(installation, legacy):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
(saved / "save").mkdir()
(saved / "save/profile.sav").write_bytes(b"earlier campaign")
record_quarantine(remote, previous, quarantine, saved, legacy=legacy)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
assert not quarantine.exists() and not remote.GAME.exists()
assert (saved / "save/profile.sav").read_bytes() == b"earlier campaign"
assert str(quarantine) in result["warning"] and str(saved) in result["warning"]
assert result["recoveredSaveBackupPaths"] == [str(saved)]
assert result["savedBackupPath"] is None and result["removalPending"] is False
def test_reinstall_after_interrupted_uninstall_still_clears_the_old_quarantine(installation):
remote, _, _, _, _ = installation
previous = "e" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
(saved / "config.toml").write_bytes(b"earlier settings")
record_quarantine(remote, previous, quarantine, saved)
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"rebuilt native executable")
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and not result.get("alreadyAbsent", False)
assert not quarantine.exists() and not remote.GAME.exists()
assert str(saved) in result["warning"]
def test_quarantine_pattern_folders_without_a_valid_run_or_marker_are_kept(installation):
remote, _, _, _, _ = installation
games = remote.GAME.parent
decoy = games / ".HaloCENativeVR-uninstall-not-a-run-id"
decoy.mkdir()
(decoy / "notes.txt").write_bytes(b"user data")
unmarked = games / (".HaloCENativeVR-uninstall-" + "f" * 32)
unmarked.mkdir()
(unmarked / "stray.txt").write_bytes(b"not ours")
forged = games / (".HaloCENativeVR-uninstall-" + "a" * 32)
forged.mkdir()
(forged / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
(forged / "precious.bin").write_bytes(b"user data")
marker_dir = games / (".HaloCENativeVR-uninstall-" + "1" * 32)
marker_dir.mkdir()
(marker_dir / remote.MARKER).mkdir()
(marker_dir / "note.txt").write_bytes(b"kept")
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["removalPending"]
assert "manually" in result["warning"]
assert set(result["retainedQuarantinePaths"]) == {str(decoy), str(unmarked), str(forged), str(marker_dir)}
assert (decoy / "notes.txt").read_bytes() == b"user data"
assert (unmarked / "stray.txt").read_bytes() == b"not ours"
assert (forged / "precious.bin").read_bytes() == b"user data"
assert (marker_dir / "note.txt").read_bytes() == b"kept"
def test_unfinishable_leftover_is_named_and_skipped_instead_of_blocking(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "2" * 32)
quarantine.mkdir()
(quarantine / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
record_quarantine(remote, "2" * 32, quarantine)
outside = remote.HOME / "foreign.map"
outside.write_bytes(b"outside file")
os.link(outside, quarantine / "linked.map")
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["removalPending"]
assert (quarantine / "linked.map").is_file() and outside.read_bytes() == b"outside file"
assert str(quarantine) in result["warning"] and "manually" in result["warning"]
def test_many_leftovers_are_all_removed_with_a_bounded_warning(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "3" * 32)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, "3" * 32, quarantine)
for index in range(40):
folder = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + f"{index:032x}")
folder.mkdir()
(folder / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
(folder / "halo").write_bytes(b"earlier binary")
record_quarantine(remote, f"{index:032x}", folder)
result = remote.uninstall(RUN_ID, keep_saves=True)
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
assert not list((remote.GAME.parent).glob(".HaloCENativeVR-uninstall-*"))
assert len(result["warning"]) <= remote.MAX_WARNING_CHARS and "additional notes omitted" in result["warning"]
def test_saves_note_requires_an_owned_backup_marker(installation):
remote, _, _, _, _ = installation
previous = "4" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
record_quarantine(remote, previous, quarantine)
journal = remote.CACHE / "runs" / previous / remote.MARKER
metadata = json.loads(journal.read_text())
metadata["savedBackupPath"] = str(saved)
journal.write_text(json.dumps(metadata))
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and quarantine.exists() and result["removalPending"]
assert str(quarantine) in result["warning"] and result["recoveredSaveBackupPaths"] == []
@pytest.mark.parametrize("same_run", [False, True])
@pytest.mark.parametrize("keep_saves", [False, True])
def test_retry_finishes_markerless_quarantine_after_root_removal_failure(
installation, monkeypatch, same_run, keep_saves):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + RUN_ID)
real_rmdir = Path.rmdir
def fail_root(path, *args, **kwargs):
if path == quarantine:
raise OSError("root directory removal interrupted")
return real_rmdir(path, *args, **kwargs)
with monkeypatch.context() as patch:
patch.setattr(Path, "rmdir", fail_root)
with pytest.raises(RuntimeError, match="Remaining native files"):
remote.uninstall(RUN_ID, keep_saves=keep_saves)
assert quarantine.is_dir() and not list(quarantine.iterdir())
journal = json.loads((remote.CACHE / "runs" / RUN_ID / remote.MARKER).read_text())
assert journal["quarantineRoot"] == remote.uninstall_root_identity(quarantine)
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
result = remote.uninstall(RUN_ID if same_run else "d" * 32, keep_saves=keep_saves)
assert result["uninstalled"] and not result["alreadyAbsent"] and not result["removalPending"]
assert not quarantine.exists()
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
assert result["recoveredSaveBackupPaths"] == ([str(saved)] if keep_saves else [])
assert result["savedBackupPath"] == (str(saved) if keep_saves and same_run else None)
def test_quarantine_journal_precedes_the_rename_and_survives_that_crash_window(installation, monkeypatch):
remote, _, _, _, _ = installation
real_rename = remote.rename_noreplace
seen = []
def check(source, target):
if source == remote.GAME:
metadata = remote.private_json(remote.CACHE / "runs" / RUN_ID / remote.MARKER,
remote.CACHE / "runs" / RUN_ID)
assert metadata["state"] == "quarantining"
assert metadata["quarantinePath"] == str(target)
assert metadata["quarantineRoot"] == remote.uninstall_root_identity(source)
seen.append(metadata)
real_rename(source, target)
monkeypatch.setattr(remote, "rename_noreplace", check)
assert remote.uninstall(RUN_ID, keep_saves=True)["uninstalled"]
assert len(seen) == 1
# A process killed after rename but before the next journal update leaves
# this exact state. It remains sufficient proof even without a game marker.
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
quarantine.mkdir()
(quarantine / "remaining.bin").write_bytes(b"native remainder")
record = record_quarantine(remote, "d" * 32, quarantine)
metadata = json.loads(record.read_text())
metadata["state"] = "quarantining"
record.write_text(json.dumps(metadata))
assert remote.uninstall("e" * 32)["uninstalled"] and not quarantine.exists()
@pytest.mark.parametrize("damage", ["missing", "corrupt", "foreign-owner", "foreign-operation", "wrong-path",
"complete", "oversize", "hardlink", "invalid-root", "foreign-marker"])
def test_unverified_quarantine_journals_are_reported_and_never_adopted(installation, damage):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record = record_quarantine(remote, previous, quarantine)
metadata = json.loads(record.read_text())
if damage == "missing":
record.unlink()
elif damage == "corrupt":
record.write_text("{broken")
elif damage == "oversize":
record.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
elif damage == "hardlink":
os.link(record, remote.HOME / "copied-journal.json")
elif damage == "foreign-marker":
(quarantine / remote.MARKER).write_text('{"owner":"another-application"}')
else:
key, value = {"foreign-owner": ("owner", "other"), "foreign-operation": ("operation", "install"),
"wrong-path": ("quarantinePath", str(remote.HOME / "unrelated")),
"complete": ("state", "complete"), "invalid-root": ("quarantineRoot", {"device": True})}[damage]
metadata[key] = value
record.write_text(json.dumps(metadata))
before = {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()}
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and not result["alreadyAbsent"] and result["removalPending"]
assert result["retainedQuarantinePaths"] == [str(quarantine)] and str(quarantine) in result["warning"]
assert {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()} == before
def test_copied_known_owner_marker_does_not_authorize_unrelated_folder_deletion(installation):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
quarantine.mkdir()
(quarantine / remote.MARKER).write_bytes((remote.GAME / remote.MARKER).read_bytes())
(quarantine / "precious.bin").write_bytes(b"user data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and not result["uninstalled"]
assert (quarantine / "precious.bin").read_bytes() == b"user data"
assert str(quarantine) in result["warning"]
def test_replaced_quarantine_root_is_kept_even_if_the_old_marker_was_copied(installation):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
intact = remote.GAME.parent / "original-quarantined-game"
os.rename(quarantine, intact)
quarantine.mkdir()
(quarantine / remote.MARKER).write_bytes((intact / remote.MARKER).read_bytes())
(quarantine / "precious.bin").write_bytes(b"replacement data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "identity" in result["warning"]
assert (quarantine / "precious.bin").read_bytes() == b"replacement data"
assert (intact / "halo").read_bytes() == b"damaged native executable"
def test_markerless_legacy_quarantine_is_named_and_kept_for_manual_review(installation):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine, legacy=True)
(quarantine / remote.MARKER).unlink()
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "older uninstall" in result["warning"]
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
@pytest.mark.parametrize("damage", ["missing", "corrupt-marker", "oversize-marker", "foreign-marker",
"hardlink", "changed-content", "empty", "symlink-root", "symlink-child"])
def test_unverifiable_save_backup_preserves_quarantine_and_all_backup_data(installation, damage):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / "campaign.sav").write_bytes(b"previous campaign")
record_quarantine(remote, previous, quarantine, saved)
marker = saved / remote.MARKER
protected = None
if damage == "missing":
(saved / "campaign.sav").unlink()
marker.unlink()
saved.rmdir()
elif damage == "corrupt-marker":
marker.write_text("not-json")
elif damage == "oversize-marker":
marker.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
elif damage == "foreign-marker":
marker.write_text('{"owner":"another-application"}')
elif damage == "hardlink":
protected = remote.HOME / "protected-campaign.sav"
os.link(saved / "campaign.sav", protected)
elif damage == "changed-content":
(saved / "campaign.sav").write_bytes(b"modified campaign")
elif damage == "empty":
(saved / "campaign.sav").unlink()
else:
protected = remote.HOME / "protected-saves"
protected.mkdir()
(protected / "keep.sav").write_bytes(b"external campaign")
if damage == "symlink-root":
os.rename(saved, remote.GAME.parent / "actual-backup")
link = saved
else:
link = saved / "linked"
try:
link.symlink_to(protected, target_is_directory=True)
except OSError:
pytest.skip("Windows symlink creation requires additional privileges.")
before = {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()}
result = remote.uninstall(RUN_ID, keep_saves=True)
assert not result["uninstalled"] and result["removalPending"] and quarantine.exists()
assert result["recoveredSaveBackupPaths"] == []
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
assert {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()} == before
if protected:
assert (protected.read_bytes() if protected.is_file() else (protected / "keep.sav").read_bytes()) in (
b"previous campaign", b"external campaign")
def test_same_device_bind_mount_in_a_quarantine_is_kept(installation, monkeypatch):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [quarantine / "maps"])
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and "mount" in result["warning"]
assert (quarantine / "maps/a10.map").read_bytes() == b"damaged or incomplete Xbox maps"
def test_recovery_and_verified_saves_are_reported_when_steam_removal_is_pending(installation, monkeypatch):
remote, steam, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
os.rename(remote.GAME, quarantine)
saved.mkdir()
(saved / "campaign.sav").write_bytes(b"earlier campaign")
record_quarantine(remote, previous, quarantine, saved)
monkeypatch.setattr(steam, "steam_running", lambda: True)
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
assert not quarantine.exists() and str(quarantine) in result["warning"]
assert result["savedBackupPath"] is None and result["recoveredSaveBackupPaths"] == [str(saved)]
assert (saved / "campaign.sav").read_bytes() == b"earlier campaign"
def test_recovery_has_bounded_work_and_can_continue_on_another_run(installation, monkeypatch):
remote, _, _, _, _ = installation
previous = "d" * 32
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
os.rename(remote.GAME, quarantine)
record_quarantine(remote, previous, quarantine)
monkeypatch.setattr(remote, "MAX_UNINSTALL_RECOVERIES", 4)
for index in range(5):
identifier = f"{index:032x}"
path = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + identifier)
path.mkdir()
(path / "remaining.bin").write_bytes(b"verified remainder")
record_quarantine(remote, identifier, path)
result = remote.uninstall(RUN_ID)
assert not result["uninstalled"] and result["removalPending"] and not result["alreadyAbsent"]
assert len(list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))) == 2
assert "one operation" in result["warning"] and len(result["warning"]) <= remote.MAX_WARNING_CHARS
assert remote.uninstall("e" * 32)["uninstalled"]
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
@pytest.mark.skipif(os.name == "nt", reason="Windows forbids these Unix filename characters.")
@pytest.mark.parametrize("suffix", ["bad\nname", "bad\\name", "bad\x7fname"])
def test_unreportable_quarantine_filenames_stay_pending_without_unsafe_path_fields(installation, suffix):
remote, _, _, _, _ = installation
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + suffix)
quarantine.mkdir()
(quarantine / "precious.bin").write_bytes(b"user data")
result = remote.uninstall(RUN_ID)
assert result["removalPending"] and not result["uninstalled"]
assert result["retainedQuarantinePaths"] == []
assert (quarantine / "precious.bin").read_bytes() == b"user data"
assert ascii(quarantine.name) in result["warning"]
def test_absent_games_parent_needs_no_creation_or_cleanup(installation):
remote, _, _, _, _ = installation
protected = remote.HOME / "original-games"
os.rename(remote.GAME.parent, protected)
result = remote.uninstall(RUN_ID)
assert result["uninstalled"] and result["alreadyAbsent"] and not result["removalPending"]
assert not remote.GAME.parent.exists()
assert (protected / "HaloCEVR/halo.exe").read_bytes() == b"old PC version; this operation must not delete it"
def test_failure_before_first_delete_restores_the_intact_game_folder(installation, monkeypatch):
@@ -364,14 +816,14 @@ def test_save_backup_prerequisite_failure_keeps_game_and_steam_untouched(install
def test_completed_removal_is_not_reported_as_failed_if_operation_bookkeeping_fails(installation, monkeypatch):
remote, _, _, _, _ = installation
real_write = Path.write_text
real_write = remote.write_uninstall_record
def write(path, content, *args, **kwargs):
if path.parent.name == RUN_ID and '"state": "complete"' in content:
def write(directory, metadata, *args, **kwargs):
if directory.name == RUN_ID and metadata.get("state") == "complete":
raise OSError("operation record update failed")
return real_write(path, content, *args, **kwargs)
return real_write(directory, metadata, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", write)
monkeypatch.setattr(remote, "write_uninstall_record", write)
result = remote.uninstall(RUN_ID)
assert result["uninstalled"] is True and not remote.GAME.exists()
assert "operation record" in result["warning"]
+102
View File
@@ -148,3 +148,105 @@ def test_post_removal_bookkeeping_warning_is_visible_without_false_failure():
assert result.uninstalled and fake.closed
assert ('detail', warning, None) in events
assert events[-1][0] == 'complete'
def host_response(fake, **extra):
original = fake.run
def run(argv, **kwargs):
response = original(argv, **kwargs)
if argv[1] != '-c':
data = json.loads(response.split(' ', 1)[1])
data.update(extra)
response = 'HFI_RESULT ' + json.dumps(data)
return response
fake.run = run
def test_successful_uninstall_retains_its_result_when_usb_cleanup_needs_attention():
class CleanupFailure(UninstallConnection):
def close(self):
self.closed = True
raise SSHError('Temporary USB forward cleanup failed private')
fake = CleanupFailure(Settings('', 'private', transport='usb'))
events = []
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert result.uninstalled and result.saved_backup_path
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
assert 'private' not in result.cleanup_warning
assert not any(event[0] in ('disconnected', 'complete') for event in events)
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
def test_uncertain_uninstall_retains_history_without_claiming_removed_or_already_absent():
fake = UninstallConnection(Settings('frame', 'private'))
previous_save = '/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32
retained = '/home/steamos/Games/.HaloCENativeVR-uninstall-unsafe-manual-folder'
warning = 'An unsafe uninstall folder was preserved; inspect it before retrying.'
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
removalPending=True, warning=warning,
retainedQuarantinePaths=[retained], recoveredSaveBackupPaths=[previous_save])
events = []
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert result.removal_pending and not result.uninstalled and not result.already_absent
assert result.removal_warning == warning
assert result.retained_quarantine_paths == (retained,)
assert result.recovered_save_backup_paths == (previous_save,)
assert result.saved_backup_path is None
assert not any(event[0] == 'complete' for event in events)
assert events[-1][0] == 'removal-pending' and events[-1][2] == 99
def test_manual_steam_failure_preserves_recovered_save_history_and_warning():
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
backup = '/home/steamos/Games/HaloCENativeVR-saves-' + 'b' * 32
warning = 'Recovered saves from an interrupted uninstall.'
host_response(fake, warning=warning, recoveredSaveBackupPaths=[backup])
events = []
with pytest.raises(SSHError, match='game was kept') as error:
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda *event: events.append(event))
assert warning in str(error.value) and backup in str(error.value)
assert ('detail', 'Recovered campaign save backup: ' + backup, None) in events
assert not any(event[0] == 'complete' for event in events)
@pytest.mark.parametrize('extra', [
{'recoveredSaveBackupPaths': ['/home/other/Games/HaloCENativeVR-saves-' + 'a' * 32]},
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-not-a-run']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-../other']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\\other']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-bad\nother']},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 256]},
{'recoveredSaveBackupPaths': ['/home/steamos/Games/HaloCENativeVR-saves-' + 'a' * 32] * 2},
{'retainedQuarantinePaths': ['folder'] * 65},
{'warning': 'x' * 4097},
{'removalPending': 'true'},
{'removalPending': True, 'uninstalled': True},
{'retainedQuarantinePaths': ['/home/steamos/Games/.HaloCENativeVR-uninstall-' + 'a' * 32]},
])
def test_invalid_uninstall_recovery_history_never_reports_success(extra):
fake = UninstallConnection(Settings('frame', 'private'))
host_response(fake, **extra)
stages = []
with pytest.raises(SSHError, match='invalid uninstall'):
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings,
lambda stage, message, percent=None: stages.append(stage))
assert 'complete' not in stages and fake.closed
def test_pending_overflow_can_be_reported_without_a_retained_path_list():
fake = UninstallConnection(Settings('frame', 'private'))
host_response(fake, uninstalled=False, alreadyAbsent=False, savedBackupPath=None,
removalPending=True, warning='Too many interrupted uninstall folders; retry cleanup.')
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
assert result.removal_pending and result.retained_quarantine_paths == ()
def test_uninstall_failure_is_not_masked_by_disconnect_failure():
fake = UninstallConnection(Settings('frame', 'private'), manual=True)
fake.close = lambda: (_ for _ in ()).throw(EOFError('private'))
with pytest.raises(SSHError, match='game was kept') as error:
Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings)
assert 'private' not in str(error.value)
+5 -1
View File
@@ -180,6 +180,7 @@ def test_finalize_rechecks_retained_hashes_before_publishing(remote, monkeypatch
struct.pack_into("<H", header, 18, 183)
(build / "halo").write_bytes(header)
(build / "libSDL3.so.0").write_bytes(header)
(build / "brokers.txt").write_text("broker.example.org:1883\n")
changed = bytearray((older / "upload/maps/a10.map").read_bytes())
changed[100] = 1
(older / "upload/maps/a10.map").write_bytes(changed)
@@ -201,6 +202,7 @@ def test_finalize_copies_only_verified_retained_maps_and_keeps_old_upload(remote
struct.pack_into("<H", header, 18, 183)
(build / "halo").write_bytes(header)
(build / "libSDL3.so.0").write_bytes(header)
(build / "brokers.txt").write_text("broker.example.org:1883\n")
resources = remote.CACHE / "resources"
resources.mkdir()
(resources / "frame-controls.patch").write_bytes(b"verified native controls")
@@ -211,11 +213,13 @@ def test_finalize_copies_only_verified_retained_maps_and_keeps_old_upload(remote
monkeypatch.setattr(remote, "rename_noreplace", publish)
response = remote.finalize(NEW_RUN)
assert response["gamePath"] == str(remote.GAME)
assert (remote.GAME / "brokers.txt").read_text() == "broker.example.org:1883\n"
assert remote.read_marker(remote.GAME / remote.MARKER)["files"]["brokers.txt"] == remote.digest(remote.GAME / "brokers.txt")
assert remote.verify_maps(remote.GAME, manifest) == manifest["totalBytes"]
assert remote.verify_maps(older / "upload", manifest) == manifest["totalBytes"]
assert (older / "src/unfinished-build").read_bytes() == b"do not resume this old build"
assert not (remote.GAME / "unfinished-build").exists()
assert list((remote.GAME / "save").iterdir()) == []
assert list(remote.save_root().iterdir()) == []
class RetryConnection: