Compare commits

...
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 d4c1fbe9a7 Release: Steam Frame installer, release signing, Frame import kept
- A Steam Frame installer for players, opened in Desktop Mode
  (Install-Halo-VR.desktop, a release asset): it downloads the latest
  release's bundle (tools/steam_frame_bundle.py) and runs
  steam_frame_install.sh, with zenity dialogs and no keyboard: the APK and
  launcher in ~/Documents/HaloCE, the maps copied out of a disc image it
  finds or is given (steam_frame_xiso.py, the app's XisoExtractor in
  Python), and Halo VR in the Steam library with its pictures and OpenVR
  set. Running it again updates the game.
- The release job adds the bundle and the desktop file to each release.
- build.gradle signs with port/android/signing.properties (ignored) when
  the workflow's key is not given, so local builds and releases install over
  each other.
- On the Frame the launcher and host take ~/Documents/HaloCE whenever it can
  be written, not only once it holds maps: a disc image imported in the app
  is no longer emptied with Lepton's folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:06:21 +02:00
12 changed files with 647 additions and 17 deletions

No files matched your search

+11 -1
View File
@@ -237,6 +237,13 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
port/android
port/third_party/extract-xiso
tools
- uses: actions/download-artifact@v4
with:
pattern: halo-*
@@ -249,6 +256,9 @@ jobs:
name="$(basename "$build")"
(cd "$build" && zip -qr "$GITHUB_WORKSPACE/release/$name.zip" .)
done
# the Steam Frame's installer (port/android/STEAM_FRAME.md): its
# bundle with the release APK, and the desktop file that fetches it
python3 tools/steam_frame_bundle.py artifacts/halo-android-release/app-release.apk release "$GH_REPO"
ls -l release
- name: Publish
@@ -262,7 +272,7 @@ jobs:
subject="$(printf '%s' "${MESSAGE:-manual run}" | head -n 1)"
printf '%s\n\nCommit: %s\nBuild: %s\n\nThe release builds are for playing; the debug builds stop at the first failed assertion and log it.\n' \
"$subject" "$COMMIT_URL" "$RUN_URL" > notes.md
gh release create "$tag" release/*.zip --target "$GITHUB_SHA" --latest \
gh release create "$tag" release/* --target "$GITHUB_SHA" --latest \
--title "Build $GITHUB_RUN_NUMBER (${GITHUB_SHA:0:7})" --notes-file notes.md
echo "tag=$tag" >> "$GITHUB_OUTPUT"
+5
View File
@@ -30,3 +30,8 @@ original/
# CI build outputs (tools/ci_build.py)
dist/
# The app signing key's local settings and keys (port/android/app/build.gradle; never commit)
port/android/signing.properties
*.jks
*.keystore
+9 -1
View File
@@ -155,7 +155,15 @@ select "Yes":
To install over the previous version, each build must have the same
signature. GitHub Actions signs each build with the key in the
`ANDROID_KEYSTORE_BASE64` and `ANDROID_KEYSTORE_PASSWORD` secrets of the
repository. If you installed a build that has a different signature, remove
repository. A computer that builds for its own devices signs with the same
key when `port/android/signing.properties` (not in the repository) names it:
storeFile=/path/to/halo-vr-release.jks
password=<the keystore's password>
keyAlias=halo
Keep the keystore and its password safe: a lost key means every player must
remove the app before the next version. If you installed a build that has a different signature, remove
that build before you install a new build. Removing the app deletes its data
folder: first make a copy of `maps/` and `save/`.
+34 -3
View File
@@ -4,6 +4,35 @@ The same APK as for the Quest runs on the Steam Frame, in Lepton (SteamOS's
Android, a Waydroid fork: Android 11, arm64), with SteamVR's OpenXR runtime.
What differs is found at run time, not by a build of its own.
## Installing it (players)
On the Frame, in Desktop Mode (Steam menu > Power > Switch to Desktop):
1. Put a disc image of Halo: Combat Evolved for the Xbox (`.iso` or
`.xiso`, any version) in Downloads (or Documents, the Desktop, a memory
card).
2. Download `Install-Halo-VR.desktop` from the latest release and open it
(double-click; confirm running it).
3. Answer the dialogs: the installer finds the disc image (or asks for it),
copies the game data out of it, adds "Halo VR" to the Steam library and
switches back to Gaming Mode. Start Halo VR from the library there.
Opening the same file again updates the game (settings, saves and game data
stay).
The desktop file (`steam_frame_installer.desktop`) downloads the latest
release's `halo-vr-steam-frame.zip` (`tools/steam_frame_bundle.py`, made by
the release job of `.github/workflows/build.yml`) and runs its
`install.sh` (`steam_frame_install.sh`), with zenity dialogs:
- `~/Documents/HaloCE`: the launcher (`halo-vr.sh`) and the release APK;
- the maps folder out of the disc image (`steam_frame_xiso.py`, the app's
`XisoExtractor.java` in Python), unless `maps/ui.map` is there;
- the Steam shortcut (`shortcuts.vdf` as below, with `OpenVR` set, and the
library pictures), unless it is there: Steam is closed for that, as it
writes its shortcuts back when it quits.
`HALO_VR_UNATTENDED=1` (and `HALO_VR_IMAGE=<image>`) runs it without
dialogs, for testing over adb; it leaves Steam and its shortcuts alone.
## The device
- Steam Settings > System > Enable Developer Mode; then start "Lepton
@@ -75,9 +104,11 @@ beside Lepton's `<frame-ip>:5555`.
Lepton uninstalls the app and empties its folders whenever it starts again.
The game's data lives in the SteamOS user's Documents instead: when
`/home/steamos/Documents/HaloCE` holds `maps/ui.map` and is writable, the
launcher and the game take it as their data folder (config.toml, saves and
debug.txt go there too). Once:
`/home/steamos/Documents` can be written, the launcher makes
`/home/steamos/Documents/HaloCE` and the launcher and the game take it as
their data folder (config.toml, saves and debug.txt go there too), so a
disc image imported in the app stays as well. The installer above fills it;
by hand, once:
adb -s <frame-ip>:5555 shell mkdir -p /home/steamos/Documents/HaloCE/maps
adb -s <frame-ip>:5555 push assets/maps/. /home/steamos/Documents/HaloCE/maps/
+1 -1
View File
@@ -39,7 +39,7 @@ named where code is taken over.
| 3b | The whole body: torso from head and hands, legs and feet following in the room; full / arms only / hands only | |
| 3c | Weapons held physically, holsters at shoulder and hip (the buttons stay) | |
| 3d | Other VR players shown with their heads, hands and bodies (today their bodies move as flat players' do: only their aim and throws come from their hands) | |
| 4 | Later: disc image import in the launcher, sprint by arm swing (offline) | |
| 4 | Later: disc image import in the launcher, sprint by arm swing (offline) | import **built** (branch `feature/release-install`): the app's import (Quest; on the Frame now kept in `~/Documents/HaloCE`), a Steam Frame installer from Desktop Mode (`STEAM_FRAME.md`), a release signing key; test pending |
## Step 2, first part (done)
+17 -4
View File
@@ -13,9 +13,22 @@ def buildNumberText = System.getenv("HALO_BUILD_NUMBER") ?: "0"
def buildNumber = buildNumberText.isInteger() ? buildNumberText.toInteger() : 0
// the workflow's signing key (.github/workflows/build.yml): every build of
// main signed with it can install over the one before; without it, the
// main signed with it can install over the one before; on a computer that
// builds for its own devices, the same key from port/android/signing.properties
// (not in the repository: storeFile, password, keyAlias), so that its
// builds and the releases install over each other; without either, the
// debug key of the computer that builds
def signingKey = System.getenv("HALO_ANDROID_KEYSTORE")
def signingPassword = System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
def signingAlias = System.getenv("HALO_ANDROID_KEY_ALIAS") ?: "halo"
def signingProperties = new File(rootDir, "signing.properties")
if (!signingKey && signingProperties.isFile()) {
def properties = new Properties()
signingProperties.withInputStream { properties.load(it) }
signingKey = properties.getProperty("storeFile")
signingPassword = properties.getProperty("password")
signingAlias = properties.getProperty("keyAlias", "halo")
}
android {
namespace "com.halo.decomp"
@@ -60,9 +73,9 @@ android {
if (signingKey) {
halo {
storeFile file(signingKey)
storePassword System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("HALO_ANDROID_KEY_ALIAS") ?: "halo"
keyPassword System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
storePassword signingPassword
keyAlias signingAlias
keyPassword signingPassword
}
}
}
@@ -49,10 +49,15 @@ public class LauncherActivity extends Activity {
dataRoot = getExternalFilesDir(null);
// the Steam Frame: Lepton empties the app's own folders whenever it
// starts again; the game's data lives in the SteamOS user's
// Documents instead, when it is there (port/android/STEAM_FRAME.md;
// the same choice as host_main.c's)
if (new File(SHARED_DATA_ROOT, "maps/ui.map").isFile() && new File(SHARED_DATA_ROOT).canWrite())
dataRoot = new File(SHARED_DATA_ROOT);
// Documents instead, whenever they can be written, so that a disc
// image imported here stays too (port/android/STEAM_FRAME.md; the
// same choice as host_main.c's)
File shared = new File(SHARED_DATA_ROOT);
if (shared.getParentFile().canWrite() || shared.canWrite()) {
shared.mkdirs();
if (shared.canWrite())
dataRoot = shared;
}
// created by the app, so that files pushed into it with adb stay
// readable (a directory adb creates there belongs to the shell user)
if (dataRoot != null)
+3 -3
View File
@@ -273,9 +273,9 @@ static void *game_main(void *unused)
host_fatal("Android storage is unavailable: %s", SDL_GetError());
/* the Steam Frame: Lepton empties the app's own folders whenever it
starts again; the game's data lives in the SteamOS user's Documents
instead, when it is there (port/android/STEAM_FRAME.md; the same choice
as LauncherActivity's) */
if (directory_has_maps(SHARED_DATA_ROOT) && access(SHARED_DATA_ROOT, W_OK) == 0)
instead, whenever they can be written (port/android/STEAM_FRAME.md; the
same choice as LauncherActivity's, which makes the folder) */
if (access(SHARED_DATA_ROOT, W_OK) == 0)
external = SHARED_DATA_ROOT;
snprintf(data_root, sizeof(data_root), "%s", external);
snprintf(save_root, sizeof(save_root), "%s/save", external);
+266
View File
@@ -0,0 +1,266 @@
#!/bin/bash
# Halo VR's installer for the Steam Frame, run in its Desktop Mode from the
# bundle the release's "Install Halo VR" desktop file downloads
# (steam_frame_installer.desktop; the bundle: this script, HaloVR.apk,
# halo-vr.sh = steam_frame_launch.sh, steam_frame_xiso.py and the library
# pictures in grid/). Everything is answered with dialogs, no keyboard:
# - the game in ~/Documents/HaloCE (port/android/STEAM_FRAME.md), its APK
# replaced (Lepton installs a newer one at the next start), so running the
# installer again updates the game;
# - the game data: the maps folder copied out of a disc image of Halo
# (.iso or .xiso) found in Downloads, Documents, the Desktop or on a
# memory card, or chosen, unless it is there already;
# - "Halo VR" in the Steam library, with its pictures, unless it is there
# already: Steam is closed for that (it keeps its shortcuts in memory and
# writes them back when it quits), and started again in Gaming Mode.
# HALO_VR_UNATTENDED=1 answers every question with its default and prints
# instead of showing dialogs (HALO_VR_IMAGE: the disc image to use), for
# testing over adb; it never closes Steam.
BUNDLE="$(cd "$(dirname "$0")" && pwd)"
HALO_DIR="$HOME/Documents/HaloCE"
STEAM_DIR="$HOME/.local/share/Steam"
LEPTON="$STEAM_DIR/steamapps/common/Lepton/lepton"
TITLE="Halo VR"
NAME="Halo VR"
EXE="\"$HALO_DIR/halo-vr.sh\""
unattended() { [ "${HALO_VR_UNATTENDED:-0}" = 1 ] || ! command -v zenity >/dev/null; }
info()
{
if unattended; then printf "info: %b\n" "$1"; else zenity --info --title="$TITLE" --width=460 --text="$1"; fi
}
fail()
{
if unattended; then printf "error: %b\n" "$1" >&2; else zenity --error --title="$TITLE" --width=460 --text="$1"; fi
exit 1
}
# yes/no; unattended: the default given ($2, "yes" or "no")
ask()
{
if unattended; then [ "$2" = yes ]; return; fi
zenity --question --title="$TITLE" --width=460 --text="$1"
}
# the disc image the player chooses
choose_image()
{
if unattended; then return 1; fi
zenity --file-selection --title="Choose a disc image of Halo: Combat Evolved (.iso or .xiso)" \
--filename="$HOME/Downloads/" --file-filter="Disc images | *.iso *.ISO *.xiso *.XISO" \
--file-filter="All files | *"
}
# the disc images near at hand, largest first (a Halo image is a few GB;
# anything under 500 MB is not one)
find_images()
{
find "$HOME/Downloads" "$HOME/Documents" "$HOME/Desktop" /run/media -maxdepth 3 -type f \
\( -iname '*.iso' -o -iname '*.xiso' \) -size +500M -printf '%s\t%p\n' 2>/dev/null |
sort -rn | cut -f2-
}
extract_maps()
{
local image="$1" log
log="$(mktemp)"
if unattended; then
python3 "$BUNDLE/steam_frame_xiso.py" "$image" "$HALO_DIR" 2>"$log" | grep '^#' | tail -n 1
status=${PIPESTATUS[0]}
else
python3 "$BUNDLE/steam_frame_xiso.py" "$image" "$HALO_DIR" 2>"$log" |
zenity --progress --title="$TITLE" --width=460 --auto-close --no-cancel \
--text="Copying the game data out of the disc image..."
status=${PIPESTATUS[0]}
fi
if [ "$status" != 0 ]; then
reason="$(sed -n 's/^error: //p' "$log" | head -n 1)"
rm -f "$log"
fail "The game data could not be copied out of\n$image\n\n${reason:-Unknown error.}"
fi
rm -f "$log"
}
# the shortcut's id as Steam makes it for a non-Steam game: its pictures in
# config/grid are named after it
shortcut_id()
{
python3 -c 'import sys, zlib; print(zlib.crc32((sys.argv[1] + sys.argv[2]).encode()) | 0x80000000)' "$EXE" "$NAME"
}
# whether every Steam user's shortcuts.vdf already holds the game
have_shortcut()
{
local found=1 config
for config in "$STEAM_DIR"/userdata/*/config; do
[ -d "$config" ] || continue
case "$config" in */userdata/0/*) continue ;; esac
grep -qaF "$HALO_DIR/halo-vr.sh" "$config/shortcuts.vdf" 2>/dev/null || return 1
found=0
done
return $found
}
# "Halo VR" added to every Steam user's shortcuts.vdf (Steam closed), as a VR
# title, with its pictures
add_shortcut()
{
local id config
id="$(shortcut_id)"
for config in "$STEAM_DIR"/userdata/*/config; do
[ -d "$config" ] || continue
case "$config" in */userdata/0/*) continue ;; esac
python3 - "$config/shortcuts.vdf" "$id" "$NAME" "$EXE" "\"$HALO_DIR/\"" <<'PYTHON' || return 1
import os, struct, sys
path, appid, name, exe, start = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4], sys.argv[5]
# Steam's binary VDF: 0x00 a map, 0x01 a string, 0x02 an int32, 0x08 a map's end
def read_map(data, at):
items = []
while True:
kind = data[at]; at += 1
if kind == 0x08:
return items, at
end = data.index(b"\0", at); key = data[at:end]; at = end + 1
if kind == 0x00:
value, at = read_map(data, at)
elif kind == 0x01:
end = data.index(b"\0", at); value = data[at:end]; at = end + 1
elif kind == 0x02:
value = struct.unpack_from("<I", data, at)[0]; at += 4
else:
raise ValueError("unknown VDF type %d" % kind)
items.append((kind, key, value))
def write_map(items):
out = b""
for kind, key, value in items:
out += bytes([kind]) + key + b"\0"
if kind == 0x00:
out += write_map(value)
elif kind == 0x01:
out += value + b"\0"
else:
out += struct.pack("<I", value)
return out + b"\x08"
shortcuts = []
if os.path.exists(path):
with open(path, "rb") as file:
data = file.read()
if data:
top, _ = read_map(data, 0)
for kind, key, value in top:
if key.lower() == b"shortcuts" and kind == 0x00:
shortcuts = [entry for entry in value if entry[0] == 0x00]
# (an older entry of the game's replaced)
shortcuts = [entry for entry in shortcuts
if not any(k.lower() == b"exe" and v == exe.encode() for _, k, v in entry[2])]
def text(key, value):
return (0x01, key.encode(), value.encode())
def number(key, value):
return (0x02, key.encode(), value)
entry = [
number("appid", appid), text("AppName", name), text("Exe", exe), text("StartDir", start),
text("icon", ""), text("ShortcutPath", ""), text("LaunchOptions", ""), number("IsHidden", 0),
number("AllowDesktopConfig", 1), number("AllowOverlay", 1), number("OpenVR", 1), number("Devkit", 0),
text("DevkitGameID", ""), number("DevkitOverrideAppID", 0), number("LastPlayTime", 0),
text("FlatpakAppID", ""), text("sortas", ""), (0x00, b"tags", []),
]
shortcuts.append((0x00, b"", entry))
shortcuts = [(0x00, str(index).encode(), value) for index, (_, _, value) in enumerate(shortcuts)]
data = write_map([(0x00, b"shortcuts", shortcuts)])
with open(path + ".tmp", "wb") as file:
file.write(data)
os.replace(path + ".tmp", path)
PYTHON
mkdir -p "$config/grid"
cp "$BUNDLE/grid/library.png" "$config/grid/${id}.png"
cp "$BUNDLE/grid/libraryp.png" "$config/grid/${id}p.png"
cp "$BUNDLE/grid/library_hero.png" "$config/grid/${id}_hero.png"
cp "$BUNDLE/grid/library_logo.png" "$config/grid/${id}_logo.png"
done
}
# --- the game
[ -x "$LEPTON" ] || fail "Lepton, which runs Android games on the Steam Frame, was not found.\n\nStart any Android game from the Steam store once, then run this installer again."
for file in HaloVR.apk halo-vr.sh steam_frame_xiso.py grid/library.png; do
[ -f "$BUNDLE/$file" ] || fail "The installer is incomplete ($file is missing). Download it again."
done
if [ -f "$HALO_DIR/HaloVR.apk" ]; then
ask "Halo VR is installed already. Update it to this version?\n\n(Your settings, saves and game data stay.)" yes || exit 0
else
ask "This installs Halo VR on your Steam Frame.\n\nYou need your own disc image of Halo: Combat Evolved for the Xbox (an .iso or .xiso file, any version), for example in Downloads.\n\nContinue?" yes || exit 0
fi
mkdir -p "$HALO_DIR/maps" || fail "Could not create $HALO_DIR."
# (the game runs in Lepton's container as a user of its own)
chmod 777 "$HALO_DIR" "$HALO_DIR/maps"
cp "$BUNDLE/halo-vr.sh" "$HALO_DIR/halo-vr.sh.tmp" && chmod 755 "$HALO_DIR/halo-vr.sh.tmp" &&
mv -f "$HALO_DIR/halo-vr.sh.tmp" "$HALO_DIR/halo-vr.sh" || fail "Could not write $HALO_DIR/halo-vr.sh."
# (newer than the installed one: Lepton installs it at the next start)
cp "$BUNDLE/HaloVR.apk" "$HALO_DIR/HaloVR.apk.tmp" && chmod 666 "$HALO_DIR/HaloVR.apk.tmp" &&
mv -f "$HALO_DIR/HaloVR.apk.tmp" "$HALO_DIR/HaloVR.apk" && touch "$HALO_DIR/HaloVR.apk" ||
fail "Could not write $HALO_DIR/HaloVR.apk (is the storage full?)."
# --- the game data
if [ ! -f "$HALO_DIR/maps/ui.map" ]; then
image="${HALO_VR_IMAGE:-}"
if [ -z "$image" ]; then
mapfile -t images < <(find_images)
for candidate in "${images[@]}"; do
if ask "Use this disc image?\n\n$candidate" yes; then
image="$candidate"
break
fi
done
fi
if [ -z "$image" ]; then
if [ ${#images[@]} = 0 ]; then
info "No disc image was found in Downloads, Documents, on the Desktop or a memory card.\n\nChoose it in the next window."
fi
image="$(choose_image)" || fail "No disc image was chosen. Run the installer again once it is on the Frame (for example in Downloads)."
fi
extract_maps "$image"
chmod -R a+rwX "$HALO_DIR/maps"
image_note="\n\nThe disc image itself is no longer needed:\n$image"
fi
# --- Steam
steam_closed=0
if ! have_shortcut; then
if unattended; then
echo "info: the Steam shortcut is missing (not added unattended)"
else
info "Halo VR is now added to your Steam library. Steam closes for a moment for that."
if pgrep -x steam >/dev/null; then
steam -shutdown >/dev/null 2>&1 &
for _ in $(seq 60); do pgrep -x steam >/dev/null || break; sleep 1; done
pgrep -x steam >/dev/null && fail "Steam did not close. Close it (Steam menu > Exit) and run the installer again."
fi
steam_closed=1
add_shortcut || fail "Could not add Halo VR to the Steam library."
fi
fi
if unattended; then
echo "info: installed in $HALO_DIR"
exit 0
fi
if ask "Halo VR is installed.${image_note:-}\n\nSwitch to Gaming Mode now? Start Halo VR from your library there." yes; then
steamos-session-select gamescope >/dev/null 2>&1 ||
info "Switch to Gaming Mode yourself (the \"Return to Gaming Mode\" icon or the power menu)."
elif [ "$steam_closed" = 1 ]; then
setsid steam >/dev/null 2>&1 < /dev/null &
fi
@@ -0,0 +1,13 @@
[Desktop Entry]
# Halo VR's installer for the Steam Frame (port/android/STEAM_FRAME.md): a
# release asset, "Install-Halo-VR.desktop", opened in Desktop Mode. It
# downloads the latest release's bundle (tools/steam_frame_bundle.py, which
# fills in the repository) and runs its installer, steam_frame_install.sh.
# (no "$", "\" or "%" in Exec: the desktop entry format would need them
# escaped)
Type=Application
Name=Install Halo VR
Comment=Installs Halo VR for the Steam Frame, or updates it
Icon=steam
Terminal=false
Exec=bash -c "if curl -fL --retry 3 -o /tmp/halo-vr-steam-frame.zip https://github.com/@REPOSITORY@/releases/latest/download/halo-vr-steam-frame.zip && rm -rf /tmp/halo-vr-install && python3 -m zipfile -e /tmp/halo-vr-steam-frame.zip /tmp/halo-vr-install; then exec bash /tmp/halo-vr-install/install.sh; else zenity --error --width=460 --title='Halo VR' --text='Halo VR could not be downloaded. Is the Steam Frame online?'; fi"
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Copies the maps folder out of an Xbox disc image (an "xiso", or a whole
disc's ".iso") on the Steam Frame's SteamOS side, for its installer
(steam_frame_install.sh). It reads the image as the app's own import does
(app/src/main/java/com/halo/decomp/XisoExtractor.java, which this follows;
the desktop games' is port/linux/src/xiso.c).
steam_frame_xiso.py <image> <destination>
writes <destination>/maps (through maps.partial, so an interrupted copy
never leaves maps/ui.map behind) and prints its progress for zenity
--progress: a percentage on a line of its own, and "# <text>" lines. On
failure it prints "error: <reason the player can act on>" to stderr and
exits 1.
The image's file system is XDVDFS, read as extract-xiso does
(https://github.com/XboxDev/extract-xiso, extract-xiso.c, whose format
handling this follows; its license is below and in
port/third_party/extract-xiso/LICENSE.TXT): 2048-byte sectors; a volume
descriptor at 0x10000 that starts and ends with "MICROSOFT*XBOX*MEDIA" and
gives the root directory's sector and size; and directories whose entries
form a binary tree (each entry: left and right subtree offsets in 4-byte
units, the start sector, the size, attributes, the name's length and the
name). Images made from a whole disc put the game partition further in, at
one of the offsets below.
Some parts of this code are copyright in@fishtank.com. This product
includes software developed by in <in@fishtank.com>.
Copyright (c) 2003 in <in@fishtank.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software
must display the following acknowledgement:
This product includes software developed by in <in@fishtank.com>.
4. Neither the name of "in" nor the email address "in@fishtank.com"
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED `AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR OR ANY CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import os
import struct
import sys
SECTOR_SIZE = 2048
VOLUME_DESCRIPTOR_OFFSET = 0x10000
ENTRY_HEADER_SIZE = 14
ATTRIBUTE_DIRECTORY = 0x10
# directory tables are a few sectors; anything much larger is not one
MAXIMUM_DIRECTORY_SIZE = 4 << 20
MAXIMUM_FILES = 256
COPY_BUFFER_SIZE = 1 << 20
VOLUME_MAGIC = b"MICROSOFT*XBOX*MEDIA"
# where the game partition starts: a plain image, and whole-disc images
# (extract-xiso's GLOBAL, XGD3 and XGD1 offsets)
PARTITION_OFFSETS = (0, 0x0FD90000, 0x02080000, 0x18300000)
class ExtractError(Exception):
"""a reason the player can act on"""
def read_at(image, offset, size):
image.seek(offset)
data = image.read(size)
if len(data) != size:
raise ExtractError("Could not read the disc image (is it complete?).")
return data
def find_volume(image):
"""the game partition's offset, and the root directory's sector and size"""
for offset in PARTITION_OFFSETS:
try:
descriptor = read_at(image, offset + VOLUME_DESCRIPTOR_OFFSET, SECTOR_SIZE)
except ExtractError:
continue
if descriptor[:20] != VOLUME_MAGIC or descriptor[0x7EC:0x7EC + 20] != VOLUME_MAGIC:
continue
sector, size = struct.unpack_from("<II", descriptor, 20)
return offset, sector, size
raise ExtractError("This is not an Xbox disc image.")
def read_directory(image, partition, sector, size, what):
if size <= 0 or size > MAXIMUM_DIRECTORY_SIZE:
raise ExtractError(what)
return read_at(image, partition + sector * SECTOR_SIZE, size)
def walk(table, offset, depth, directories, out, visited):
"""collects the entries (name, sector, size) of the subtree at offset
(4-byte units), files or directories as asked"""
at = offset * 4
# a malformed table must not loop or run off its end
visited[0] += 1
if depth > 64 or visited[0] > 4096 or at + ENTRY_HEADER_SIZE > len(table):
return
left, right, sector, size, attributes, name_length = struct.unpack_from("<HHIIBB", table, at)
# 0xFFFF: padding, an empty directory
if left == 0xFFFF:
return
if left:
walk(table, left, depth + 1, directories, out, visited)
is_directory = bool(attributes & ATTRIBUTE_DIRECTORY)
if (at + ENTRY_HEADER_SIZE + name_length <= len(table) and name_length > 0
and is_directory == directories and len(out) < MAXIMUM_FILES):
name = table[at + ENTRY_HEADER_SIZE:at + ENTRY_HEADER_SIZE + name_length].decode("latin-1")
# (as extract-xiso refuses them: no name may leave the folder)
if name not in (".", "..") and "/" not in name and "\\" not in name:
out.append((name, sector, size))
if right:
walk(table, right, depth + 1, directories, out, visited)
def extract(image_path, destination):
with open(image_path, "rb") as image:
partition, root_sector, root_size = find_volume(image)
# the root's maps folder
table = read_directory(image, partition, root_sector, root_size,
"The disc image's file system is damaged.")
directories = []
walk(table, 0, 0, True, directories, [0])
maps = [entry for entry in directories if entry[0].lower() == "maps"]
if not maps:
raise ExtractError("The disc image has no maps folder: it is not a Halo disc.")
# its files
table = read_directory(image, partition, maps[-1][1], maps[-1][2],
"The disc image's maps folder is damaged.")
files = []
walk(table, 0, 0, False, files, [0])
if not any(name.lower() == "ui.map" for name, _, _ in files):
raise ExtractError("The disc image's maps folder has no ui.map: it is not a Halo disc.")
total = sum(size for _, _, size in files) or 1
partial = os.path.join(destination, "maps.partial")
finished = os.path.join(destination, "maps")
try:
os.makedirs(partial, exist_ok=True)
except OSError:
raise ExtractError("Could not create %s." % partial)
done = 0
shown = -1
for name, sector, size in files:
path = os.path.join(partial, name)
offset = partition + sector * SECTOR_SIZE
remaining = size
print("# Copying maps/%s" % name, flush=True)
try:
with open(path, "wb") as out:
while remaining > 0:
count = min(remaining, COPY_BUFFER_SIZE)
out.write(read_at(image, offset, count))
offset += count
remaining -= count
done += count
# (zenity closes at 100: that is left to the caller)
percent = min(99, done * 100 // total)
if percent != shown:
print(percent, flush=True)
shown = percent
except ExtractError:
raise ExtractError("Could not read %s from the disc image (is it complete?)." % name)
except OSError:
raise ExtractError("Could not write %s (is the storage full?)." % path)
# (the maps folder may be there, empty)
try:
os.makedirs(finished, exist_ok=True)
for name, _, _ in files:
os.replace(os.path.join(partial, name), os.path.join(finished, name))
os.rmdir(partial)
except OSError as error:
raise ExtractError("Could not move the maps into %s (%s)." % (finished, error.strerror))
def main():
if len(sys.argv) != 3:
print("usage: steam_frame_xiso.py <image> <destination>", file=sys.stderr)
return 2
try:
extract(sys.argv[1], sys.argv[2])
except ExtractError as error:
print("error: %s" % error, file=sys.stderr)
return 1
except OSError as error:
print("error: Could not open the disc image (%s)." % error.strerror, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
The Steam Frame's installer as release assets (port/android/STEAM_FRAME.md):
python3 tools/steam_frame_bundle.py <apk> <output folder> <owner/repository>
writes into the output folder
- halo-vr-steam-frame.zip: the installer (port/android/steam_frame_install.sh,
as install.sh), the APK (HaloVR.apk), the launcher (steam_frame_launch.sh,
as halo-vr.sh), the disc image reader (steam_frame_xiso.py, with
extract-xiso's license) and the library pictures (grid/);
- Install-Halo-VR.desktop (port/android/steam_frame_installer.desktop): what
the player downloads and opens in Desktop Mode; it fetches the latest
release's zip from the repository named.
"""
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ANDROID = ROOT / "port/android"
def main():
if len(sys.argv) != 4 or "/" not in sys.argv[3]:
print(__doc__.strip(), file=sys.stderr)
return 2
apk, output, repository = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3]
output.mkdir(parents=True, exist_ok=True)
files = [
(ANDROID / "steam_frame_install.sh", "install.sh", 0o755),
(ANDROID / "steam_frame_launch.sh", "halo-vr.sh", 0o755),
(ANDROID / "steam_frame_xiso.py", "steam_frame_xiso.py", 0o755),
(ROOT / "port/third_party/extract-xiso/LICENSE.TXT", "extract-xiso-LICENSE.txt", 0o644),
(apk, "HaloVR.apk", 0o644),
]
files += [(picture, "grid/" + picture.name, 0o644)
for picture in sorted((ANDROID / "steam_frame_grid").glob("*.png"))]
bundle = output / "halo-vr-steam-frame.zip"
with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as archive:
for path, name, mode in files:
info = zipfile.ZipInfo.from_file(path, name)
info.external_attr = (0o100000 | mode) << 16
info.compress_type = zipfile.ZIP_STORED if name.endswith((".apk", ".png")) else zipfile.ZIP_DEFLATED
archive.writestr(info, path.read_bytes())
print(f"{bundle.relative_to(Path.cwd()) if bundle.is_relative_to(Path.cwd()) else bundle}")
desktop = (ANDROID / "steam_frame_installer.desktop").read_text().replace("@REPOSITORY@", repository)
installer = output / "Install-Halo-VR.desktop"
installer.write_text(desktop)
installer.chmod(0o755)
print(installer)
return 0
if __name__ == "__main__":
sys.exit(main())