mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-17 16:00:26 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c604bb95 | ||
|
|
8662777f11 | ||
|
|
e24df8ead9 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <in@fishtank.com>.
|
||||
*
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user