mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-17 13:00:25 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da699b035 | ||
|
|
c5c604bb95 | ||
|
|
8662777f11 | ||
|
|
e24df8ead9 | ||
|
|
7a10a413c8 | ||
|
|
5ce39c27a7 | ||
|
|
67d738451c | ||
|
|
91bdb7e67c | ||
|
|
bf778b6d60 | ||
|
|
d8a786f5a1 | ||
|
|
7c294c9c39 | ||
|
|
f90d955527 | ||
|
|
085676d827 | ||
|
|
4301ca88d6 | ||
|
|
9c70f5f5e3 | ||
|
|
d9669f6eef |
No files matched your search
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
python -m pytest -q tools/test_linux_port.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py
|
||||
python -m pytest -q tools/test_linux_port.py tools/test_android_asm_convert.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py
|
||||
python tools/test_light_storage.py
|
||||
|
||||
- name: Compile cache
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -388,7 +388,9 @@ The guest is the game, the platform layer and a small runtime:
|
||||
AArch64 target of clang. The options `-U__APPLE__` and
|
||||
`-fno-define-target-os-macros` hide the Darwin environment.
|
||||
2. `tools/android_asm_convert.py` changes the Mach-O assembly to ELF
|
||||
assembly.
|
||||
assembly. It renames symbols, but keeps string literals (`.ascii`,
|
||||
`.asciz`) byte for byte: they hold tables as well as text
|
||||
(`tools/test_android_asm_convert.py`).
|
||||
3. The AArch64 assembler makes the objects.
|
||||
4. `ld.lld` links the objects with `guest/guest.ld` to a static image,
|
||||
`build/android/halo_guest.elf`, at a fixed address above the Xbox memory
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ named where code is taken over.
|
||||
| Area | What |
|
||||
| --- | --- |
|
||||
| Headsets | Quest 3; Steam Frame (Lepton): its controllers' layout, a Steam library tile that installs, starts, updates and quits the game, data kept in `~/Documents/RingfallMjolnirVR` |
|
||||
| Rendering | Both eyes in one pass (multiview) on the Quest; one by one on SteamVR, where multiview's eyes did not fuse; lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
|
||||
| Rendering | The eyes drawn one by one (both at once, multiview, only with `debug.multiview`: its eyes did not always fuse, see the open points); lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
|
||||
| Aiming | The weapon in the hand, its shots from the muzzle, laser and crosshair options |
|
||||
| Body | Physical crouch (`vr.crouch_limit`) beside the crouch button, leaning (`vr.lean_limit`), melee by swinging, grenades thrown with the left hand, two-handed weapons |
|
||||
| Vehicles | The Warthog's wheel turned with the hands, right trigger throttle, left brake; first-person seats; turrets with handles |
|
||||
@@ -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) |
|
||||
@@ -42,6 +43,7 @@ named where code is taken over.
|
||||
| 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; moving by swinging the arms (in place of the stick, never faster than it: online too), and a sprint by swinging them harder (faster than the game allows: offline only) | import **done** (in `main` 2026-10-06): the app's import (tried on the Quest 3: all 24 maps; on the Frame kept in `~/Documents/RingfallMjolnirVR`), a Steam Frame installer from Desktop Mode (`STEAM_FRAME.md`; tried on the Frame: the old folder renamed, the shortcut added by Steam), a release signing key (CI and local builds the same). Arm swinging and its offline sprint still open |
|
||||
| 5 | Later: multiview again (both eyes in one pass, half the drawing's CPU cost): find why its eyes do not fuse in big online games on the Quest 3 (and now and then on the Steam Frame), then turn it back on | open: off by default since 2026-10-16 (`debug.multiview`); findings in the open points |
|
||||
| R | Release as **Ringfall: Mjolnir VR** (no "Halo" in the product's name): app name, id `io.github.cesarus85.ringfall`, icon and library pictures (`tools/ringfall_art.py`), Frame folder and installer, release assets, README; the repository renamed | **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good) |
|
||||
|
||||
## Step 2, first part (done)
|
||||
@@ -518,6 +520,34 @@ see that nothing is missing or slower.
|
||||
|
||||
## Open points outside the order
|
||||
|
||||
- Multiview's doubled pictures (2026-10-16): on the developer's Quest 3,
|
||||
doubled and shifted pictures from the start of big online games (26-35
|
||||
players) as the head turned, nauseating; none in a local game with 48
|
||||
bots; none online either with the eyes drawn one by one. Not the frame
|
||||
rate: one by one, online at 72 Hz, the compositor repeated as many
|
||||
frames (about 4 a second) and nothing was seen. Not the facing jumps
|
||||
(respawns only) nor the camera (it moves every frame). Before: the
|
||||
Steam Frame's (2026-10-05), each eye alone sharp. So the eyes are drawn
|
||||
one by one by default now (`debug.multiview` for both at once). To do:
|
||||
find what the shared pass draws wrongly online, e.g. by saving both
|
||||
eyes' pictures in a big online game and comparing what is doubled
|
||||
(sprites and effects projected by the game itself, `mv_depth`; programs
|
||||
made on the shader worker; the sky rows), then turn it back on: it
|
||||
halves the drawing's CPU cost.
|
||||
- Big online games (2026-10-16): doubled pictures on a 32-player server,
|
||||
none in a local game with 48 bots. Not the GPU (60–80 %): the ticks cost
|
||||
3.3–4.6 ms as a client there (1.3–1.9 ms with bots or 16 players), so
|
||||
47–70 of 150 frames with a tick missed 90 Hz's 11.1 ms (the doubling
|
||||
itself was multiview's, above). In `main` 2026-10-16 (Quest 3 good):
|
||||
`vr.refresh_rate_auto`
|
||||
(VR options MORE, "Lower it when frames are late", on by default) steps
|
||||
the rate down a step (90, 80, 72 Hz) while more than one frame in 32
|
||||
comes late, and back up once they would keep up; "VR: refresh rate" in
|
||||
the log. First try: 90 to 80 Hz a few seconds into a 30-player game, no
|
||||
doubled picture seen; 2-5 stale frames a second left at 80, hence one
|
||||
in 32 (was 16). Later:
|
||||
why a client's ticks cost that much per remote player.
|
||||
|
||||
- Doubled pictures when turning on the developer's Quest 3 (2026-10-12):
|
||||
with every build tried, back to 2026-10-06's, at 90 and at 72 Hz, even
|
||||
in seconds the compositor reported no stale frame. That Quest runs
|
||||
@@ -526,6 +556,8 @@ see that nothing is missing or slower.
|
||||
default (smooth there). To do: try this and later builds with "high" on
|
||||
a Quest 3 outside PTC (the developer's wife's), and set the default
|
||||
back to "high" or "auto" if it holds.
|
||||
Resolved 2026-10-16: multiview (above), not the Horizon OS PTC.
|
||||
|
||||
- Multiview on the Steam Frame: why the eyes do not fuse there (suspected
|
||||
Mesa zink/Turnip); tools in branch `frame/doubled-images` (86335828),
|
||||
not merged.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The PC version's menus: ui\shell\main_menu\multiplayer_type_select\connected (tools/ce_menus.py) -->
|
||||
<menus>
|
||||
<widget name="main_menu/multiplayer_type_select/connected/connected_map_select_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game tag_controller_index force_handle_mouse">
|
||||
<widget name="main_menu/multiplayer_type_select/connected/connected_map_select_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse">
|
||||
<child widget="main_menu/multiplayer_type_select/mp_map_select/mp_map_select_screen"/>
|
||||
</widget>
|
||||
<widget name="main_menu/multiplayer_type_select/connected/gametype_select_screen_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game">
|
||||
|
||||
@@ -50,7 +50,7 @@ void halo_vr_eye_camera(int eye, const float game_position[3],
|
||||
/* copies the back buffer, just drawn for an eye, to that eye's image */
|
||||
void halo_vr_capture_eye(int eye);
|
||||
|
||||
/* Both eyes in one draw (vr.multiview, where GL_OVR_multiview2 is): whether
|
||||
/* Both eyes in one draw (debug.multiview, where GL_OVR_multiview2 is): whether
|
||||
this frame draws so; the camera between the eyes whose view takes in both
|
||||
(as halo_vr_eye_camera), which also starts drawing into both eyes' layers;
|
||||
and after the draw, the layers taken for the eyes' images. */
|
||||
|
||||
@@ -200,6 +200,12 @@ static const struct config_setting config_settings[] =
|
||||
{ "vr.refresh_rate", _config_real, "90.0", "HALO_VR_REFRESH_RATE", _environment_value, _platform_android,
|
||||
"The display refresh rate to ask the headset for, in Hz (72, 90, 120);\n"
|
||||
"it picks the nearest it has at or below. 0 for the highest." },
|
||||
{ "vr.refresh_rate_auto", _config_boolean, "true", "HALO_VR_REFRESH_RATE_AUTO", _environment_value,
|
||||
_platform_android,
|
||||
"While frames come late at that rate (big online games), step down to the\n"
|
||||
"next the headset has (90 to 72 Hz), and back up once they would keep up\n"
|
||||
"again; the headset shows a late frame twice, a doubled picture when\n"
|
||||
"turning. Only where the headset lets the game choose (not SteamVR)." },
|
||||
{ "vr.graphics", _config_string, "\"medium\"", "HALO_VR_GRAPHICS", _environment_value, _platform_android,
|
||||
"Which of the costlier effects are drawn (\"medium\" by default): \"auto\" all of them while the\n"
|
||||
"GPU keeps up, leaving out one after another (the shield's glow, object\n"
|
||||
@@ -227,9 +233,6 @@ static const struct config_setting config_settings[] =
|
||||
"away from the player's body (walls are not checked)." },
|
||||
{ "vr.crouch_limit", _config_real, "0.9", "HALO_VR_CROUCH_LIMIT", _environment_value, _platform_android,
|
||||
"How far, in metres, the view follows your head down." },
|
||||
{ "vr.multiview", _config_boolean, "true", "HALO_VR_MULTIVIEW", _environment_value, _platform_android,
|
||||
"Draw both eyes in one pass (GL_OVR_multiview2), from between them: about\n"
|
||||
"half the drawing's cost on the processor. false: one eye after the other." },
|
||||
{ "vr.log_performance", _config_boolean, "false", "HALO_VR_LOG_PERFORMANCE", _environment_value, _platform_android,
|
||||
"Log the frame rate and where the frame time goes every five seconds\n"
|
||||
"(adb logcat -s halo)." },
|
||||
@@ -780,10 +783,12 @@ static const struct config_setting config_settings[] =
|
||||
"machines of twice it), to test the netcode as over the internet; 0 none." },
|
||||
{ "debug.network_loss", _config_real, "0.0", "HALO_NETWORK_LOSS", _environment_value, _platform_all,
|
||||
"Percent of datagrams received that are dropped, for the same; 0 none." },
|
||||
{ "debug.multiview_steamvr", _config_boolean, "false", "HALO_MULTIVIEW_STEAMVR", _environment_value,
|
||||
_platform_android,
|
||||
"On SteamVR (the Steam Frame) the eyes are drawn one by one, whatever\n"
|
||||
"vr.multiview says: true draws them at once there too, for testing." },
|
||||
{ "debug.multiview", _config_boolean, "false", "HALO_MULTIVIEW", _environment_value, _platform_android,
|
||||
"Draw both eyes in one pass (GL_OVR_multiview2), from between them: about\n"
|
||||
"half the drawing's cost on the processor, but the eyes' pictures did not\n"
|
||||
"always fuse (doubled as the head turned: on the Steam Frame now and then,\n"
|
||||
"on the Quest 3 in big online games). Off, one eye after the other; on,\n"
|
||||
"for finding why." },
|
||||
{ "debug.gl_thread", _config_integer, "-1", "HALO_GL_THREAD", _environment_value, _platform_android,
|
||||
"Mesa's GL thread (its drivers: the Steam Frame's): 1 on, 0 off, -1 as the\n"
|
||||
"driver chooses. Read when the app starts." },
|
||||
|
||||
@@ -395,6 +395,10 @@ void voice_audio_play(int speaker, unsigned short sequence, const unsigned char
|
||||
|
||||
if (length <= 0 || length > VOICE_MAXIMUM_PACKET)
|
||||
return;
|
||||
/* (every build sends a frame of 20 ms: anything else, damaged, is
|
||||
dropped before the decoder sees it, and made up for as lost) */
|
||||
if (opus_packet_get_nb_samples(packet, length, VOICE_RATE) != VOICE_FRAME_SAMPLES)
|
||||
return;
|
||||
pthread_mutex_lock(&voice_lock);
|
||||
slot = voice_slot_for(speaker, TRUE);
|
||||
if (!slot)
|
||||
|
||||
+119
-14
@@ -138,7 +138,7 @@ static struct
|
||||
float seat_frame_yaw;
|
||||
int seat_frame_valid;
|
||||
float seat_turn;
|
||||
int multiview; /* vr.multiview: both eyes in one draw */
|
||||
int multiview; /* debug.multiview: both eyes in one draw */
|
||||
int multiview_logged;
|
||||
int multiview_logged_views;
|
||||
/* vr.foveation's level last logged (+1; 0: none yet) */
|
||||
@@ -298,6 +298,15 @@ static struct
|
||||
vr.resolution_scale as the app started with it */
|
||||
float refresh_rate_wanted;
|
||||
float resolution_scale;
|
||||
/* vr.refresh_rate_auto (refresh_governor): the rate asked for now (the
|
||||
one wanted, or lower while frames come late), the rates below and above
|
||||
it, and the window's frames: all, late at this rate, and those that
|
||||
would be late at the one above */
|
||||
int refresh_auto;
|
||||
float refresh_rate_now, refresh_lower, refresh_higher, refresh_period_ms;
|
||||
unsigned long long refresh_window_us, refresh_changed_us;
|
||||
unsigned long refresh_frames, refresh_late, refresh_tight;
|
||||
int refresh_bad_windows, refresh_good_windows;
|
||||
} vr;
|
||||
|
||||
/* d3d8_gl.c */
|
||||
@@ -585,12 +594,23 @@ static void vr_read_live_settings(void)
|
||||
/* (the refresh rate changes at once where the runtime lets it) */
|
||||
float refresh_rate = (float)config_real("vr.refresh_rate");
|
||||
|
||||
if (vr.active && refresh_rate != vr.refresh_rate_wanted)
|
||||
int automatic = config_boolean("vr.refresh_rate_auto");
|
||||
|
||||
if (vr.active && (refresh_rate != vr.refresh_rate_wanted ||
|
||||
(!automatic && vr.refresh_rate_now != refresh_rate)))
|
||||
{
|
||||
platform_log("VR: refresh rate %.0f Hz asked for", refresh_rate);
|
||||
host_xr_set_refresh_rate(refresh_rate);
|
||||
}
|
||||
if (refresh_rate != vr.refresh_rate_wanted || automatic != vr.refresh_auto)
|
||||
{
|
||||
/* (the governor starts over from the rate chosen) */
|
||||
vr.refresh_rate_now = refresh_rate;
|
||||
vr.refresh_window_us = 0;
|
||||
vr.refresh_bad_windows = vr.refresh_good_windows = 0;
|
||||
}
|
||||
vr.refresh_rate_wanted = refresh_rate;
|
||||
vr.refresh_auto = automatic;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +645,7 @@ void halo_vr_initialize(void)
|
||||
vr.two_hand_reach = (float)config_real("vr.two_hand_reach");
|
||||
vr.melee_swing_speed = (float)config_real("vr.melee_swing_speed");
|
||||
vr.grenade_throw_speed = (float)config_real("vr.grenade_throw_speed");
|
||||
vr.multiview = config_boolean("vr.multiview");
|
||||
vr.multiview = config_boolean("debug.multiview");
|
||||
vr.vehicle_first_person = config_boolean("vr.vehicle_first_person");
|
||||
vr.vehicle_spawn = config_boolean("debug.vehicle_spawn");
|
||||
vr.turret_handles = config_boolean("vr.turret_handles");
|
||||
@@ -658,16 +678,13 @@ void halo_vr_initialize(void)
|
||||
vr.active = 1;
|
||||
platform_log("VR: %s, eyes %dx%d at %.0f Hz", vr.info.system_name, vr.info.eye_width, vr.info.eye_height,
|
||||
vr.info.refresh_rate);
|
||||
/* SteamVR (the Steam Frame): each eye drawn apart. With both drawn at
|
||||
once the eyes' pictures did not fuse, now and then: each alone was
|
||||
sharp, the two together doubled as the head turned (2026-10-05), and
|
||||
one by one costs it no more (its time goes to the driver either way).
|
||||
debug.multiview_steamvr draws them at once there again, for finding why */
|
||||
if (vr.multiview && strstr(vr.info.system_name, "SteamVR") && !config_boolean("debug.multiview_steamvr"))
|
||||
{
|
||||
vr.multiview = 0;
|
||||
platform_log("VR: SteamVR: the eyes drawn one by one (debug.multiview_steamvr)");
|
||||
}
|
||||
/* Each eye drawn apart (debug.multiview draws both at once). At once,
|
||||
the eyes' pictures did not always fuse: on the Steam Frame now and then
|
||||
(each alone sharp, the two together doubled as the head turned,
|
||||
2026-10-05; one by one costs it no more there), and on the Quest 3 in
|
||||
big online games (doubled from the start as the head turned, none with
|
||||
48 bots; one by one, none online either, 2026-10-16) */
|
||||
platform_log("VR: the eyes drawn %s (debug.multiview)", vr.multiview ? "at once" : "one by one");
|
||||
}
|
||||
|
||||
int halo_vr_active(void)
|
||||
@@ -889,6 +906,93 @@ static void performance_phases(unsigned long long now)
|
||||
vr.frame_ticks = 0;
|
||||
}
|
||||
|
||||
/* vr.refresh_rate_auto: frames late at the headset's rate (a game's big
|
||||
online games: the ticks cost as a client in step with the players) are
|
||||
shown twice by the headset, a doubled picture when turning. While more
|
||||
than one in 32 of them come late, two windows of REFRESH_WINDOW_US
|
||||
running, the rate steps down to the next the headset offers (the Quest
|
||||
3's: 90, 80, 72 Hz); while hardly any would be late at the next one up (one in a
|
||||
hundred), ten windows running, it steps back up, never above
|
||||
vr.refresh_rate. Frames of a load (REFRESH_LOAD_MS and more) count for
|
||||
neither; a step waits REFRESH_SETTLE_US (down) or REFRESH_RAISE_US (up)
|
||||
after the last. */
|
||||
#define REFRESH_WINDOW_US 2000000ULL
|
||||
#define REFRESH_SETTLE_US 10000000ULL
|
||||
#define REFRESH_RAISE_US 30000000ULL
|
||||
#define REFRESH_LOAD_MS 50.0f
|
||||
|
||||
static void refresh_rates_around(void)
|
||||
{
|
||||
float rates[16], current = 0.0f;
|
||||
int count = host_xr_refresh_rates(rates, 16, ¤t);
|
||||
float ceiling = vr.refresh_rate_wanted > 1.0f ? vr.refresh_rate_wanted + 0.5f : 1e6f;
|
||||
int index;
|
||||
|
||||
vr.refresh_lower = vr.refresh_higher = 0.0f;
|
||||
if (current <= 1.0f)
|
||||
current = vr.refresh_rate_now;
|
||||
vr.refresh_period_ms = current > 1.0f ? 1000.0f / current : 0.0f;
|
||||
for (index = 0; index < count; index++)
|
||||
{
|
||||
if (rates[index] < current - 0.5f && rates[index] > vr.refresh_lower)
|
||||
vr.refresh_lower = rates[index];
|
||||
if (rates[index] > current + 0.5f && rates[index] <= ceiling &&
|
||||
(!vr.refresh_higher || rates[index] < vr.refresh_higher))
|
||||
vr.refresh_higher = rates[index];
|
||||
}
|
||||
}
|
||||
|
||||
static void refresh_step(float rate, unsigned long long now, const char *why)
|
||||
{
|
||||
platform_log("VR: refresh rate %.0f Hz (from %.0f): %s (%lu of %lu frames late, %lu too slow for the rate above)",
|
||||
rate, 1000.0f / vr.refresh_period_ms, why, vr.refresh_late, vr.refresh_frames, vr.refresh_tight);
|
||||
host_xr_set_refresh_rate(rate);
|
||||
vr.refresh_rate_now = rate;
|
||||
vr.refresh_changed_us = now;
|
||||
vr.refresh_bad_windows = vr.refresh_good_windows = 0;
|
||||
}
|
||||
|
||||
static void refresh_governor(void)
|
||||
{
|
||||
unsigned long long now = now_us();
|
||||
float work_ms = (float)(now - vr.frame_start_us) * 1e-3f;
|
||||
|
||||
if (!vr.refresh_auto)
|
||||
return;
|
||||
if (!vr.refresh_window_us)
|
||||
{
|
||||
refresh_rates_around();
|
||||
vr.refresh_window_us = now;
|
||||
vr.refresh_frames = vr.refresh_late = vr.refresh_tight = 0;
|
||||
return;
|
||||
}
|
||||
if (work_ms < REFRESH_LOAD_MS && vr.refresh_period_ms > 0.0f)
|
||||
{
|
||||
vr.refresh_frames++;
|
||||
if (work_ms + 1.0f > vr.refresh_period_ms)
|
||||
vr.refresh_late++;
|
||||
if (vr.refresh_higher > 1.0f && work_ms + 1.0f > 1000.0f / vr.refresh_higher)
|
||||
vr.refresh_tight++;
|
||||
}
|
||||
if (now - vr.refresh_window_us < REFRESH_WINDOW_US)
|
||||
return;
|
||||
if (vr.refresh_frames)
|
||||
{
|
||||
vr.refresh_bad_windows = vr.refresh_late * 32 > vr.refresh_frames ? vr.refresh_bad_windows + 1 : 0;
|
||||
vr.refresh_good_windows = vr.refresh_tight * 100 <= vr.refresh_frames ? vr.refresh_good_windows + 1 : 0;
|
||||
if (vr.refresh_lower > 1.0f && vr.refresh_bad_windows >= 2 && now - vr.refresh_changed_us >= REFRESH_SETTLE_US)
|
||||
refresh_step(vr.refresh_lower, now, "frames come late");
|
||||
else if (vr.refresh_higher > 1.0f && vr.refresh_good_windows >= 10 &&
|
||||
now - vr.refresh_changed_us >= REFRESH_RAISE_US)
|
||||
{
|
||||
refresh_step(vr.refresh_higher, now, "frames would keep up again");
|
||||
}
|
||||
}
|
||||
refresh_rates_around();
|
||||
vr.refresh_window_us = now;
|
||||
vr.refresh_frames = vr.refresh_late = vr.refresh_tight = 0;
|
||||
}
|
||||
|
||||
static void performance_report(int stereo)
|
||||
{
|
||||
unsigned long long now = now_us();
|
||||
@@ -1886,6 +1990,7 @@ void halo_vr_present(unsigned int back_buffer_texture)
|
||||
vr.last_overlay_mode = vr.overlay_mode;
|
||||
vr.last_frame_stereo = submit.eyes;
|
||||
vr.frame_begun = 0;
|
||||
refresh_governor();
|
||||
if (vr.log_performance)
|
||||
performance_report(submit.eyes);
|
||||
}
|
||||
@@ -2053,7 +2158,7 @@ float halo_vr_refresh_rate(void)
|
||||
return vr.active ? vr.info.refresh_rate : 0.0f;
|
||||
}
|
||||
|
||||
/* ---------- both eyes at once (vr.multiview)
|
||||
/* ---------- both eyes at once (debug.multiview)
|
||||
|
||||
The scene is drawn once, from a camera between the eyes whose view takes in
|
||||
both eyes' (with a margin, for what is near: the eyes stand apart), into two
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7468,6 +7468,7 @@ enum
|
||||
_vr_option_graphics,
|
||||
_vr_option_foveation,
|
||||
_vr_option_refresh_rate,
|
||||
_vr_option_refresh_auto,
|
||||
_vr_option_resolution,
|
||||
_vr_option_controls,
|
||||
_vr_option_quit,
|
||||
@@ -7521,8 +7522,8 @@ static short const vr_option_pages[NUMBER_OF_VR_OPTION_PAGES][VR_OPTIONS_PER_PAG
|
||||
_vr_option_button_first + HALO_VR_ACTION_CROUCH, _vr_option_button_first + HALO_VR_ACTION_ZOOM,
|
||||
_vr_option_button_first + HALO_VR_ACTION_MELEE, _vr_option_button_first + HALO_VR_ACTION_TALK,
|
||||
_vr_option_buttons_reset, _vr_option_controls, NONE, NONE },
|
||||
{ _vr_option_graphics, _vr_option_foveation, _vr_option_refresh_rate, _vr_option_resolution, _vr_option_quit,
|
||||
NONE, NONE, NONE, NONE, NONE, NONE, NONE },
|
||||
{ _vr_option_graphics, _vr_option_foveation, _vr_option_refresh_rate, _vr_option_refresh_auto,
|
||||
_vr_option_resolution, _vr_option_quit, NONE, NONE, NONE, NONE, NONE, NONE },
|
||||
};
|
||||
|
||||
/* the page a row is on, and its place there */
|
||||
@@ -7942,6 +7943,9 @@ static void ui_vr_options_change(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case _vr_option_refresh_auto:
|
||||
config_write_boolean("vr.refresh_rate_auto", !config_boolean("vr.refresh_rate_auto"));
|
||||
break;
|
||||
case _vr_option_resolution:
|
||||
ui_vr_options_step("vr.resolution_scale", vr_resolution_scales, NUMBEROF(vr_resolution_scales), change);
|
||||
break;
|
||||
@@ -8651,7 +8655,10 @@ static void ui_vr_options_render(
|
||||
short count = ui_vr_options_refresh_rates(rates, &chosen, ¤t);
|
||||
|
||||
label = "Refresh rate";
|
||||
if (count > 0)
|
||||
/* (lowered while frames come late: vr.refresh_rate_auto) */
|
||||
if (count > 0 && current > 1.0f && current < rates[chosen] - 0.5f)
|
||||
snprintf(value, sizeof(value), "%.0f Hz (now %.0f)", rates[chosen], current);
|
||||
else if (count > 0)
|
||||
snprintf(value, sizeof(value), "%.0f Hz", rates[chosen]);
|
||||
else if (current > 1.0f)
|
||||
snprintf(value, sizeof(value), "%.0f Hz (system)", current);
|
||||
@@ -8660,6 +8667,17 @@ static void ui_vr_options_render(
|
||||
unused = count <= 1;
|
||||
break;
|
||||
}
|
||||
case _vr_option_refresh_auto:
|
||||
{
|
||||
/* (dim where the headset offers one rate: SteamVR's) */
|
||||
real rates[16], current;
|
||||
short chosen;
|
||||
|
||||
label = "Lower it when frames are late";
|
||||
snprintf(value, sizeof(value), "%s", config_boolean("vr.refresh_rate_auto") ? "On" : "Off");
|
||||
unused = ui_vr_options_refresh_rates(rates, &chosen, ¤t) <= 1;
|
||||
break;
|
||||
}
|
||||
case _vr_option_resolution:
|
||||
{
|
||||
real scale = vr_resolution_scales[ui_vr_options_nearest((real)config_real("vr.resolution_scale"),
|
||||
@@ -8717,12 +8735,11 @@ static void ui_vr_options_render(
|
||||
}
|
||||
if (page == _vr_options_page_buttons)
|
||||
{
|
||||
/* (what the buttons' rows do) */
|
||||
ui_join_code_draw_text(128, 512, 302, "An action moved to a button swaps with the one there;", FALSE, 0,
|
||||
/* (what the buttons' rows do: below the page's ten rows) */
|
||||
ui_join_code_draw_text(128, 512, 320, "An action moved to a button swaps with the one there;", FALSE, 0,
|
||||
&dim_color);
|
||||
ui_join_code_draw_text(128, 512, 320, "left-handed and mirrored, the buttons are mirrored too.", FALSE, 0,
|
||||
ui_join_code_draw_text(128, 512, 338, "mirrored, the buttons are too. The menus keep theirs.", FALSE, 0,
|
||||
&dim_color);
|
||||
ui_join_code_draw_text(128, 512, 338, "The menus keep their buttons.", FALSE, 0, &dim_color);
|
||||
}
|
||||
if (page == _vr_options_page_more)
|
||||
{
|
||||
@@ -8739,6 +8756,8 @@ static void ui_vr_options_render(
|
||||
&dim_color);
|
||||
ui_join_code_draw_text(128, 512, 320, "Foveation draws the view's edges coarser (Quest only).", FALSE, 0,
|
||||
&dim_color);
|
||||
ui_join_code_draw_text(128, 512, 338, "Late frames: the rate steps down (90, 80, 72) while they last.", FALSE, 0,
|
||||
&dim_color);
|
||||
}
|
||||
ui_join_code_draw_text(126, 514, VR_OPTIONS_FOOTER + 8, "%a-button Change %b-button Done", TRUE, 0, &text_color);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ Usage: android_asm_convert.py input.s output.s
|
||||
import re
|
||||
import sys
|
||||
|
||||
IDENT = re.compile(r'"[^"]*"|[A-Za-z_.$][\w.$]*')
|
||||
# a quoted name (its escapes kept: \" does not end it) or a plain one
|
||||
IDENT = re.compile(r'"(?:[^"\\]|\\.)*"|[A-Za-z_.$][\w.$]*')
|
||||
|
||||
# directives whose operands are string literals, the same in both
|
||||
# syntaxes: they go through as they are, never renamed as symbols (data
|
||||
# starting with "_" would lose that byte)
|
||||
STRING_DIRECTIVES = {".ascii", ".asciz", ".string"}
|
||||
|
||||
DROP_DIRECTIVES = {
|
||||
".subsections_via_symbols", ".build_version", ".watchos_version_min",
|
||||
@@ -217,10 +223,7 @@ class Converter:
|
||||
if op == ".align":
|
||||
self.emit(f"\t.p2align {args}")
|
||||
return
|
||||
if op in (".ascii", ".asciz", ".string"):
|
||||
# string data, kept as it is: no symbol names in it (renamed, a
|
||||
# "_" at the start of a word was dropped: a string with \" in it
|
||||
# went wrong, Opus's tables among them)
|
||||
if op in STRING_DIRECTIVES:
|
||||
self.emit(f"\t{op} {args}")
|
||||
return
|
||||
if op.startswith("."):
|
||||
|
||||
@@ -22,7 +22,7 @@ no files beside its guest image.
|
||||
|
||||
The data are 32-bit words, not bytes: the Android build passes the guest's
|
||||
assembly through tools/android_asm_convert.py, which rewrites identifiers in
|
||||
operands and would garble a long .ascii string of binary data.
|
||||
operands (it keeps .ascii strings as they are, but once garbled them).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
@@ -618,6 +618,11 @@ MAP_KIND_CHOOSER = "main_menu/new_select/list_item_0_map_kind"
|
||||
# changes to the PC version's widgets (by our names): attributes set, all
|
||||
# their handlers replaced, children added, game data inputs added
|
||||
WIDGET_PATCHES = {
|
||||
# The host returns here after a round with no menu history. Back must
|
||||
# reopen the main menu when it closes the last screen, as the lobby does.
|
||||
f"{MT}/connected/connected_map_select_wrapper": {"set": {
|
||||
"flags": "pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse",
|
||||
}},
|
||||
# (the profile settings' picture: on Gamepad Setup's row, the profile's
|
||||
# button settings, BITMAP_FRAMES; menu_functions.c's
|
||||
# profile_gamepad_layout)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,32 +1,76 @@
|
||||
"""Tests of tools/android_asm_convert.py, the Mach-O to ELF assembly
|
||||
conversion of the Android build's guest code."""
|
||||
"""The Darwin to ELF assembly conversion of the ILP32 guest
|
||||
(tools/android_asm_convert.py): string literals keep every byte. It once took
|
||||
a quoted .ascii operand for a quoted symbol name and dropped its leading "_"
|
||||
(and, after an escaped quote, the "_" of any word after it), which shifted
|
||||
tables such as Opus's pulse cache by a byte: voice chat crashed in
|
||||
decode_pulses on the arm64 and Android builds."""
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import pytest
|
||||
|
||||
from android_asm_convert import Converter # noqa: E402
|
||||
from tools.android_asm_convert import Converter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def convert(*lines):
|
||||
return Converter(list(lines)).run().split("\n")
|
||||
return Converter(list(lines)).run().splitlines()
|
||||
|
||||
|
||||
def test_symbols_lose_their_underscore():
|
||||
out = convert("_main:", "\tbl\t_foo", "\t.long\t_bar")
|
||||
assert out[:3] == ["main:", "\tbl foo", "\t.long bar"]
|
||||
@pytest.mark.parametrize("directive", [
|
||||
'.ascii\t"_c[}]L{s{"',
|
||||
'.asciz\t"_transport_error_none"',
|
||||
'.ascii\t"v\\177\\"O7v\\177_\\037\\374"',
|
||||
'.ascii\t"=`\'\\\\_\\026"',
|
||||
'.asciz\t"ltmp0 _x @PAGE ; not a comment"',
|
||||
])
|
||||
def test_string_literals_are_kept(directive):
|
||||
op, args = directive.split("\t", 1)
|
||||
assert convert("l_.str:", directive) == [".Ll_.str:", f"\t{op} {args}"]
|
||||
|
||||
|
||||
def test_strings_are_kept():
|
||||
# (a string starting with "_", or one with \" in it, lost a "_": Opus's
|
||||
# pulse cache was one byte short, and its encoder crashed)
|
||||
out = convert(
|
||||
'\t.asciz\t"_lan_"',
|
||||
'\t.ascii\t"\\"$&\'\\0063_\\206"',
|
||||
'\t.string\t"l_.str _foo"',
|
||||
)
|
||||
assert out[:3] == [
|
||||
'\t.asciz "_lan_"',
|
||||
'\t.ascii "\\"$&\'\\0063_\\206"',
|
||||
'\t.string "l_.str _foo"',
|
||||
]
|
||||
def test_symbols_are_still_renamed():
|
||||
assert convert("\t.globl\t_table", "_table:", "\tadrp\tx8, _table@PAGE",
|
||||
"\tadd\tx8, x8, _table@PAGEOFF", '\t.long\t"_odd name"') == [
|
||||
"\t.globl table", "table:", "\tadrp x8, table", "\tadd x8, x8, :lo12:table", '\t.long "odd name"']
|
||||
|
||||
|
||||
def rodata(elf: bytes) -> bytes:
|
||||
"""the .rodata section of a 64-bit little-endian ELF object"""
|
||||
shoff, = struct.unpack_from("<Q", elf, 0x28)
|
||||
shentsize, shnum, shstrndx = struct.unpack_from("<HHH", elf, 0x3a)
|
||||
sections = [struct.unpack_from("<IIQQQQ", elf, shoff + index * shentsize) for index in range(shnum)]
|
||||
names = sections[shstrndx][4]
|
||||
for name, _, _, _, offset, size in sections:
|
||||
if elf[names + name:elf.index(b"\0", names + name)] == b".rodata":
|
||||
return elf[offset:offset + size]
|
||||
raise AssertionError("no .rodata")
|
||||
|
||||
|
||||
def test_tables_survive_the_build(tmp_path):
|
||||
"""every byte value, and the patterns that were lost, through the
|
||||
guest's pipeline (clang for arm64_32, the conversion, the assembler)"""
|
||||
clang = shutil.which("clang")
|
||||
if not clang:
|
||||
pytest.skip("no clang")
|
||||
table = bytes(range(256)) + b'_a"_b\\_c' + bytes(reversed(range(256)))
|
||||
source = tmp_path / "table.c"
|
||||
source.write_text(
|
||||
"const unsigned char table[] = {" + ",".join(str(b) for b in table) + "};\n"
|
||||
'const char text[] = "_starts with an underscore";\n')
|
||||
darwin, converted, obj = tmp_path / "table.darwin.s", tmp_path / "table.s", tmp_path / "table.o"
|
||||
try:
|
||||
subprocess.run([clang, "--target=arm64_32-apple-watchos", "-O2", "-S", str(source), "-o", str(darwin)],
|
||||
check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
pytest.skip("clang has no arm64_32 target")
|
||||
subprocess.run([sys.executable, str(ROOT / "tools" / "android_asm_convert.py"), str(darwin), str(converted)],
|
||||
check=True)
|
||||
subprocess.run([clang, "--target=aarch64-linux-gnu", "-c", str(converted), "-o", str(obj)], check=True)
|
||||
data = rodata(obj.read_bytes())
|
||||
assert table in data
|
||||
assert b"_starts with an underscore\0" in data
|
||||
Reference in new issue
Block a user