2 Commits
28 changed files with 1710 additions and 106 deletions

No files matched your search

+40 -11
View File
@@ -1,18 +1,39 @@
# 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>
<p align="center">
<img src="resources/ui/app-icon.png" alt="Installer Master Chief helmet icon" width="44" height="44">
&nbsp;&nbsp;
<img src="resources/artwork/halo-ce-logo.png" alt="Halo: Combat Evolved" width="180">
<br>
<strong>Native VR for Steam Frame</strong><br>
Xbox-style controls · USB-C or network transfer · Guided Windows setup
</p>
<p align="center">
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe"><strong>DOWNLOAD v1.3.1 — WINDOWS EXE</strong></a><br>
<sub>One installer file · Windows 10/11 x64 · No administrator rights needed</sub><br>
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.1">Release notes &amp; checksum</a> · <a href="#download-and-run">Setup guide</a> · <a href="#sources-and-credits">Sources &amp; credits</a>
</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.
**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.
**Version 1.3.1 — Halo library artwork and game Notes.** Adds a CE banner, transparent title logo, and Master Chief shortcut icon alongside the covers. A game description and Xbox-style control guide appear in **Steam Notes**, accessible from the game's details page; Steam does not provide an editable store-style About description for non-Steam games. Choose **Steam info** to update an existing native install without another ISO or rebuild. Custom artwork, icons, and notes are preserved. These additions have automated software coverage; their appearance on a physical Frame still needs verification.
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/cover-art 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.
## Download and run
**[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)**
**[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.1.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe)**
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.
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.1 release](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.1)** description includes the EXE's SHA256 checksum.
The executable has a Master Chief helmet icon with olive armor and a gold visor, drawn for this project.
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.
1. **Game data.** Choose **Install**, then use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
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.
@@ -56,7 +77,7 @@ These builds come from the [pinned port's compatibility table](https://github.co
### 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.
Choose **Repair** in the **Game data** step, connect your Frame, and click **Repair Halo VR** in the last step. Setup detects the standard native folder `~/Games/HaloCENativeVR` and reinstalls the native executable, SDL library, controls/tutorial patch, and notices; it restores the required VR/control settings while preserving saves and unrelated settings. Valid original Xbox maps already on the Frame can be reused without selecting another ISO. If maps are missing or damaged, select a valid local image/maps folder and retry. Previous installs made by this installer or the original guided native setup are recognized. A hand-installed native game in the same folder can be adopted only through the explicit Repair action, after its native executable and original Xbox maps validate. Unknown folders and other install locations are not overwritten.
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.
@@ -64,9 +85,15 @@ The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access,
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.
### Update only the Steam library information
Choose **Steam info** on the first page, connect the Frame, then click **Update Steam info**. No ISO, map transfer, compiler, or rebuild is needed. Setup verifies the recognized native installation and updates its shortcut, missing artwork, and **Halo CE VR — About & controls** note. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects.
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 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.
Choose **Uninstall** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup asks the running client to remove the exact native shortcut from its active account, then verifies that removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. An owned installation build still running also stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
**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.
@@ -101,7 +128,9 @@ See [controls and patch details](docs/CONTROLS.md).
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 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 installer's 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; **Steam info**, repair, and **Add to Steam again** use the same handling. No artwork download is needed. The new bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](resources/artwork/README.md). The user confirmed the older entry and covers on the Frame tested with 1.2.3; the 1.3.1 banner, logo, icon, and Notes still need a physical Frame check.
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.
@@ -112,7 +141,7 @@ The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenX
- [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).
- [Original Xbox dashboard visual references](docs/SOURCES.md#dashboard-visual-references) and [Halo CE artwork attribution and Steam Notes references](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.
@@ -134,7 +163,7 @@ Full source attribution, decompilation lineage, multiplayer references, and thir
- **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.
- **Artwork or description absent:** use **Steam info > Update Steam info**, or **Add to Steam again** after a completed 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](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.
@@ -150,7 +179,7 @@ python -m venv .venv
.\.venv\Scripts\python scripts/build_release.py
```
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.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.
Corresponding installer source and applicable third-party source/notices are available in the **[version 1.3.1 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.1)** 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.
+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.
+16 -15
View File
@@ -1,23 +1,24 @@
# Halo Steam Frame Setup 1.3.0
# Halo Steam Frame Setup 1.3.1
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 only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.1.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.1/Halo-Steam-Frame-Setup-1.3.1.exe)**. This is the complete Windows installer, including USB tools and library artwork; no separate ZIP or Python installation is needed.
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.
This patch fills out the native Halo non-Steam library entry with an original Halo CE banner, transparent title logo, cover images, and a Master Chief shortcut icon. It adds a game description and Xbox-style control guide in **Steam Notes**. Steam's non-Steam shortcuts do not expose an editable store-style About description: the patch uses the existing Notes feature instead of claiming a store page, achievements, reviews, or another game's Steam identity.
- 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.
Already installed? Choose **Steam info**, connect by USB-C or Wi-Fi/Ethernet, and click **Update Steam info**. This verifies the existing native game and updates its library information without reinstalling or compiling it, uploading maps, or requiring another ISO. Steam Home and SteamVR stay running.
**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.
- Adds missing cover, landscape tile, hero/banner and transparent logo through the running Steam client, or safe library-file registration when Steam is already closed. Custom artwork is preserved.
- Stores the shortcut icon in the owned native game folder and uses Steam's shortcut-icon interface. Existing custom icons are kept. If the icon's saved shortcut identity cannot be verified, setup shows a retry/manual step instead of guessing.
- Adds one private **Halo CE VR — About & controls** note using Steam's native Notes interface. Existing notes and edits are preserved. Notes use Steam's normal synchronization; if the feature is unavailable or sync cannot be verified, setup provides text to copy manually.
- Checks the active Steam account and exact native executable before library changes. Unavailable or unverified artwork, icon, or Notes updates are reported separately from the installed game's status.
- Retains Xbox controls, original Xbox data validation, tutorial changes, USB transfer, left-to-right progress, redacted logs, save-preserving repair and uninstall, and the Steam Home session safeguard.
- Refreshes the GitHub README with Halo banner, logo, and helmet artwork and a prominent single-file download link.
**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.
The new bundled artwork adds less than 1 MB. Hero and logo provenance and file hashes are recorded in the [artwork notice](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.1/resources/artwork/README.md). Promotional artwork remains separate from the project's MIT code license.
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.
**Validation limits:** the additional artwork, icon, and Notes paths have software tests; their appearance and Notes behavior still need verification on a real Steam Frame. The native game, Xbox controls, and earlier cover-art registration were previously checked on one Frame. Direct USB transfer, a fresh first install, and full game uninstall remain unverified on physical hardware. The upstream ARM64/OpenXR port is experimental. The Frame still needs internet for source and compiler downloads during installation; a Steam-info-only update does not build the game.
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.
Use your own authorized original **Xbox** Halo CE data for game installation. USA Rev 2 is validated; other retail revisions are accepted only when their actual map headers match the supported builds. PC, Xbox 360 Anniversary, MCC and Quest inputs remain unsupported. No ISO, game maps, commercial executable, keys or soundtrack is bundled.
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.
The [source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.1) includes the installer source, build scripts and third-party notices. The release attaches one clearly labeled EXE and prints its SHA256 here. The EXE is unsigned.
Sources: [Valve's library artwork guide](https://partner.steamgames.com/doc/store/assets/libraryassets?language=english), [Valve's Notes announcement](https://store.steampowered.com/news/app/593110/view/3687931965598906184), [Valve's Frame USB/SSH guide](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging), and the [full acknowledgements](SOURCES.md).
+11 -1
View File
@@ -97,7 +97,17 @@ These links credit visual and historical references. Dashboard styling is an ind
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 have automated tests but still need verification on a physical Frame.
## Local Halo menu music
+5 -1
View File
@@ -70,7 +70,11 @@ The installer icon is an original project drawing of Master Chief's helmet, with
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).
## User-supplied menu audio
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "halo-steam-frame-installer"
version = "1.3.0"
version = "1.3.1"
description = "Guided USB and network SSH installer for the native ARM64 Halo CE Steam Frame VR port"
requires-python = ">=3.11"
license = "MIT"
+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 installer's
existing original Master Chief helmet drawing (`resources/ui/app-icon.svg` and
`app-icon.png`). It is reused as the shortcut icon, separate from the original
game's promotional artwork.
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

+2 -2
View File
@@ -106,13 +106,13 @@
},
{
"name": "halo-steam-frame-installer",
"version": "1.3.0",
"version": "1.3.1",
"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",
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.1.dist-info/licenses/LICENSE",
"sha256": "b19db6d1e8942380f6a0dc3b9ec39cec802606c9d1c08a6050eae2990568afbb",
"bytes": 1318
}
@@ -0,0 +1,25 @@
MIT License
Copyright (c) 2026 Halo Steam Frame Installer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This license covers original installer code. Refer to docs/THIRD_PARTY.md
and resources/licenses for upstream code, patch, dependencies, and notices.
Commercial Halo game data is not included or licensed by this project.
+23 -5
View File
@@ -278,8 +278,9 @@ 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.")
@@ -312,7 +313,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 +323,17 @@ 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.")
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)
@@ -1056,7 +1069,11 @@ def shortcut(close_steam=False):
raise ValueError("Install and verify the native game 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():
@@ -1332,7 +1349,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 +1361,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}
+200
View File
@@ -0,0 +1,200 @@
"""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"
NOTE_CONTENT = """[h1]Halo: Combat Evolved VR (Native)[/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. 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.
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
provided by this installer.
[h2]Sources and setup help[/h2]
[url=https://github.com/ClassicWasTaken/HaloCE-SteamFrame]Installer, controls, and troubleshooting[/url]
[url=https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106]Pinned native port source[/url]
"""
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.');
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');
if (matches.length) {
await identity();
if (matches[0].title !== S.title || matches[0].content !== S.content ||
matches[0].shortcut_name !== S.name)
return {ok:true,status:'custom',message:'Your edited Halo note was kept unchanged.'};
return {ok:true,status:'existing',message:'The Halo About and controls note is already present.'};
}
if (doc.notes.length >= S.maxNotes) throw new Error('Steam notes exceed supported bounds');
const timestamp=Math.floor(Date.now()/1000);
const note={id:S.noteId,shortcut_name:S.name,ordinal:0,time_created:timestamp,
time_modified:timestamp,title:S.title,content:S.content};
const updated={...doc,notes:[...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 {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})
with Client() as client:
value = client.evaluate(expression(NOTES, spec))
status = value.get("status")
if status in ("added", "existing", "custom") and value.get("ok") is True:
result.update({"ok": True, "status": status,
"message": {"added": "Added the Halo About and controls note to Steam Game Notes.",
"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
+137 -7
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 = "88142798513ebd99fc7c6224023e8b44c05d0106"
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
@@ -420,11 +428,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 +565,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 +665,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 +692,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)}
+1 -1
View File
@@ -9,7 +9,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
VERSION = "1.3.0"
VERSION = "1.3.1"
EXE_NAME = f"Halo-Steam-Frame-Setup-{VERSION}.exe"
def allowed_source_files():
+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
import shutil
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)
# Reuse the installer's existing original vector-derived helmet drawing.
# Keeping the exact rendition also keeps its attribution and checksums clear.
shutil.copyfile(repo / "resources" / "ui" / "app-icon.png",
destination / "halo-ce-icon.png")
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.3.1"
+127 -25
View File
@@ -29,7 +29,8 @@ XBOX_REVISION_NOTE = "Original Xbox USA Rev 2 validated; other retail revisions
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 = (
@@ -307,17 +308,27 @@ class App(tk.Tk):
mode_card = self._card(body, "SELECT OPERATION")
choices = tk.Frame(mode_card, bg=SURFACE)
choices.pack(fill="x")
choices.columnconfigure((0, 1, 2), weight=1, uniform="operations")
choices.columnconfigure((0, 1, 2, 3), 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", "New native game", "install"),
(1, "Repair", "Keep saves", "repair"),
(2, "Steam info", "Update library", "library"),
(3, "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))
padx=(0, 6) if index == 0 else (6, 0) if index == 3 else (6, 6))
self.mode_choices.append(choice)
library = self._card(body, "UPDATE STEAM INFO")
self.library_card = library.master
self._label(library,
"Add Halo cover, banner, logo, and icon artwork, plus a game description in Steam Notes.\n"
"Use the native game already installed at ~/Games/HaloCENativeVR.\n"
"No ISO or rebuild is needed. Your game, saves, and custom library content are kept.",
10, background=SURFACE, wraplength=620).pack(anchor="w")
self.library_card.pack_forget()
data_panel = self._card(body)
self.data_panel = data_panel.master
data_panel.columnconfigure(1, weight=1)
@@ -494,6 +505,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)
@@ -537,44 +550,56 @@ class App(tk.Tk):
def _mode_changed(self):
repair = self.mode.get() == "repair"
uninstall = self.mode.get() == "uninstall"
if uninstall:
library = self.mode.get() == "library"
if uninstall or library:
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))
if library:
self.library_card.pack(fill="x", pady=(0, 10))
else:
self.library_card.pack_forget()
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.")
self.connection_steam_note.configure(text="Steam Home and SteamVR stay running. Uninstall may need a manual library step." if uninstall
else "Steam Home and SteamVR stay running. Setup will show any manual library steps.")
self.install_note.configure(text="Keep this window open until the uninstall finishes and setup disconnects." if uninstall
else "Keep this window open until Steam info is updated and setup disconnects. No rebuild is needed." if library
else "Keep this window open during setup. Allow 12 GB free on the Frame.\nThe first native build can take a while.")
self._update_summary()
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):
source = self.source.get()
uninstall = self.mode.get() == "uninstall"
self.summary_heading.set("UNINSTALL SUMMARY" if uninstall else "INSTALLATION SUMMARY")
self.summary_data_heading.set("NATIVE GAME FOLDER" if uninstall else "GAME DATA")
self.summary_source.set("~/Games/HaloCENativeVR" if uninstall else
library = self.mode.get() == "library"
self.summary_heading.set("UNINSTALL SUMMARY" if uninstall else "STEAM INFO SUMMARY" if library else "INSTALLATION SUMMARY")
self.summary_data_heading.set("NATIVE GAME FOLDER" if uninstall or library else "GAME DATA")
self.summary_source.set("~/Games/HaloCENativeVR" if uninstall or library else
Path(source).name if source else "Use valid Xbox maps already installed on the Frame")
if self.transport.get() == "usb":
serial = self.usb_serial.get().strip()
self.summary_frame.set("USB-C cable" + (f" · {serial}" if serial else " · Detect connected Frame"))
else:
self.summary_frame.set(self.host.get().strip() or "Enter your Frame address")
self.summary_action_heading.set("CAMPAIGN SAVES" if uninstall else "XBOX CONTROLS")
self.summary_action_heading.set("CAMPAIGN SAVES" if uninstall else "STEAM LIBRARY" if library else "XBOX CONTROLS")
self.summary_action.set(("Keep saves in a backup on the Frame" if self.keep_saves.get()
else "Remove saves contained in the native game folder") if uninstall else
"Cover, banner, logo, icon, and description in Steam Notes" if library 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
"Only Steam library info changes. Existing custom artwork and notes are preserved." if library else
"Motion aiming and the full Xbox-style layout are included.")
def _show_page(self, index):
@@ -586,11 +611,15 @@ 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")
library = self.mode.get() == "library"
titles = ("UNINSTALL HALO VR" if uninstall else "STEAM INFO" if library else "GAME DATA", "CONNECT FRAME",
"UNINSTALL HALO VR" if uninstall else "UPDATE STEAM INFO" if library else "INSTALL HALO VR")
subtitles = ("Choose your save preference before removing the native game." if uninstall else
"Install, repair, or uninstall native Halo VR.",
"Update library art and game notes for your installed native game." if library else
"Install, repair, update Steam info, or uninstall native Halo VR.",
"Prepare SSH, then connect to your Steam Frame.",
"Review the native game and saves before removing them." if uninstall else
"Refresh the library page without reinstalling the game." if library else
"Native VR. Xbox controls. Your Steam library.")
self.page_title.configure(text=titles[index])
self.page_subtitle.configure(text=subtitles[index])
@@ -614,7 +643,7 @@ class App(tk.Tk):
return
if self.current_page == 0:
repair = self.mode.get() == "repair"
if self.mode.get() != "uninstall" and (not self.authorized.get() or (not repair and not self.source.get())):
if self.mode.get() not in ("uninstall", "library") and (not self.authorized.get() or (not repair and not self.source.get())):
messagebox.showinfo("Choose game data",
"Confirm that you are authorized to use the installed Xbox game data." if repair else
"Choose your original Xbox Halo CE image or maps, and confirm you are authorized to use them.", parent=self)
@@ -631,11 +660,14 @@ 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 updates Halo's Steam library art and game notes without rebuilding." if self.mode.get() == "library" else
"Setup builds the native game, applies Xbox controls, and adds Halo to Steam.")
self._show_page(2)
else:
if self.mode.get() == "uninstall":
self._uninstall()
elif self.mode.get() == "library":
self._retry_steam()
else:
self._install(repair=self.mode.get() == "repair")
@@ -647,6 +679,7 @@ class App(tk.Tk):
nav.configure(state="disabled" if self.busy or index > self.max_page else "normal")
self.back_button.configure(state="disabled" if self.busy or self.current_page == 0 else "normal")
label = ("Continue" if self.current_page < 2 else "Uninstall Halo VR" if self.mode.get() == "uninstall"
else "Update Steam info" if self.mode.get() == "library"
else "Repair Halo VR" if self.mode.get() == "repair" else "Install Halo VR")
self.next_button.configure(text=label, state="disabled" if self.busy else "normal")
if self.busy:
@@ -680,6 +713,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()
@@ -761,6 +795,33 @@ 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.")
card = self._card(body, "BEFORE YOU START")
@@ -777,6 +838,7 @@ 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"
"• Steam info updates artwork and a Steam Notes description without an ISO or rebuild.\n"
"• Online play uses the native port's own multiplayer protocol.",
10, background=SURFACE, wraplength=530).pack(anchor="w")
links = self._card(body, "Project references")
@@ -876,6 +938,13 @@ class App(tk.Tk):
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()
@@ -984,6 +1053,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 +1064,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:
@@ -1112,29 +1183,60 @@ class App(tk.Tk):
elif kind == "complete":
result = event[1]
self.last_result = result
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)
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 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 "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("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
+12 -6
View File
@@ -172,9 +172,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 +184,20 @@ 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("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,8 +255,8 @@ 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)
+1 -1
View File
@@ -27,7 +27,7 @@ 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"),
"complete": (99, 99, "Finishing setup"),
+17 -3
View File
@@ -10,10 +10,11 @@ def smoke_test(resources: Path) -> dict:
from .gui import App
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:
@@ -50,7 +51,7 @@ def smoke_test(resources: Path) -> dict:
and hasattr(app, '_game_cover') and hasattr(app, 'music'))
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'}:
if {choice.value for choice in app.mode_choices} != {'install', 'repair', 'library', 'uninstall'}:
raise RuntimeError('Packaged operation choices are incomplete')
if {choice.value for choice in app.transport_choices} != {'network', 'usb'}:
raise RuntimeError('Packaged connection choices are incomplete')
@@ -71,6 +72,17 @@ def smoke_test(resources: Path) -> dict:
raise RuntimeError('Packaged USB setup must keep Steam Home running')
settings.password = ''
app.password.set('')
app.mode.set('library')
app.source.set('')
app.authorized.set(False)
app._show_page(0)
app._continue()
if (app.current_page != 1 or app.data_panel.winfo_manager()
or app.data_consent.winfo_manager() or not app.library_card.winfo_manager()
or 'No ISO or rebuild' not in '\n'.join(
str(widget.cget('text')) for widget in app.library_card.winfo_children()[0].winfo_children()
if 'text' in widget.keys())):
raise RuntimeError('Packaged Steam info update requires game data or lost its no-rebuild guidance')
finally:
for callback in app.tk.call('after', 'info'):
app.after_cancel(callback)
@@ -84,5 +96,7 @@ def smoke_test(resources: Path) -> dict:
'automaticMusicWithoutToggle': True,
'keepsSteamSessionRunning': True,
'usbTransferAvailable': True,
'steamInfoUpdateWithoutRebuild': True,
'steamDescriptionUsesNotes': True,
'usbToolsBundled': usb_bundled,
'passwordReprRedacted': True, 'networkConnections': 0}
+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": "88142798513ebd99fc7c6224023e8b44c05d0106",
"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()
+173 -2
View File
@@ -149,6 +149,7 @@ def test_gui_offline_smoke():
from halo_frame_installer import __version__
assert result['version'] == __version__
assert result['guiPagesInitialized'] == ['gameData', 'connection', 'install']
assert result['steamInfoUpdateWithoutRebuild'] and result['steamDescriptionUsesNotes']
assert result['networkConnections'] == 0
@@ -596,7 +597,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)
@@ -882,7 +883,7 @@ def test_revision_guidance_names_validated_rev_2_and_checked_cache_builds(app):
@WINDOWS_GUI
@pytest.mark.parametrize('mode', ['install', 'repair', 'uninstall'])
@pytest.mark.parametrize('mode', ['install', 'repair', 'library', 'uninstall'])
@pytest.mark.parametrize('games_closed', [False, True])
def test_game_closed_acknowledgment_never_authorizes_steam_shutdown(app, mode, games_closed):
app.mode.set(mode)
@@ -936,3 +937,173 @@ 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
def test_steam_info_navigation_requires_connection_but_no_iso_or_authorization(app):
app.mode.set('library')
assert app.library_card.winfo_manager()
assert not app.data_panel.winfo_manager() and not app.data_consent.winfo_manager()
assert not app.uninstall_card.winfo_manager()
assert 'No ISO or rebuild' in '\n'.join(_display_text(app.library_card))
app._continue()
assert app.current_page == 1
app._continue()
assert app.current_page == 1
app.password.set('offline-secret')
app._continue()
assert app.current_page == 1 # Existing game-close acknowledgement remains required.
app.steam_closed.set(True)
app._continue()
assert app.current_page == 2
assert app.primary_button.cget('text') == 'Update Steam info'
assert app.summary_source.get() == '~/Games/HaloCENativeVR'
assert 'Steam Notes' in app.summary_action.get()
assert 'custom artwork and notes are preserved' in app.summary_note.get()
app.mode.set('install')
app._show_page(0)
app._continue()
assert app.current_page == 0
assert not app.library_card.winfo_manager()
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
@WINDOWS_GUI
@pytest.mark.parametrize('transport', ['network', 'usb'])
def test_steam_info_worker_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.mode.set('library')
app.transport.set(transport)
app.steam_closed.set(True)
app.password.set('offline-secret')
app._show_page(2)
app._continue()
_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'
@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.mode.set('library')
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', 'library', '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()
+58 -1
View File
@@ -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():
@@ -253,6 +265,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
+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())
+234
View File
@@ -0,0 +1,234 @@
"""Exercise the actual Notes JavaScript against a bounded native API contract."""
import importlib.util
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})
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)
@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_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 "experimental native ARM64 Linux/OpenXR" in notes.NOTE_CONTENT