mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-17 16:00:27 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1110a361a |
No files matched your search
@@ -7,9 +7,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.2/Halo-Steam-Frame-Setup-1.3.2.exe"><strong>DOWNLOAD v1.3.2 — WINDOWS EXE</strong></a><br>
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.3/Halo-Steam-Frame-Setup-1.3.3.exe"><strong>DOWNLOAD v1.3.3 — WINDOWS EXE</strong></a><br>
|
||||
<sub>One installer file · No administrator rights needed</sub><br>
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.2">Release notes & checksum</a>
|
||||
<a href="https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.3">Release notes & checksum</a>
|
||||
</p>
|
||||
|
||||
Bring Halo: Combat Evolved to Steam Frame with a native ARM64/OpenXR VR port, Xbox-style controls, and Halo artwork in your Steam library. The guided installer uses an original Xbox dashboard style and the classic Halo PC desktop icon.
|
||||
@@ -20,7 +20,7 @@ You need **Windows 10/11 x64**, a **Steam Frame**, and your own authorized **ori
|
||||
2. Enable Frame Developer Mode, set its user password, and connect over **USB-C** or **Wi-Fi / Ethernet**. The app guides you through SSH setup.
|
||||
3. Save and close games, keep **Steam Home** running, and click **Install Halo VR**. The first build can take tens of minutes; your Frame needs internet access.
|
||||
|
||||
Already installed? Choose **Repair** to refresh the game, **Steam info** to update library artwork and description in Steam Notes without rebuilding, or **Uninstall** to remove it with an optional save backup.
|
||||
Steam artwork and the description/control guide in **Notes** are added automatically during Install and Repair. Already installed? Choose **Repair** to refresh the game, or **Uninstall** to remove it with an optional save backup.
|
||||
|
||||
The port is experimental; newer USB and library features still need physical Frame verification.
|
||||
|
||||
|
||||
+9
-11
@@ -1,19 +1,17 @@
|
||||
# Halo Steam Frame Setup 1.3.2
|
||||
# Halo Steam Frame Setup 1.3.3
|
||||
|
||||
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.2.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.2/Halo-Steam-Frame-Setup-1.3.2.exe)**. This is the complete Windows installer; no separate ZIP or Python installation is needed.
|
||||
Download only **[DOWNLOAD THIS FILE — Halo-Steam-Frame-Setup-1.3.3.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.3/Halo-Steam-Frame-Setup-1.3.3.exe)**. This is the complete Windows installer; no separate ZIP or Python installation is needed.
|
||||
|
||||
This small visual patch replaces the Windows setup executable and window icon with the classic Halo PC Master Chief helmet-and-shoulders icon. It uses the exact original ICO artwork matching the requested reference, including all 12 native image entries: 16, 24, 32, and 48 pixels at 4, 8, and 32-bit color. The clean asset has no desktop background, shortcut arrow, or filename label.
|
||||
Install and Repair automatically add the Steam shortcut, covers, banner, logo, icon, and description/control guide in **Steam Game Notes**. The first page now has three choices: **Install**, **Repair**, and **Uninstall**. A separate Steam info selection is no longer needed. **Add to Steam again** remains available when a completed run reports pending library information; that retry does not rebuild the game.
|
||||
|
||||
The GitHub README is shorter, with just the Halo CE banner and title logo as artwork, a prominent single-EXE download link, and the essential setup steps. Detailed instructions and troubleshooting are in the [setup guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.2/docs/SETUP.md).
|
||||
Music preparation is more responsive after selecting an ISO. The background decoder uses precomputed ADPCM transitions and regularly yields CPU time to the window. Canceled or superseded data checks cannot start an old music preview or replace the current selection. The music still comes only from your own local Xbox game data, at the existing volume; no soundtrack is included or downloaded.
|
||||
|
||||
The source ICO was downloaded from [HaloNet](https://halonet.net/favicon.ico) and verified byte-for-byte against the locally installed retail Halo PC executable's icon group 102. Only icon artwork is included, with [exact hashes and attribution](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.2/resources/ui/README.md). Halo artwork rights remain separate from the installer's MIT code license.
|
||||
The installer retains its original Xbox dashboard appearance, classic Halo PC icon, automatic music, Xbox controls, USB/network transfer, save-preserving repair, and optional save backup during uninstall. Steam Home stays running throughout setup.
|
||||
|
||||
This release retains the 1.3.1 Steam banner, title logo, covers, private description/control guide in Steam Notes, and **Steam info > Update Steam info** workflow. The Steam shortcut retains its existing project-drawn helmet icon. USB/network transfer, Xbox controls, repair, uninstall, and Steam Home safeguards continue unchanged. The new installer icon does not require reinstalling the game.
|
||||
The native ARM64/OpenXR port remains experimental. Direct USB transfer, a fresh first install, full uninstall, and the newer Steam banner/logo/icon/Notes still need physical Frame verification. Automated checks of the music decoder and window responsiveness do not replace a listening check on your PC.
|
||||
|
||||
The native ARM64/OpenXR port remains experimental. New Steam library artwork/Notes, direct USB transfer, a fresh first install, and full game uninstall still need physical Frame verification. This cosmetic patch does not establish additional device compatibility or performance improvements.
|
||||
Supply your own authorized original **Xbox** Halo CE data. **USA Rev 2 is validated**; other retail revisions need supported map headers. PC, Xbox 360 Anniversary, MCC, and Quest data are unsupported. No game executable, ISO, maps, product keys, or soundtrack is bundled.
|
||||
|
||||
The installer uses user-supplied original **Xbox** Halo CE data: USA Rev 2 is validated; other retail revisions require supported map headers. PC, Xbox 360 Anniversary, MCC, and Quest game-data inputs remain unsupported. No game executable, ISO, maps, product keys, or soundtrack is bundled.
|
||||
The EXE is unsigned. Its SHA256 appears below. [Matching source and third-party notices](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.3) remain available for rebuilding. GitHub's automatic source downloads are optional for developers.
|
||||
|
||||
The EXE is unsigned. Its SHA256 appears below. [Matching source and third-party notices](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.2) remain available for rebuilding. GitHub's automatic source downloads are optional for developers.
|
||||
|
||||
[Full sources and credits](SOURCES.md).
|
||||
[Setup guide](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/blob/v1.3.3/docs/SETUP.md) · [Sources and credits](SOURCES.md).
|
||||
+7
-7
@@ -6,7 +6,7 @@ This guide covers installation, USB-C and network connections, repair, Steam lib
|
||||
|
||||
## Before you begin
|
||||
|
||||
Download the single **[Halo-Steam-Frame-Setup-1.3.2.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.2/Halo-Steam-Frame-Setup-1.3.2.exe)** for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. The [release notes](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.2) include the EXE's SHA256 checksum.
|
||||
Download the single **[Halo-Steam-Frame-Setup-1.3.3.exe](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/download/v1.3.3/Halo-Steam-Frame-Setup-1.3.3.exe)** for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. The [release notes](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/releases/tag/v1.3.3) include the EXE's SHA256 checksum.
|
||||
|
||||
Use your own authorized original Xbox Halo CE ISO/XISO or extracted maps. USA Rev 2 is validated; the supported-build table below explains other retail revisions. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are unsupported. No commercial game data is bundled.
|
||||
|
||||
@@ -40,7 +40,7 @@ The included ADB executable, two companion DLLs, and unmodified notices are chec
|
||||
|
||||
These changes apply after you launch the new EXE and start a run. An installation already running in an older EXE keeps its existing behavior; downloading a replacement cannot change that ongoing process.
|
||||
|
||||
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
|
||||
After you select valid local Xbox game data, the installer automatically loops the original Halo menu music. Validation and music preparation run in the background. Setup reads the menu loop from your own `ui.map`, decodes it at 12% of the original sample amplitude, and keeps the temporary audio on your Windows computer until the installer closes. No soundtrack is bundled, downloaded, or uploaded to the Frame. Repair using only maps already on the Frame has no local music preview. If music is unavailable, installation can continue.
|
||||
|
||||
### Supported Xbox ISO revisions
|
||||
|
||||
@@ -62,9 +62,9 @@ The old PC installation at `~/Games/HaloCEVR` is detected and explained separate
|
||||
|
||||
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
|
||||
### Automatic 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.
|
||||
Install and Repair automatically update the native Steam shortcut, missing artwork, and **Halo CE VR — About & controls** note after the game is ready. There is no separate Steam info option to select. Save and close games, leave Steam Home running, and keep the headset connected until setup disconnects. If registration, artwork, or Notes remains pending at the end, **Add to Steam again** retries that step without transferring maps or rebuilding the game.
|
||||
|
||||
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.
|
||||
|
||||
@@ -107,7 +107,7 @@ Game: `~/Games/HaloCENativeVR`, including `halo`, bundled SDL3, your maps, confi
|
||||
|
||||
The original Halo CE cover is shown inside the installer. The native shortcut receives missing portrait and landscape covers, a 1,920 × 620 original CE promotional banner, transparent title logo, and the project-drawn Master Chief helmet icon. With Steam running, setup uses its verified client interface, checks the active account and native executable, and verifies the artwork hashes. The icon is kept in the owned game's `.installer-artwork` folder so it survives the setup EXE closing. Its existing path is verified through read-only shortcut-file checks before changing it through Steam's API. Both running-client and already-closed file paths preserve custom art and icons, including different supported image extensions. An icon that cannot be verified is reported as pending rather than replacing an uncertain user choice.
|
||||
|
||||
The description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; **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 description and Xbox controls use **Steam Game Notes**. Setup appends one bounded note without replacing other notes or an edited installer note. Artwork and Notes have independent pending states; Install, Repair, and any completion retry use the same handling. No artwork download is needed. The new bundled images total less than 1 MB, with sources and exact hashes in the [artwork resource notice](../resources/artwork/README.md). The user confirmed the older entry and covers on the Frame tested with 1.2.3; the newer banner, logo, icon, and Notes still need a physical Frame check.
|
||||
|
||||
The 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.
|
||||
|
||||
@@ -140,7 +140,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.
|
||||
- **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.
|
||||
- **Artwork or description absent:** Install and Repair add these automatically. If setup reports pending Steam information, click **Add to Steam again** after the run. The description is under **Notes**, not the non-Steam About panel. Pending artwork, icon, or Notes is reported separately from the shortcut; manual Notes includes copyable text. Existing custom artwork and notes are kept. Direct file edits require Steam to be already closed; Notes needs the running client's interface.
|
||||
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](SOURCES.md#steam-session-safeguard).
|
||||
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
|
||||
|
||||
@@ -156,7 +156,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.2 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.2)** 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.3 source tag](https://github.com/ClassicWasTaken/HaloCE-SteamFrame/tree/v1.3.3)** 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.
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "halo-steam-frame-installer"
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
description = "Guided USB and network SSH installer for the native ARM64 Halo CE Steam Frame VR port"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
|
||||
@@ -106,13 +106,13 @@
|
||||
},
|
||||
{
|
||||
"name": "halo-steam-frame-installer",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"license_expression": "MIT",
|
||||
"declared_license": null,
|
||||
"project_urls": [],
|
||||
"notices": [
|
||||
{
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.2.dist-info/licenses/LICENSE",
|
||||
"path": "packages/halo-steam-frame-installer/halo_steam_frame_installer-1.3.3.dist-info/licenses/LICENSE",
|
||||
"sha256": "b19db6d1e8942380f6a0dc3b9ec39cec802606c9d1c08a6050eae2990568afbb",
|
||||
"bytes": 1318
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Halo Steam Frame Installer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This license covers original installer code. Refer to docs/THIRD_PARTY.md
|
||||
and resources/licenses for upstream code, patch, dependencies, and notices.
|
||||
Commercial Halo game data is not included or licensed by this project.
|
||||
@@ -9,7 +9,7 @@ import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
VERSION = "1.3.2"
|
||||
VERSION = "1.3.3"
|
||||
EXE_NAME = f"Halo-Steam-Frame-Setup-{VERSION}.exe"
|
||||
|
||||
def allowed_source_files():
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
|
||||
__version__ = "1.3.2"
|
||||
__version__ = "1.3.3"
|
||||
@@ -119,6 +119,7 @@ def _cache_header(header: bytes, name: str, actual_size: int) -> str:
|
||||
|
||||
class _XboxImage:
|
||||
def __init__(self, path: Path, cancel_event=None):
|
||||
_cancel(cancel_event)
|
||||
_regular_file(path)
|
||||
self.path = path
|
||||
self.image: BinaryIO = path.open("rb")
|
||||
@@ -161,6 +162,7 @@ class _XboxImage:
|
||||
ranges.append((offset, end))
|
||||
self.image.seek(offset)
|
||||
builds.add(_cache_header(self.image.read(SECTOR_SIZE), entry.name, entry.size))
|
||||
_cancel(cancel_event)
|
||||
self.info = _info(builds, sum(entry.size for entry in self.maps), self.partition)
|
||||
except BaseException:
|
||||
self.image.close()
|
||||
@@ -170,6 +172,7 @@ class _XboxImage:
|
||||
self.image.close()
|
||||
|
||||
def entries(self, sector: int, length: int) -> list[_Entry]:
|
||||
_cancel(self.cancel_event)
|
||||
if not 0 < length <= MAX_DIRECTORY_BYTES:
|
||||
raise AssetError("Invalid Xbox directory length.")
|
||||
offset = self.partition + sector * SECTOR_SIZE
|
||||
@@ -224,15 +227,19 @@ def _info(builds: set[str], total: int, partition: int | None) -> ImageInfo:
|
||||
return ImageInfo("original-xbox", next(iter(builds)), total, len(REQUIRED_MAPS), partition)
|
||||
|
||||
|
||||
def inspect_image(path: str | Path) -> ImageInfo:
|
||||
image = _XboxImage(Path(path))
|
||||
def inspect_image(path: str | Path, cancel_event=None) -> ImageInfo:
|
||||
"""Inspect image metadata, checking cancellation throughout validation."""
|
||||
_cancel(cancel_event)
|
||||
image = _XboxImage(Path(path), cancel_event)
|
||||
try:
|
||||
_cancel(cancel_event)
|
||||
return image.info
|
||||
finally:
|
||||
image.close()
|
||||
|
||||
|
||||
def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
def _maps_source(path: Path, cancel_event=None) -> tuple[Path, list[Path], ImageInfo]:
|
||||
_cancel(cancel_event)
|
||||
if not path.is_dir() or _is_link(path):
|
||||
raise AssetError("Select a real maps directory, not a link.")
|
||||
candidate = path / "maps"
|
||||
@@ -242,6 +249,7 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
path = candidate
|
||||
names: dict[str, Path] = {}
|
||||
for count, entry in enumerate(path.iterdir(), 1):
|
||||
_cancel(cancel_event)
|
||||
if count > MAX_DIRECTORY_ENTRIES:
|
||||
raise AssetError("The maps directory has too many entries.")
|
||||
_safe_name(entry.name)
|
||||
@@ -251,22 +259,27 @@ def _maps_source(path: Path) -> tuple[Path, list[Path], ImageInfo]:
|
||||
if folded in names:
|
||||
raise AssetError("Case-insensitive duplicate filenames in the maps directory.")
|
||||
names[folded] = entry
|
||||
_cancel(cancel_event)
|
||||
missing = set(REQUIRED_MAPS) - names.keys()
|
||||
if missing:
|
||||
raise AssetError("Incomplete maps directory: missing " + ", ".join(sorted(missing)))
|
||||
files = [names[name] for name in REQUIRED_MAPS]
|
||||
builds, total = set(), 0
|
||||
for file in files:
|
||||
_cancel(cancel_event)
|
||||
_regular_file(file)
|
||||
with file.open("rb") as source:
|
||||
size = os.fstat(source.fileno()).st_size
|
||||
builds.add(_cache_header(source.read(SECTOR_SIZE), file.name, size))
|
||||
total += size
|
||||
_cancel(cancel_event)
|
||||
return path, files, _info(builds, total, None)
|
||||
|
||||
|
||||
def inspect_maps(path: str | Path) -> ImageInfo:
|
||||
return _maps_source(Path(path))[2]
|
||||
def inspect_maps(path: str | Path, cancel_event=None) -> ImageInfo:
|
||||
"""Inspect extracted map headers without copying, with optional cancellation."""
|
||||
_cancel(cancel_event)
|
||||
return _maps_source(Path(path), cancel_event)[2]
|
||||
|
||||
|
||||
def _new_staging(dest: Path) -> Path:
|
||||
@@ -365,7 +378,7 @@ def copy_maps(source: str | Path, dest: str | Path, progress: Progress | None =
|
||||
cancel_event=None) -> dict:
|
||||
"""Prepare an already extracted retail maps folder using the same validation."""
|
||||
_cancel(cancel_event)
|
||||
source, source_files, info = _maps_source(Path(source))
|
||||
source, source_files, info = _maps_source(Path(source), cancel_event)
|
||||
dest = Path(dest).absolute()
|
||||
stage = _new_staging(dest)
|
||||
try:
|
||||
|
||||
@@ -102,6 +102,8 @@ class App(tk.Tk):
|
||||
self.entries = []
|
||||
self.checks = []
|
||||
self.last_result = None
|
||||
self._asset_generation = 0
|
||||
self._inspection_cancel = None
|
||||
self._build()
|
||||
self.source.trace_add("write", lambda *args:self._source_changed())
|
||||
self.mode.trace_add("write", lambda *args:self._mode_changed())
|
||||
@@ -308,27 +310,17 @@ 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, 3), weight=1, uniform="operations")
|
||||
choices.columnconfigure((0, 1, 2), weight=1, uniform="operations")
|
||||
self.mode_choices = []
|
||||
for index, title, subtitle, value in (
|
||||
(0, "Install", "New native game", "install"),
|
||||
(1, "Repair", "Keep saves", "repair"),
|
||||
(2, "Steam info", "Update library", "library"),
|
||||
(3, "Uninstall", "Remove game", "uninstall")):
|
||||
(2, "Uninstall", "Remove game", "uninstall")):
|
||||
choice = Choice(choices, title, subtitle, self.mode, value, self._mode_changed)
|
||||
choice.grid(row=0, column=index, sticky="ew",
|
||||
padx=(0, 6) if index == 0 else (6, 0) if index == 3 else (6, 6))
|
||||
padx=(0, 6) if index == 0 else (6, 0) if index == 2 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)
|
||||
@@ -540,6 +532,13 @@ class App(tk.Tk):
|
||||
pass
|
||||
|
||||
def _source_changed(self):
|
||||
self._asset_generation += 1
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self._inspection_cancel = None
|
||||
if self.busy and getattr(self, "operation", None) == "data":
|
||||
self._set_busy(False)
|
||||
self.status.set("Game data selection changed. Choose an original Xbox image or maps folder to continue.")
|
||||
source = self.source.get()
|
||||
self.source_label.set(Path(source).name if source else "No game data selected")
|
||||
if not source:
|
||||
@@ -550,8 +549,7 @@ class App(tk.Tk):
|
||||
def _mode_changed(self):
|
||||
repair = self.mode.get() == "repair"
|
||||
uninstall = self.mode.get() == "uninstall"
|
||||
library = self.mode.get() == "library"
|
||||
if uninstall or library:
|
||||
if uninstall:
|
||||
self.data_panel.pack_forget()
|
||||
self.data_consent.pack_forget()
|
||||
else:
|
||||
@@ -561,17 +559,12 @@ class App(tk.Tk):
|
||||
self.uninstall_card.pack(fill="x", pady=(0, 10))
|
||||
else:
|
||||
self.uninstall_card.pack_forget()
|
||||
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()
|
||||
@@ -583,24 +576,21 @@ class App(tk.Tk):
|
||||
def _update_summary(self):
|
||||
source = self.source.get()
|
||||
uninstall = self.mode.get() == "uninstall"
|
||||
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
|
||||
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
|
||||
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 "STEAM LIBRARY" if library else "XBOX CONTROLS")
|
||||
self.summary_action_heading.set("CAMPAIGN SAVES" if uninstall 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.")
|
||||
"Motion aiming and Xbox-style controls are included. Steam artwork and game Notes are added automatically.")
|
||||
|
||||
def _show_page(self, index):
|
||||
if not 0 <= index < len(self.pages):
|
||||
@@ -611,15 +601,12 @@ 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"
|
||||
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")
|
||||
titles = ("UNINSTALL HALO VR" if uninstall else "GAME DATA", "CONNECT FRAME",
|
||||
"UNINSTALL HALO VR" if uninstall else "INSTALL HALO VR")
|
||||
subtitles = ("Choose your save preference before removing the native game." if uninstall else
|
||||
"Update library art and game notes for your installed native game." if library else
|
||||
"Install, repair, update Steam info, or uninstall native Halo VR.",
|
||||
"Install, repair, 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])
|
||||
@@ -643,7 +630,7 @@ class App(tk.Tk):
|
||||
return
|
||||
if self.current_page == 0:
|
||||
repair = self.mode.get() == "repair"
|
||||
if self.mode.get() not in ("uninstall", "library") and (not self.authorized.get() or (not repair and not self.source.get())):
|
||||
if self.mode.get() != "uninstall" 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)
|
||||
@@ -660,14 +647,11 @@ 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.")
|
||||
"Setup builds the native game, applies Xbox controls, and automatically adds Steam artwork and game Notes.")
|
||||
self._show_page(2)
|
||||
else:
|
||||
if self.mode.get() == "uninstall":
|
||||
self._uninstall()
|
||||
elif self.mode.get() == "library":
|
||||
self._retry_steam()
|
||||
else:
|
||||
self._install(repair=self.mode.get() == "repair")
|
||||
|
||||
@@ -679,7 +663,6 @@ 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:
|
||||
@@ -749,6 +732,8 @@ class App(tk.Tk):
|
||||
self.status.set("Finishing removal; please wait until setup disconnects.")
|
||||
return
|
||||
self.cancel.set()
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self.phase.set("Cancelling setup…")
|
||||
self.status.set("Keep this window open until setup stops. Check activity for the uninstall result." if getattr(self, "operation", None) == "uninstall"
|
||||
else "Keep this window open until setup stops. Existing saves will be kept.")
|
||||
@@ -838,7 +823,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"
|
||||
"• Install and repair automatically add Steam artwork and a game description in Steam Notes.\n"
|
||||
"• Online play uses the native port's own multiplayer protocol.",
|
||||
10, background=SURFACE, wraplength=530).pack(anchor="w")
|
||||
links = self._card(body, "Project references")
|
||||
@@ -877,21 +862,51 @@ class App(tk.Tk):
|
||||
self.adb_path.set(selected)
|
||||
|
||||
def _inspect(self, selected):
|
||||
if self.busy:
|
||||
if self.busy and getattr(self, "operation", None) != "data":
|
||||
return
|
||||
selected = Path(selected)
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
self._asset_generation += 1
|
||||
generation = self._asset_generation
|
||||
cancel = self._inspection_cancel = threading.Event()
|
||||
self.operation = "data"
|
||||
self.asset_status.set("Checking original Xbox game data…")
|
||||
self.status.set("Checking the selected Xbox game data…")
|
||||
self.music.load(None)
|
||||
self._set_busy(True)
|
||||
def worker():
|
||||
summary = error = None
|
||||
try:
|
||||
info = inspect_maps(selected) if selected.is_dir() else inspect_image(selected)
|
||||
self.events.put(("asset", str(selected), f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"))
|
||||
info = (inspect_maps(selected, cancel_event=cancel) if selected.is_dir()
|
||||
else inspect_image(selected, cancel_event=cancel))
|
||||
summary = f"Validated {info.map_count} original Xbox maps • {info.estimated_bytes / 1e9:.2f} GB • {info.build}"
|
||||
except Exception as exc:
|
||||
self.events.put(("error", str(exc)))
|
||||
error = str(exc)
|
||||
finally:
|
||||
self.events.put(("idle",))
|
||||
# A single generation-bound result cannot clear the busy state
|
||||
# or report an error for a different, newer selection.
|
||||
self.events.put(("asset", generation, cancel, str(selected), summary, error))
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _finish_asset_check(self, generation, cancel, selected, summary, error):
|
||||
if generation != self._asset_generation or cancel is not self._inspection_cancel:
|
||||
return
|
||||
self._inspection_cancel = None
|
||||
self._set_busy(False)
|
||||
if cancel.is_set() or self.cancel.is_set():
|
||||
self.asset_status.set("Game data check cancelled. Choose an original Xbox image or maps folder to retry.")
|
||||
self.status.set("Game data check cancelled. Your previous selection was kept.")
|
||||
self.phase.set("Game data check cancelled")
|
||||
return
|
||||
if error is not None:
|
||||
self._show_setup_error(error)
|
||||
return
|
||||
self.source.set(selected)
|
||||
self.asset_status.set(summary)
|
||||
self.music.load(Path(selected))
|
||||
self.status.set("Game data validated. Connect your Frame next.")
|
||||
|
||||
def _settings(self):
|
||||
usb = self.transport.get() == "usb"
|
||||
if usb:
|
||||
@@ -932,7 +947,12 @@ class App(tk.Tk):
|
||||
choice.configure(state="disabled" if value else "normal")
|
||||
for choice in self.transport_choices:
|
||||
choice.configure(state="disabled" if value else "normal")
|
||||
allow_steam_retry = self.last_result is not None and self.mode.get() != "uninstall"
|
||||
steam = self.last_result.steam if self.last_result is not None else {}
|
||||
pending_steam = (bool(self.last_result and self.last_result.requires_manual_steam_step)
|
||||
or steam.get("artwork", {}).get("status") == "manual"
|
||||
or steam.get("artwork", {}).get("icon", {}).get("status") == "manual"
|
||||
or steam.get("notes", {}).get("status") == "manual")
|
||||
allow_steam_retry = self.last_result is not None and pending_steam and self.mode.get() != "uninstall"
|
||||
self.retry_button.configure(state="normal" if not value and allow_steam_retry else "disabled")
|
||||
if allow_steam_retry:
|
||||
self.retry_button.pack(anchor="w", pady=(11, 0))
|
||||
@@ -1032,7 +1052,8 @@ class App(tk.Tk):
|
||||
result = run(settings, None, report, self.cancel)
|
||||
self.events.put(("complete", result))
|
||||
return
|
||||
info = inspect_maps(source) if source.is_dir() else inspect_image(source)
|
||||
info = (inspect_maps(source, cancel_event=self.cancel) if source.is_dir()
|
||||
else inspect_image(source, cancel_event=self.cancel))
|
||||
if shutil.disk_usage(tempfile.gettempdir()).free < info.estimated_bytes + 512 * 1024**2:
|
||||
raise ValueError("Not enough free temporary space on this computer to prepare the maps.")
|
||||
with tempfile.TemporaryDirectory(prefix="halo-frame-") as temporary:
|
||||
@@ -1143,9 +1164,7 @@ class App(tk.Tk):
|
||||
event = self.events.get_nowait()
|
||||
kind = event[0]
|
||||
if kind == "asset":
|
||||
self.source.set(event[1]); self.asset_status.set(event[2])
|
||||
self.music.load(Path(event[1]))
|
||||
self.status.set("Game data validated. Connect your Frame next.")
|
||||
self._finish_asset_check(*event[1:])
|
||||
elif kind == "music":
|
||||
self.music_status.set(self._redact(event[1]))
|
||||
elif kind == "fingerprint":
|
||||
@@ -1260,18 +1279,7 @@ class App(tk.Tk):
|
||||
self.retry_button.pack_forget()
|
||||
messagebox.showinfo("Uninstall needs attention" if pending else "Uninstall complete", done, parent=self)
|
||||
elif kind == "error":
|
||||
text = self._redact(event[1])
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
if getattr(self, "operation", None) == "connection":
|
||||
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
|
||||
elif getattr(self, "operation", None) == "data":
|
||||
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
|
||||
self._append(text)
|
||||
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
|
||||
if len(summary) > 500 or "\n" in text:
|
||||
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
|
||||
messagebox.showerror("Setup needs attention", summary, parent=self)
|
||||
self._show_setup_error(event[1])
|
||||
elif kind == "clear_password":
|
||||
self.password.set(""); self.password_to_redact = ""
|
||||
elif kind == "idle":
|
||||
@@ -1284,6 +1292,20 @@ class App(tk.Tk):
|
||||
pass
|
||||
self.after(10 if not self.events.empty() else 80, self._drain)
|
||||
|
||||
def _show_setup_error(self, text):
|
||||
text = self._redact(text)
|
||||
self.status.set("Setup stopped. See the message below; you can retry.")
|
||||
self.phase.set("Let's try that again")
|
||||
if getattr(self, "operation", None) == "connection":
|
||||
self.connection_status.set("Connection check needs attention. See setup activity, then retry.")
|
||||
elif getattr(self, "operation", None) == "data":
|
||||
self.asset_status.set("Couldn't validate this data. Choose an original Xbox image or maps folder.")
|
||||
self._append(text)
|
||||
summary = text.strip().splitlines()[0] if text.strip() else "Setup could not finish. You can retry."
|
||||
if len(summary) > 500 or "\n" in text:
|
||||
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
|
||||
messagebox.showerror("Setup needs attention", summary, parent=self)
|
||||
|
||||
def _remember(self, host, port, value):
|
||||
identity = host if port is None or host.startswith("usb:") else f"{host}:{port}"
|
||||
self.fingerprints[identity] = value
|
||||
@@ -1316,6 +1338,9 @@ class App(tk.Tk):
|
||||
self.destroy()
|
||||
|
||||
def destroy(self):
|
||||
self._asset_generation += 1
|
||||
if self._inspection_cancel is not None:
|
||||
self._inspection_cancel.set()
|
||||
music = getattr(self, "music", None)
|
||||
if music is not None:
|
||||
music.close()
|
||||
|
||||
@@ -32,6 +32,19 @@ STEP = (7,8,9,10,11,12,13,14,16,17,19,21,23,25,28,31,34,37,41,45,
|
||||
INDEX = (-1,-1,-1,-1,2,4,6,8,-1,-1,-1,-1,2,4,6,8)
|
||||
|
||||
|
||||
# The Xbox predictor/index transition depends only on these 89 x 16 states.
|
||||
# Calculate it once instead of doing shifts and clamps for every audio sample.
|
||||
def _transition(index, nibble):
|
||||
step = STEP[index]
|
||||
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
|
||||
return (-difference if nibble & 8 else difference), max(0, min(88, index+INDEX[nibble]))
|
||||
|
||||
|
||||
TRANSITIONS = tuple(tuple(_transition(index, nibble) for nibble in range(16)) for index in range(89))
|
||||
DECODE_BATCH_BLOCKS = 64
|
||||
DECODE_YIELD_SECONDS = 0.001
|
||||
|
||||
|
||||
class MusicError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -182,24 +195,33 @@ def decode_adpcm(source: bytes, channels: int, cancel_event=None, gain=QUIET_GAI
|
||||
block_size = 36*channels
|
||||
samples = array('h', [0]) * (len(source)//block_size*64*channels)
|
||||
for block in range(len(source)//block_size):
|
||||
if block % 128 == 0:
|
||||
if block % DECODE_BATCH_BLOCKS == 0:
|
||||
_cancel(cancel_event)
|
||||
if block:
|
||||
# A background Python thread still competes with Tk for the GIL.
|
||||
# Give the window a regular scheduling opportunity while decoding
|
||||
# long tracks, and promptly abandon a superseded ISO selection.
|
||||
time.sleep(DECODE_YIELD_SECONDS)
|
||||
_cancel(cancel_event)
|
||||
position = block*block_size
|
||||
for channel in range(channels):
|
||||
predictor, index, _ = _fields(source, position+channel*4, '<hBB')
|
||||
if index > 88:
|
||||
raise MusicError('Invalid ADPCM predictor index.')
|
||||
sample_position = block*64*channels+channel
|
||||
for group in range(8):
|
||||
base = position+4*channels+(group*channels+channel)*4
|
||||
for byte in range(4):
|
||||
packed = source[base+byte]
|
||||
for half, nibble in enumerate((packed & 15, packed >> 4)):
|
||||
step = STEP[index]
|
||||
difference = (step >> 3) + (step >> 2 if nibble & 1 else 0) + (step >> 1 if nibble & 2 else 0) + (step if nibble & 4 else 0)
|
||||
predictor = max(-32768, min(32767, predictor + (-difference if nibble & 8 else difference)))
|
||||
index = max(0, min(88, index+INDEX[nibble]))
|
||||
frame = block*64+group*8+byte*2+half
|
||||
samples[frame*channels+channel] = int(predictor*gain)
|
||||
for nibble in (packed & 15, packed >> 4):
|
||||
difference, index = TRANSITIONS[index][nibble]
|
||||
predictor += difference
|
||||
if predictor < -32768:
|
||||
predictor = -32768
|
||||
elif predictor > 32767:
|
||||
predictor = 32767
|
||||
samples[sample_position] = int(predictor*gain)
|
||||
sample_position += channels
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
return samples.tobytes()
|
||||
|
||||
@@ -51,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', 'library', 'uninstall'}:
|
||||
if {choice.value for choice in app.mode_choices} != {'install', 'repair', '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')
|
||||
@@ -72,17 +72,12 @@ 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')
|
||||
for mode in ('install', 'repair'):
|
||||
app.mode.set(mode)
|
||||
guidance = app.summary_note.get()
|
||||
if ('Steam artwork and game Notes are added automatically' not in guidance
|
||||
or hasattr(app, 'library_card')):
|
||||
raise RuntimeError('Packaged install/repair lost automatic Steam info setup')
|
||||
finally:
|
||||
for callback in app.tk.call('after', 'info'):
|
||||
app.after_cancel(callback)
|
||||
@@ -96,7 +91,7 @@ def smoke_test(resources: Path) -> dict:
|
||||
'automaticMusicWithoutToggle': True,
|
||||
'keepsSteamSessionRunning': True,
|
||||
'usbTransferAvailable': True,
|
||||
'steamInfoUpdateWithoutRebuild': True,
|
||||
'steamInfoAutomaticOnInstall': True,
|
||||
'steamDescriptionUsesNotes': True,
|
||||
'usbToolsBundled': usb_bundled,
|
||||
'passwordReprRedacted': True, 'networkConnections': 0}
|
||||
@@ -198,6 +198,118 @@ class AssetTests(unittest.TestCase):
|
||||
self.assertFalse(dest.exists())
|
||||
self.assertFalse(list(self.root.glob(".halo-assets-*")))
|
||||
|
||||
def test_cancelled_inspection_does_not_access_source_paths(self):
|
||||
event = threading.Event()
|
||||
event.set()
|
||||
with patch.object(assets, "_XboxImage") as image_reader, \
|
||||
patch.object(assets, "_maps_source") as maps_reader:
|
||||
for inspect in (assets.inspect_image, assets.inspect_maps):
|
||||
with self.subTest(inspect=inspect.__name__):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
inspect(self.root / "does-not-exist", cancel_event=event)
|
||||
image_reader.assert_not_called()
|
||||
maps_reader.assert_not_called()
|
||||
|
||||
def test_image_directory_inspection_cancels_and_closes_its_file(self):
|
||||
event = threading.Event()
|
||||
opened, inspected_names = [], []
|
||||
original_open, original_name = Path.open, assets._safe_name
|
||||
|
||||
def capture_open(path, *args, **kwargs):
|
||||
stream = original_open(path, *args, **kwargs)
|
||||
opened.append(stream)
|
||||
return stream
|
||||
|
||||
def cancel_after_first_name(name):
|
||||
original_name(name)
|
||||
inspected_names.append(name)
|
||||
event.set()
|
||||
|
||||
with patch.object(Path, "open", capture_open), \
|
||||
patch.object(assets, "_safe_name", cancel_after_first_name):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_image(self.image, cancel_event=event)
|
||||
self.assertEqual(inspected_names, ["maps"])
|
||||
self.assertEqual(len(opened), 1)
|
||||
self.assertTrue(opened[0].closed)
|
||||
|
||||
def test_image_header_inspection_cancels_including_after_last_header(self):
|
||||
original_header, original_open = assets._cache_header, Path.open
|
||||
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
|
||||
with self.subTest(cancel_after=cancel_after):
|
||||
event = threading.Event()
|
||||
opened, inspected = [], []
|
||||
|
||||
def capture_open(path, *args, **kwargs):
|
||||
stream = original_open(path, *args, **kwargs)
|
||||
opened.append(stream)
|
||||
return stream
|
||||
|
||||
def cancel_during_headers(header, name, size):
|
||||
value = original_header(header, name, size)
|
||||
inspected.append(name)
|
||||
if len(inspected) == cancel_after:
|
||||
event.set()
|
||||
return value
|
||||
|
||||
with patch.object(Path, "open", capture_open), \
|
||||
patch.object(assets, "_cache_header", cancel_during_headers):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_image(self.image, cancel_event=event)
|
||||
self.assertEqual(len(inspected), cancel_after)
|
||||
self.assertTrue(all(stream.closed for stream in opened))
|
||||
|
||||
def test_maps_inspection_cancels_during_directory_enumeration(self):
|
||||
maps = self.root / "synthetic-maps"
|
||||
maps.mkdir()
|
||||
for name in assets.REQUIRED_MAPS:
|
||||
(maps / name).write_bytes(cache_header())
|
||||
event = threading.Event()
|
||||
inspected_names = []
|
||||
original_name = assets._safe_name
|
||||
|
||||
def cancel_after_first_name(name):
|
||||
original_name(name)
|
||||
inspected_names.append(name)
|
||||
event.set()
|
||||
|
||||
with patch.object(assets, "_safe_name", cancel_after_first_name), \
|
||||
patch.object(assets, "_cache_header") as header_reader:
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
assets.inspect_maps(maps, cancel_event=event)
|
||||
header_reader.assert_not_called()
|
||||
self.assertEqual(len(inspected_names), 1)
|
||||
|
||||
def test_maps_inspection_and_copy_cancel_during_source_header_validation(self):
|
||||
maps = self.root / "synthetic-maps"
|
||||
maps.mkdir()
|
||||
for name in assets.REQUIRED_MAPS:
|
||||
(maps / name).write_bytes(cache_header())
|
||||
original_header = assets._cache_header
|
||||
for operation in ("inspect", "copy"):
|
||||
for cancel_after in (1, len(assets.REQUIRED_MAPS)):
|
||||
with self.subTest(operation=operation, cancel_after=cancel_after):
|
||||
event = threading.Event()
|
||||
inspected = []
|
||||
|
||||
def cancel_during_headers(header, name, size):
|
||||
value = original_header(header, name, size)
|
||||
inspected.append(name)
|
||||
if len(inspected) == cancel_after:
|
||||
event.set()
|
||||
return value
|
||||
|
||||
dest = self.root / "not-published"
|
||||
with patch.object(assets, "_cache_header", cancel_during_headers):
|
||||
with self.assertRaises(assets.ExtractionCancelled):
|
||||
if operation == "inspect":
|
||||
assets.inspect_maps(maps, cancel_event=event)
|
||||
else:
|
||||
assets.copy_maps(maps, dest, cancel_event=event)
|
||||
self.assertEqual(len(inspected), cancel_after)
|
||||
self.assertFalse(dest.exists())
|
||||
self.assertFalse(list(self.root.glob(".halo-assets-*")))
|
||||
|
||||
def test_write_error_removes_owned_staging_and_does_not_publish(self):
|
||||
dest = self.root / "failed"
|
||||
with patch.object(assets.os, "fsync", side_effect=OSError("synthetic disk full")):
|
||||
|
||||
+247
-32
@@ -149,7 +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['steamInfoAutomaticOnInstall'] and result['steamDescriptionUsesNotes']
|
||||
assert result['networkConnections'] == 0
|
||||
|
||||
|
||||
@@ -883,7 +883,7 @@ def test_revision_guidance_names_validated_rev_2_and_checked_cache_builds(app):
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair', 'library', 'uninstall'])
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair', '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)
|
||||
@@ -940,37 +940,30 @@ def test_manual_library_fallback_uses_steam_ui_without_teardown_instructions(app
|
||||
|
||||
|
||||
@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()
|
||||
@pytest.mark.parametrize('mode', ['install', 'repair'])
|
||||
def test_install_and_repair_describe_automatic_steam_info_without_separate_choice(app, mode):
|
||||
import tkinter as tk
|
||||
app.mode.set(mode)
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
||||
assert not hasattr(app, 'library_card')
|
||||
assert 'Steam artwork and game Notes are added automatically' in app.summary_note.get()
|
||||
assert app.data_panel.winfo_manager() and app.data_consent.winfo_manager()
|
||||
app._show_page(2)
|
||||
assert app.primary_button.cget('text') == ('Install Halo VR' if mode == 'install' else 'Repair Halo VR')
|
||||
app._show_help()
|
||||
dialogs = [widget for widget in app.winfo_children() if isinstance(widget, tk.Toplevel)]
|
||||
try:
|
||||
text = '\n'.join(_display_text(dialogs[0]))
|
||||
assert 'Install and repair automatically add Steam artwork and a game description in Steam Notes' in text
|
||||
assert 'Steam info updates' not in text
|
||||
finally:
|
||||
for dialog in dialogs:
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('transport', ['network', 'usb'])
|
||||
def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
|
||||
def test_pending_steam_retry_does_not_read_game_data_or_build_and_clears_credentials(app, monkeypatch, transport):
|
||||
from halo_frame_installer import gui, install
|
||||
calls = []
|
||||
|
||||
@@ -990,12 +983,15 @@ def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentia
|
||||
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.last_result = install.InstallResult('/home/steamos/Games/HaloCENativeVR', False,
|
||||
{'status': 'added', 'notes': {'status': 'manual', 'title': 'About Halo', 'content': 'Campaign.'}}, None)
|
||||
app._show_page(2)
|
||||
app._continue()
|
||||
app._set_busy(False)
|
||||
assert app.retry_button.winfo_manager() and not _disabled(app.retry_button)
|
||||
app.retry_button.invoke()
|
||||
_drain_events(app)
|
||||
assert len(calls) == 1
|
||||
settings, password = calls[0]
|
||||
@@ -1007,13 +1003,13 @@ def test_steam_info_worker_does_not_read_game_data_or_build_and_clears_credentia
|
||||
assert 'native game and saves were kept' in app.status.get()
|
||||
assert 'installed.' not in app.status.get()
|
||||
assert app._test_dialogs[-1][0] == 'Steam info updated'
|
||||
assert not app.retry_button.winfo_manager()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('pending', ['icon', 'notes', 'artwork'])
|
||||
def test_steam_info_partial_results_stay_actionable_without_claiming_complete_update(app, pending):
|
||||
from halo_frame_installer.install import InstallResult
|
||||
app.mode.set('library')
|
||||
app.operation = 'library'
|
||||
app.password_to_redact = 'offline-secret'
|
||||
steam = {'status': 'added', 'artwork': {'status': 'added', 'icon': {'status': 'added'}},
|
||||
@@ -1100,10 +1096,229 @@ def test_all_operation_tile_captions_fit_minimum_width(app):
|
||||
app.deiconify()
|
||||
app._show_page(0)
|
||||
app.update()
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'library', 'uninstall'}
|
||||
assert {choice.value for choice in app.mode_choices} == {'install', 'repair', 'uninstall'}
|
||||
for choice in app.mode_choices:
|
||||
for item in choice.find_all():
|
||||
if choice.type(item) == 'text':
|
||||
left, top, right, bottom = choice.bbox(item)
|
||||
assert 0 <= left < right <= choice.winfo_width()
|
||||
assert 0 <= top < bottom <= choice.winfo_height()
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_iso_inspection_runs_off_tk_thread_and_window_remains_responsive(app, monkeypatch, tmp_path):
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from halo_frame_installer import gui
|
||||
real_thread = threading.Thread
|
||||
started, release = threading.Event(), threading.Event()
|
||||
workers, inspected_threads, music_loads = [], [], []
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
inspected_threads.append(threading.get_ident())
|
||||
assert path == tmp_path / 'Halo.iso'
|
||||
assert not cancel_event.is_set()
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
return SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276')
|
||||
|
||||
def schedule(**kwargs):
|
||||
thread = real_thread(**kwargs)
|
||||
workers.append(thread)
|
||||
return thread
|
||||
|
||||
monkeypatch.setattr(gui.threading, 'Thread', schedule)
|
||||
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
||||
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
||||
try:
|
||||
app._inspect(tmp_path / 'Halo.iso')
|
||||
assert started.wait(2)
|
||||
assert app.busy and not app.source.get()
|
||||
assert inspected_threads == [workers[0].ident]
|
||||
assert workers[0].ident != threading.get_ident()
|
||||
heartbeat = []
|
||||
app.after(0, lambda: heartbeat.append(True))
|
||||
app.update()
|
||||
assert heartbeat == [True] # Tk processed a timer while validation was blocked.
|
||||
assert music_loads == [None] # The old preview stops before reading the new ISO.
|
||||
finally:
|
||||
release.set()
|
||||
for worker in workers:
|
||||
worker.join(3)
|
||||
_drain_events(app)
|
||||
assert not app.busy
|
||||
assert app.source.get() == str(tmp_path / 'Halo.iso')
|
||||
assert 'Validated 24 original Xbox maps' in app.asset_status.get()
|
||||
assert music_loads == [None, tmp_path / 'Halo.iso']
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued_inspection(app, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from halo_frame_installer import gui
|
||||
workers, music_loads = [], []
|
||||
|
||||
class DeferredThread:
|
||||
def __init__(self, target, **kwargs):
|
||||
self.target = target
|
||||
workers.append(self)
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(gui.threading, 'Thread', DeferredThread)
|
||||
monkeypatch.setattr(app.music, 'load', music_loads.append)
|
||||
return SimpleNamespace(workers=workers, music_loads=music_loads,
|
||||
info=SimpleNamespace(map_count=24, estimated_bytes=1_000_000_000, build='01.10.12.2276'))
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('old_error', [False, True])
|
||||
@pytest.mark.parametrize('old_finishes_first', [False, True])
|
||||
def test_late_inspection_results_do_not_replace_new_selection_or_clear_its_busy_state(app, monkeypatch, tmp_path, queued_inspection, old_error, old_finishes_first):
|
||||
from halo_frame_installer import gui
|
||||
old, new = tmp_path / 'old.iso', tmp_path / 'new.iso'
|
||||
cancellations = {}
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
cancellations[path] = cancel_event
|
||||
if path == old and old_error:
|
||||
raise ValueError('The old image was invalid.')
|
||||
return queued_inspection.info
|
||||
|
||||
monkeypatch.setattr(gui, 'inspect_image', inspect)
|
||||
app._inspect(old)
|
||||
old_cancel = app._inspection_cancel
|
||||
app._inspect(new)
|
||||
assert old_cancel.is_set()
|
||||
if old_finishes_first:
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert app.busy and not app.source.get()
|
||||
assert app.asset_status.get() == 'Checking original Xbox game data…'
|
||||
queued_inspection.workers[1].target()
|
||||
_drain_events(app)
|
||||
if not old_finishes_first:
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert app.source.get() == str(new)
|
||||
assert app.status.get() == 'Game data validated. Connect your Frame next.'
|
||||
assert not app.busy
|
||||
assert cancellations[old].is_set() and not cancellations[new].is_set()
|
||||
assert queued_inspection.music_loads == [None, None, new]
|
||||
assert not app._test_dialogs
|
||||
assert 'old image' not in '\n'.join(app.log_lines)
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_clearing_source_cancels_inspection_and_ignores_its_late_success(app, monkeypatch, tmp_path, queued_inspection):
|
||||
from halo_frame_installer import gui
|
||||
monkeypatch.setattr(gui, 'inspect_image', lambda path, **kwargs: queued_inspection.info)
|
||||
app._inspect(tmp_path / 'Halo.iso')
|
||||
cancel = app._inspection_cancel
|
||||
app.source.set('')
|
||||
assert cancel.is_set() and not app.busy
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert not app.source.get()
|
||||
assert app.asset_status.get() == gui.XBOX_REVISION_NOTE
|
||||
assert queued_inspection.music_loads == [None, None]
|
||||
assert not app._test_dialogs
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('folder', [False, True])
|
||||
def test_cancelled_source_inspection_keeps_previous_selection_and_does_not_start_music(app, monkeypatch, tmp_path, queued_inspection, folder):
|
||||
from halo_frame_installer import gui
|
||||
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
||||
if folder:
|
||||
selected.mkdir()
|
||||
calls = []
|
||||
|
||||
def inspect(path, *, cancel_event):
|
||||
calls.append((path, cancel_event.is_set()))
|
||||
# Even a reader finishing after cancellation cannot accept its result.
|
||||
return queued_inspection.info
|
||||
|
||||
monkeypatch.setattr(gui, 'inspect_maps' if folder else 'inspect_image', inspect)
|
||||
app.source.set('previous.iso')
|
||||
app._inspect(selected)
|
||||
app._cancel_setup()
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert calls == [(selected, True)]
|
||||
assert app.source.get() == 'previous.iso'
|
||||
assert not app.busy
|
||||
assert app.phase.get() == 'Game data check cancelled'
|
||||
assert 'previous selection was kept' in app.status.get()
|
||||
assert queued_inspection.music_loads == [None]
|
||||
assert not app._test_dialogs
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
def test_current_inspection_failure_preserves_selection_and_readable_redacted_error(app, monkeypatch, tmp_path, queued_inspection):
|
||||
from halo_frame_installer import gui
|
||||
def invalid(path, *, cancel_event):
|
||||
raise ValueError('Invalid original Xbox image. offline-secret\nHeader validation failed.')
|
||||
monkeypatch.setattr(gui, 'inspect_image', invalid)
|
||||
app.source.set('previous.iso')
|
||||
app.password_to_redact = 'offline-secret'
|
||||
app._inspect(tmp_path / 'invalid.iso')
|
||||
queued_inspection.workers[0].target()
|
||||
_drain_events(app)
|
||||
assert not app.busy
|
||||
assert app.source.get() == 'previous.iso'
|
||||
assert "Couldn't validate this data" in app.asset_status.get()
|
||||
assert len(app._test_dialogs) == 1
|
||||
assert 'Header validation failed.' in '\n'.join(app.log_lines)
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
assert 'offline-secret' not in app._test_dialogs[0][1]
|
||||
assert queued_inspection.music_loads == [None]
|
||||
|
||||
|
||||
@WINDOWS_GUI
|
||||
@pytest.mark.parametrize('folder', [False, True])
|
||||
def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_credentials(app, monkeypatch, tmp_path, folder):
|
||||
from halo_frame_installer import gui, install
|
||||
selected = tmp_path / ('maps' if folder else 'Halo.iso')
|
||||
if folder:
|
||||
selected.mkdir()
|
||||
name = 'inspect_maps' if folder else 'inspect_image'
|
||||
inspect_source = getattr(gui, name)
|
||||
inspections, settings_used = [], []
|
||||
get_settings = app._settings
|
||||
|
||||
def remember_settings():
|
||||
settings = get_settings()
|
||||
settings_used.append(settings)
|
||||
return settings
|
||||
|
||||
def cancel_validation(path, *, cancel_event):
|
||||
assert cancel_event is app.cancel
|
||||
inspections.append(path)
|
||||
# Model Cancel arriving as the worker starts its second validation.
|
||||
cancel_event.set()
|
||||
return inspect_source(path, cancel_event=cancel_event)
|
||||
|
||||
monkeypatch.setattr(app, '_settings', remember_settings)
|
||||
monkeypatch.setattr(gui, name, cancel_validation)
|
||||
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
|
||||
for action in ('extract_image', 'copy_maps'):
|
||||
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
|
||||
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Cancelled validation began installation.'))
|
||||
app.source.set(str(selected))
|
||||
app.authorized.set(True)
|
||||
app.steam_closed.set(True)
|
||||
app.password.set('offline-secret')
|
||||
app._install()
|
||||
_drain_events(app)
|
||||
assert inspections == [selected]
|
||||
assert app.cancel.is_set()
|
||||
assert not app.busy and not app.installing
|
||||
assert not app.password.get() and not settings_used[0].password
|
||||
assert not app.password_to_redact
|
||||
assert app.source.get() == str(selected)
|
||||
assert 'cancelled' in '\n'.join(app.log_lines).lower()
|
||||
assert 'offline-secret' not in '\n'.join(app.log_lines)
|
||||
assert float(app.progress['value']) == 0
|
||||
assert not _disabled(app.primary_button)
|
||||
@@ -173,6 +173,60 @@ def test_setup_reports_success_only_after_ssh_disconnects():
|
||||
assert seen[-3:] == ['disconnect', 'disconnected', 'complete']
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["fresh", "repair"])
|
||||
def test_normal_install_and_repair_automatically_publish_steam_information(tmp_path, mode):
|
||||
steam_info = {"status": "added", "appid": 3732925724, "libraryAdded": True,
|
||||
"artwork": {"status": "added", "installed": ["cover", "header", "hero", "logo"],
|
||||
"icon": {"status": "added"}},
|
||||
"notes": {"ok": True, "status": "added", "title": "About and controls"}}
|
||||
|
||||
class SuccessfulConnection(FakeConnection):
|
||||
def __init__(self, settings):
|
||||
super().__init__(settings, existing=mode == "repair")
|
||||
self.events = []
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
self.events.append(("upload", Path(local).name))
|
||||
super().put(local, remote, **kwargs)
|
||||
|
||||
def run(self, argv, **kwargs):
|
||||
if argv[1] != "-c" and argv[2] in ("build", "finalize", "shortcut"):
|
||||
self.commands.append(argv)
|
||||
self.events.append(("step", argv[2]))
|
||||
responses = {"build": {"built": True},
|
||||
"finalize": {"gamePath": "/home/steamos/Games/HaloCENativeVR",
|
||||
"reused": False, "repaired": mode == "repair"},
|
||||
"shortcut": steam_info}
|
||||
return "HFI_RESULT " + json.dumps(responses[argv[2]])
|
||||
return super().run(argv, **kwargs)
|
||||
|
||||
settings = Settings("frame", "private", reinstall_existing=mode == "repair",
|
||||
close_steam_for_shortcut=True)
|
||||
fake = SuccessfulConnection(settings)
|
||||
maps = None
|
||||
if mode == "fresh":
|
||||
maps = tmp_path / "maps"
|
||||
maps.mkdir()
|
||||
for name in EXPECTED_MAPS:
|
||||
(maps / name).write_bytes(name.encode())
|
||||
result = Installer(lambda _: fake, RESOURCES).run(settings, maps)
|
||||
|
||||
steps = [argv[2] for argv in fake.commands if argv[1] != "-c"]
|
||||
assert steps == ["prepare", "build", "finalize", "shortcut"]
|
||||
assert fake.commands[0][3] == "preflight"
|
||||
assert steps.count("shortcut") == 1
|
||||
assert fake.events.index(("step", "finalize")) < fake.events.index(("step", "shortcut"))
|
||||
for filename in ("steam_notes.py", "halo-ce-cover.jpg", "halo-ce-landscape.png",
|
||||
"halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
|
||||
assert fake.events.index(("upload", filename)) < fake.events.index(("step", "build"))
|
||||
assert result.steam == steam_info
|
||||
assert result.repaired is (mode == "repair")
|
||||
assert fake.closed
|
||||
assert not any("--close-steam" in argv for argv in fake.commands)
|
||||
uploaded_maps = [remote for _, remote in fake.uploads if remote.endswith(".map")]
|
||||
assert len(uploaded_maps) == (len(EXPECTED_MAPS) if mode == "fresh" else 0)
|
||||
|
||||
|
||||
def test_default_existing_install_rebuilds_without_uploading_maps():
|
||||
fake = FakeConnection(Settings("frame", "private"))
|
||||
with pytest.raises(SSHError, match="build failed"):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Synthetic cache/audio tests; no Halo recordings or game data are fixtures."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import io
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
@@ -89,6 +91,43 @@ def test_stereo_channel_headers_and_interleaving_remain_distinct():
|
||||
assert samples[1::2] == (-120,) * 128
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channels,expected", [
|
||||
(1, "08af960a705a06d131940dc54952955876058e449f7f37f92727a9ddf37188df"),
|
||||
(2, "62a98442266869a1c78911817c0ab5f4685fc01d5ad42649f60151675baf9d83"),
|
||||
])
|
||||
def test_batched_decoder_preserves_pcm_for_every_predictor_index(channels, expected):
|
||||
# Golden hashes from the original decoder: varied nibbles, all valid index
|
||||
# states, both clipping limits, and mono/stereo sample ordering.
|
||||
randomizer = random.Random(516)
|
||||
blocks = []
|
||||
for index in range(89):
|
||||
headers = b"".join(struct.pack("<hBB", -32768 if channel % 2 else 32767, index, 0)
|
||||
for channel in range(channels))
|
||||
blocks.append(headers + bytes(randomizer.randrange(256) for _ in range(32*channels)))
|
||||
output = music.decode_adpcm(b"".join(blocks), channels)
|
||||
assert hashlib.sha256(output).hexdigest() == expected
|
||||
|
||||
|
||||
def test_long_decode_yields_regularly_and_checks_cancellation_after_yield(monkeypatch):
|
||||
block = struct.pack("<hBB", 1000, 0, 0) + bytes(32)
|
||||
batches = 3
|
||||
pauses = []
|
||||
monkeypatch.setattr(music.time, "sleep", pauses.append)
|
||||
output = music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS*batches+1), 1)
|
||||
assert len(output) == (music.DECODE_BATCH_BLOCKS*batches+1)*128
|
||||
assert pauses == [music.DECODE_YIELD_SECONDS]*batches
|
||||
|
||||
cancel = threading.Event()
|
||||
|
||||
def cancel_while_yielded(seconds):
|
||||
assert seconds == music.DECODE_YIELD_SECONDS
|
||||
cancel.set()
|
||||
|
||||
monkeypatch.setattr(music.time, "sleep", cancel_while_yielded)
|
||||
with pytest.raises(music.MusicError, match="cancelled"):
|
||||
music.decode_adpcm(block*(music.DECODE_BATCH_BLOCKS+1), 1, cancel)
|
||||
|
||||
|
||||
def test_quiet_gain_is_bounded_and_zero_gain_is_silent():
|
||||
block = struct.pack("<hBB", 32767, 88, 0) + b"\x77" * 32
|
||||
samples = struct.unpack("<64h", music.decode_adpcm(block, 1))
|
||||
|
||||
Reference in new issue
Block a user