Compare commits

...
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 c5c604bb95 Import the European disc in Spanish and Italian, in the headset's language
That disc has no maps folder but one for each language, maps_es and
maps_it, and the disc image setup stopped: "The disc image has no maps
folder: it is not a Halo disc." The Quest's (XisoExtractor.java) and
the Steam Frame's (steam_frame_xiso.py, RINGFALL_LANGUAGE or the
system's) now take the folder of the device's language, else English's,
else the first, and write its language into maps/language.txt. The game
takes that as the Xbox's language (XGetLanguage) while game.language is
unset, so the menus speak it too. (Reported for ChupathingyCE, another
OpenCE fork, in its issue 145; written anew here.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:39:30 +02:00
Stefan MaierandClaude Opus 5.5 8662777f11 Merge feature/frame-updater: the Steam Frame's launcher updates the game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:11 +02:00
Stefan MaierandClaude Opus 5.5 e24df8ead9 The Steam Frame's launcher updates the game
The game's own updater (the Quest's) cannot on the Steam Frame: Lepton
installs the APK in the game's folder there at every start. So the
launcher does it: while the game runs it asks GitHub for the latest
release, fetches a newer one's Steam Frame bundle into update/, checks
it (its build.txt is the release's number), and puts the APK and the
launcher in place as the next start begins, before Lepton runs the APK.
update.auto = false in config.toml stops it, as on the Quest; a build
with no number (build.txt, which the bundle now carries and the
installer copies; 0 for local builds) never looks; without the internet,
or when anything fails, nothing changes. The in-game updater no longer
starts there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:40:26 +02:00
11 changed files with 217 additions and 10 deletions

No files matched your search

+5 -3
View File
@@ -68,7 +68,8 @@ of the game.
- A **Meta Quest 3 / 3S** or a **Steam Frame**.
- A **disc image** (`.iso` or `.xiso`) of **Halo: Combat Evolved for the
original Xbox**, made from **a disc you own**. Any region and version
works.
works. The game plays in the disc's language; on the European disc in
Spanish and Italian it takes your headset's language (else the first).
This project does not include, link to or help find game files. Please
do not ask for or share disc images in issues or discussions; such posts
@@ -116,8 +117,9 @@ No computer and no keyboard needed.
library and offers to switch back to Gaming Mode.
6. Start **Ringfall: Mjolnir VR** from your library (under *Non-Steam*).
To update, open the same file in Desktop Mode again; your settings,
saves and game data stay. More about the Steam Frame:
The game updates itself: when a new version is out, it is downloaded
while you play and used from the next start on. Your settings, saves and
game data stay. More about the Steam Frame:
[port/android/STEAM_FRAME.md](port/android/STEAM_FRAME.md).
## Playing
+5 -2
View File
@@ -16,8 +16,11 @@ On the Frame, in Desktop Mode (Steam menu > Power > Switch to Desktop):
copies the game data out of it, adds "Ringfall: Mjolnir VR" to the Steam library
and switches back to Gaming Mode. Start it from the library there.
Opening the same file again updates the game (settings, saves and game data
stay).
The game updates itself: while it runs, its launcher looks for a newer
release, fetches it and puts it in place as the next start begins
(`steam_frame_launch.sh`; `update.auto = false` in `config.toml` stops it;
`launcher.log` says what it did). Opening the same file again updates it
too (settings, saves and game data stay).
The desktop file (`steam_frame_installer.desktop`) downloads the latest
release's `ringfall-mjolnir-vr-steam-frame.zip` (`tools/steam_frame_bundle.py`, made by
+1
View File
@@ -27,6 +27,7 @@ named where code is taken over.
| Bots | Bots in multiplayer: on foot (paths, items, objectives), in vehicles and at turrets; they make room for a joining person |
| Voice chat | Push to talk on any button (`vr.button_talk`, BUTTONS page; the free hand's grip by default, sending nothing while it reaches for the foregrip or the wheel), open mic, off (2026-10-13) |
| Builds | The Android build's assembly conversion (`tools/android_asm_convert.py`) keeps string data as it is: strings and tables starting with "_" or holding \" lost a "_" (Opus's pulse cache among them: the Steam Frame's crashes in online games while talking), 2026-10-13 |
| Languages | The European disc in Spanish and Italian (a maps folder for each, `maps_es` and `maps_it`, none called `maps`) imported on the Quest and the Steam Frame: the headset's language's, its language the game's and the menus' (`maps/language.txt`, unless `game.language` is set), 2026-10-17; not yet tried with that disc |
| Controls | Left-handed mode (`vr.handedness`): the weapon in the left hand, the arms and grips mirrored, sticks and buttons mirrored on both controllers (`vr.mirror_controls`), the assault rifle's counter read the right way round (2026-10-06) |
| Graphics | Presets low / medium / high / max (`vr.graphics`), refresh rate and resolution in the VR options, all kept over a restart (2026-10-06) |
| Arms and hands | Both arms by IK from shoulders that follow the torso, the elbow led by the hand's roll; fingers from trigger, grip and touch; `vr.body` arms / hands / off, `vr.fingers` (2026-10-06) |
@@ -35,7 +35,7 @@ import java.nio.channels.FileChannel;
*/
public class LauncherActivity extends Activity {
private static final int PICK_IMAGE = 1;
private static final String SHARED_DATA_ROOT = "/home/steamos/Documents/RingfallMjolnirVR";
static final String SHARED_DATA_ROOT = "/home/steamos/Documents/RingfallMjolnirVR";
private File dataRoot;
private TextView status;
@@ -36,7 +36,8 @@ import java.util.zip.ZipInputStream;
* the latest release when it starts, on a thread of its own, and if it is
* newer asks the player whether to update:
*
* - Yes: the release's app (halo-android-release.zip or -debug.zip) is
* - Yes: the release's app (ringfall-mjolnir-vr-android-release.zip or
* -debug.zip) is
* downloaded and handed to Android's package installer, which replaces the
* game (closing it) and offers to open the new version.
* - No: nothing, until the next start.
@@ -45,6 +46,9 @@ import java.util.zip.ZipInputStream;
*
* Every build of main is signed with the same key (the workflow's), which an
* app must keep for Android to install a new version over it.
*
* Not on the Steam Frame: Lepton installs the APK in the game's folder there
* at every start, so its launcher updates that (steam_frame_launch.sh).
*/
final class Updater {
/* the VR port's own releases: the original's, made without VR, must
@@ -63,7 +67,8 @@ final class Updater {
static void start(Activity activity) {
File config = configFile(activity);
if (BuildConfig.HALO_BUILD_NUMBER <= 0 || config == null || !autoUpdate(config))
if (BuildConfig.HALO_BUILD_NUMBER <= 0 || config == null || !autoUpdate(config) ||
new File(LauncherActivity.SHARED_DATA_ROOT).isDirectory())
return;
new Thread(() -> {
int latest = latestRelease();
@@ -9,6 +9,7 @@ import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Copies the maps folder out of an Xbox disc image (an "xiso", or a whole
@@ -29,6 +30,12 @@ import java.util.List;
* The files are written to maps.partial first and moved into maps once they
* all are, so an interrupted extraction never leaves maps/ui.map behind.
*
* A European disc in two languages (Spanish and Italian) has no maps folder
* but one for each, maps_es and maps_it: the device's language's is taken,
* else English's, else the first, and its language written to
* maps/language.txt, which the game asks the Xbox for (game.language unset,
* port/linux/src/xbox_xapi.c): the menus in it too.
*
* Some parts of this code are copyright in@fishtank.com. This product
* includes software developed by in &lt;in@fishtank.com&gt;.
*
@@ -225,6 +232,28 @@ final class XisoExtractor {
if (entry.name.equalsIgnoreCase("maps"))
maps = entry;
}
String language = null;
if (maps == null) {
String wanted = Locale.getDefault().getLanguage();
Entry english = null, first = null;
for (Entry entry : directories) {
String code = mapsLanguage(entry.name);
if (code == null)
continue;
if (code.equals(wanted))
maps = entry;
if (code.equals("en"))
english = entry;
if (first == null)
first = entry;
}
if (maps == null)
maps = english != null ? english : first;
if (maps != null)
language = mapsLanguage(maps.name);
}
if (maps == null)
throw new ExtractException("The disc image has no maps folder: it is not a Halo disc.");
@@ -286,5 +315,22 @@ final class XisoExtractor {
throw new ExtractException("Could not move " + file.name + " into " + finished + ".");
}
partial.delete();
File languageFile = new File(finished, "language.txt");
languageFile.delete();
if (language != null) {
try (FileOutputStream out = new FileOutputStream(languageFile)) {
out.write((language + "\n").getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
throw new ExtractException("Could not write " + languageFile + " (is the storage full?).");
}
}
}
/** a language's maps folder's language ("maps_it": "it"), else null */
static String mapsLanguage(String name) {
if (name.length() != 7 || !name.regionMatches(true, 0, "maps_", 0, 5))
return null;
String code = name.substring(5).toLowerCase(Locale.ROOT);
return Character.isLetter(code.charAt(0)) && Character.isLetter(code.charAt(1)) ? code : null;
}
}
+4
View File
@@ -237,6 +237,10 @@ cp "$BUNDLE/$LAUNCHER" "$GAME_DIR/$LAUNCHER.tmp" && chmod 755 "$GAME_DIR/$LAUNCH
cp "$BUNDLE/$APK_NAME" "$GAME_DIR/$APK_NAME.tmp" && chmod 666 "$GAME_DIR/$APK_NAME.tmp" &&
mv -f "$GAME_DIR/$APK_NAME.tmp" "$GAME_DIR/$APK_NAME" && touch "$GAME_DIR/$APK_NAME" ||
fail "Could not write $GAME_DIR/$APK_NAME (is the storage full?)."
# (the build's number: the launcher updates from it, steam_frame_launch.sh;
# an update fetched for an older build is dropped)
cp "$BUNDLE/build.txt" "$GAME_DIR/build.txt" 2>/dev/null || rm -f "$GAME_DIR/build.txt"
rm -rf "$GAME_DIR/update"
# --- the game data
+72
View File
@@ -70,6 +70,75 @@ run()
child=0
}
# Updates (update.auto in config.toml; the game's own updater, the Quest's,
# does nothing here: Lepton installs the APK in this folder each start).
# While the game runs, the latest release is looked for; a newer one's
# bundle is fetched into update/, checked, and put in place as the next
# start begins, before Lepton runs the APK (it installs the newer one).
# A build that knows no number of its own (build.txt: a local one) never
# looks. Nothing changes without the internet, or when anything fails.
REPOSITORY="@REPOSITORY@"
UPDATE="$DATA/update"
BUILD_FILE="$DATA/build.txt"
SELF="$DATA/ringfall-mjolnir-vr.sh"
update_apply()
{
local new="$UPDATE/bundle" build
[ -f "$UPDATE/ready" ] || { rm -rf "$UPDATE"; return 0; }
build=$(cat "$new/build.txt" 2>/dev/null)
# (the launcher renamed over this one: bash goes on reading its own copy)
if cp "$new/ringfall-mjolnir-vr.sh" "$SELF.tmp" && chmod 755 "$SELF.tmp" &&
cp "$new/RingfallMjolnirVR.apk" "$APK.tmp" && chmod 666 "$APK.tmp" &&
mv -f "$SELF.tmp" "$SELF" && mv -f "$APK.tmp" "$APK" && touch "$APK" &&
cp "$new/build.txt" "$BUILD_FILE"
then
log "update: build $build put in place"
else
log "update: build $build could not be put in place"
rm -f "$SELF.tmp" "$APK.tmp"
fi
rm -rf "$UPDATE"
}
update_wanted()
{
# (update.auto = false in config.toml's [update]: never)
! awk '/^\[/ { section = $0 } section == "[update]" && /^[ \t]*auto[ \t]*=/ { print $3 }' \
"$DATA/config.toml" 2>/dev/null | grep -q false
}
update_check()
{
local installed latest
installed=$(cat "$BUILD_FILE" 2>/dev/null)
[[ "$installed" =~ ^[0-9]+$ ]] && (( installed > 0 )) || return 0
update_wanted || return 0
latest=$(curl -fsSL --max-time 20 -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPOSITORY/releases/latest" |
python3 -I -c 'import json, sys; print(json.load(sys.stdin)["tag_name"].rpartition("-")[2])' 2>/dev/null)
[[ "$latest" =~ ^[0-9]+$ ]] && (( latest > installed )) || return 0
log "update: build $latest found (this is $installed), fetching it"
rm -rf "$UPDATE" && mkdir -p "$UPDATE" || return 0
if curl -fsSL --retry 2 --max-time 900 -o "$UPDATE/bundle.zip" \
"https://github.com/$REPOSITORY/releases/download/build-$latest/ringfall-mjolnir-vr-steam-frame.zip" &&
python3 -I -m zipfile -e "$UPDATE/bundle.zip" "$UPDATE/bundle" &&
[ "$(cat "$UPDATE/bundle/build.txt" 2>/dev/null)" = "$latest" ] &&
[ -s "$UPDATE/bundle/RingfallMjolnirVR.apk" ] && [ -s "$UPDATE/bundle/ringfall-mjolnir-vr.sh" ]
then
rm -f "$UPDATE/bundle.zip"
touch "$UPDATE/ready"
log "update: build $latest fetched, put in place at the next start"
else
log "update: build $latest could not be fetched"
rm -rf "$UPDATE"
fi
}
update_apply
update_check </dev/null >/dev/null 2>&1 &
updater=$!
# (a new APK is only installed on its first start, not started; and a start
# right after a game that was ended hard can fail in SteamVR, which then
# needs a moment: again, up to three times, while it ends within 30 s)
@@ -83,4 +152,7 @@ for attempt in 1 2 3; do
sleep 5
done
rm -f "$QUIT"
# (a fetch still under way is given up: the next start looks again)
pkill -P "$updater" 2>/dev/null
kill "$updater" 2>/dev/null
log "end"
+31
View File
@@ -138,6 +138,13 @@ def walk(table, offset, depth, directories, out, visited):
walk(table, right, depth + 1, directories, out, visited)
def maps_language(name):
"""a language's maps folder's language ("maps_it": "it"), else None"""
if len(name) == 7 and name[:5].lower() == "maps_" and name[5:].isalpha():
return name[5:].lower()
return None
def extract(image_path, destination):
with open(image_path, "rb") as image:
partition, root_sector, root_size = find_volume(image)
@@ -148,6 +155,24 @@ def extract(image_path, destination):
directories = []
walk(table, 0, 0, True, directories, [0])
maps = [entry for entry in directories if entry[0].lower() == "maps"]
# (a European disc in two languages, Spanish and Italian, has a maps
# folder for each, maps_es and maps_it: the one for the language
# asked for (RINGFALL_LANGUAGE, else the system's), else English's,
# else the first; its language goes into maps/language.txt, which the
# game takes as game.language's when that is unset)
language = None
if not maps:
languages = [(maps_language(entry[0]), entry) for entry in directories if maps_language(entry[0])]
wanted = (os.environ.get("RINGFALL_LANGUAGE") or os.environ.get("LC_ALL") or
os.environ.get("LC_MESSAGES") or os.environ.get("LANG") or "")[:2].lower()
for preferred in (wanted, "en"):
chosen = [pair for pair in languages if pair[0] == preferred]
if chosen:
break
chosen = chosen or languages[:1]
if chosen:
language, entry = chosen[0]
maps = [entry]
if not maps:
raise ExtractError("The disc image has no maps folder: it is not a Halo disc.")
@@ -197,6 +222,12 @@ def extract(image_path, destination):
for name, _, _ in files:
os.replace(os.path.join(partial, name), os.path.join(finished, name))
os.rmdir(partial)
language_file = os.path.join(finished, "language.txt")
if os.path.exists(language_file):
os.remove(language_file)
if language:
with open(language_file, "w") as out:
out.write(language + "\n")
except OSError as error:
raise ExtractError("Could not move the maps into %s (%s)." % (finished, error.strerror))
+30
View File
@@ -42,10 +42,40 @@ DWORD WINAPI XLaunchNewImageA(LPCSTR image_path, PLAUNCH_DATA launch_data)
exit(EXIT_SUCCESS);
}
/* port: a European disc in two languages has a maps folder for each, and
the Android and Steam Frame disc image setups (XisoExtractor.java,
steam_frame_xiso.py) write the language of the one they took into
maps/language.txt: the Xbox's language, unless game.language says another */
static const char *maps_language(void)
{
static char language[4];
static BOOL read;
if (!read)
{
char path[MAX_PATH];
FILE *file;
read = TRUE;
snprintf(path, sizeof(path), "%s/maps/language.txt", platform_data_root());
if ((file = fopen(path, "r")) != NULL)
{
if (!fgets(language, sizeof(language), file))
language[0] = '\0';
language[2] = '\0';
fclose(file);
}
}
return language;
}
DWORD WINAPI XGetLanguage(void)
{
const char *language = config_string("game.language");
if (!*language)
language = maps_language();
if (*language)
{
if (!strncmp(language, "ja", 2)) return XC_LANGUAGE_JAPANESE;
+15 -2
View File
@@ -7,13 +7,17 @@ The Steam Frame's installer as release assets (port/android/STEAM_FRAME.md):
writes into the output folder
- ringfall-mjolnir-vr-steam-frame.zip: the installer (port/android/steam_frame_install.sh,
as install.sh), the APK (RingfallMjolnirVR.apk), the launcher
(steam_frame_launch.sh, as ringfall-mjolnir-vr.sh), the disc image reader (steam_frame_xiso.py, with
(steam_frame_launch.sh, as ringfall-mjolnir-vr.sh, which updates the game
from the repository named), the build's number (build.txt: the workflow's
run number, HALO_BUILD_NUMBER or GITHUB_RUN_NUMBER; 0, which never
updates, without one), the disc image reader (steam_frame_xiso.py, with
extract-xiso's license) and the library pictures (grid/);
- Install-Ringfall-Mjolnir-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 os
import sys
import zipfile
from pathlib import Path
@@ -38,13 +42,22 @@ def main():
]
files += [(picture, "grid/" + picture.name, 0o644)
for picture in sorted((ANDROID / "steam_frame_grid").glob("*.png"))]
build = os.environ.get("HALO_BUILD_NUMBER") or os.environ.get("GITHUB_RUN_NUMBER") or "0"
if not build.isdigit():
build = "0"
bundle = output / "ringfall-mjolnir-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())
data = path.read_bytes()
if name == "ringfall-mjolnir-vr.sh":
data = data.replace(b"@REPOSITORY@", repository.encode())
archive.writestr(info, data)
info = zipfile.ZipInfo("build.txt", date_time=(2026, 1, 1, 0, 0, 0))
info.external_attr = (0o100000 | 0o644) << 16
archive.writestr(info, build + "\n")
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)