Compare commits

..
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 06cb70fb58 Steam Frame: notes, and no variable the Android loader never reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:27:28 +02:00
Stefan MaierandClaude Opus 5.5 1709698053 Start on the Steam Frame (Lepton)
Lepton has no clipboard service, and SDL3's activity died taking one for
granted: the build now patches SDL's clipboard handler to do without.
The Frame lists an implicit OpenXR layer for Vulkan apps whose library an
app cannot load, which failed xrCreateInstance (and the Android loader
reads no variable to turn it off): the app now carries a pass-through
layer under that library's name. With both, the game runs on the Frame
and its OpenXR session starts (SteamVR, 1728x1728 an eye, 72 Hz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:26:39 +02:00
5 changed files with 170 additions and 28 deletions

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
# Steam Frame
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.
## The device
- Steam Settings > System > Enable Developer Mode; then start "Lepton
Development" from the library. While the headset sleeps Lepton stops, and
adb with it.
- adb over the network: `adb connect <frame-ip>:5555`; over USB:
`adb forward tcp:5555 tcp:5555` then `adb connect localhost:5555`.
Always name the device (`adb -s <frame-ip>:5555 ...`): a Quest may be
connected as well.
- Install with `adb install -r --no-streaming`. It prints "Lepton post
installation failed due to APP_PATH being unset", but installs. Start the
game with `am start -n com.halo.decomp/.LauncherActivity`.
- Lepton empties `/sdcard/Android/data` whenever it starts again: the game's
data must live elsewhere (stage 2). `/sdcard/Documents` and
`/sdcard/Download` are the SteamOS user's folders and are kept.
## What the port does for it
- SDL3's clipboard handler takes a clipboard service for granted, which
Lepton has not: `tools/android_build.py` patches it to do without.
- SteamVR lists an implicit OpenXR layer for Vulkan apps whose library an
app cannot load: the APK carries a pass-through layer under its name
(`port/android/xr_layer/fdm_injection_stub.c`).
## Stages
| # | Stage | State |
| --- | --- | --- |
| 1 | Base: the fixes above on the current main | in progress |
| 2 | The game's data where Lepton keeps it | |
| 3 | Menus, HUD and text visible (quad layers) | |
| 4 | The freeze at the cryo bay's calibration | |
| 5 | The Frame's controllers | |
| 6 | Frame rate, resolution | |
| 7 | Multiplayer, voice, cross-play with the Quest | |
| 8 | Installing it, for others | |
## Findings
- 2026-10-02: runs flat and in VR (GLES 3.2 by Mesa zink on Turnip, eyes
1728x1728, 72 Hz, XR_FB_display_refresh_rate absent). Quad layers
(menus, HUD, instructions) are not shown; the projection layer is. Froze
at the cryo bay's look calibration; its log was lost to Lepton's wipe.
+6
View File
@@ -895,6 +895,12 @@ int host_xr_probe(float resolution_scale, int *eye_width, int *eye_height)
/* the loader and the runtime keep the activity */
activity = (*environment)->NewGlobalRef(environment, activity);
/* (The Steam Frame lists an implicit API layer of its own for Vulkan
apps, XR_APILAYER_VALVE_fdm_injection, whose library an app cannot load
from its namespace, failing xrCreateInstance; the Android loader reads
no variable to turn it off, so the app carries a pass-through layer
under that library's name: port/android/xr_layer/fdm_injection_stub.c) */
if (!XR_SUCCEEDED(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
(PFN_xrVoidFunction *)&initialize_loader)) || !initialize_loader)
{
@@ -0,0 +1,62 @@
/*
FDM_INJECTION_STUB.C
An OpenXR API layer that does nothing, named as the Steam Frame's
XR_APILAYER_VALVE_fdm_injection names its library
(libVkLayer_VALVE_fdm_injection.so). The Frame lists that layer as implicit
for every OpenXR app (/vendor/etc/openxr/1/api_layers/implicit.d), but an
Android app (in Lepton) cannot load the system's library from its own
namespace, and the Khronos loader then fails xrCreateInstance altogether;
nor does the Android loader read the variable that would turn the layer
off. With this one in the app's libraries the loader finds a layer of that
name, which hands every call on to the next. (The real layer adds fragment
density maps to Vulkan apps; this game draws with OpenGL ES.) On a Quest no
manifest names it and it is never loaded.
*/
#include <openxr/openxr.h>
#include <openxr/openxr_loader_negotiation.h>
#include <stddef.h>
static PFN_xrGetInstanceProcAddr next_get_instance_proc_addr;
static XRAPI_ATTR XrResult XRAPI_CALL layer_get_instance_proc_addr(XrInstance instance, const char *name,
PFN_xrVoidFunction *function)
{
if (!next_get_instance_proc_addr)
return XR_ERROR_HANDLE_INVALID;
return next_get_instance_proc_addr(instance, name, function);
}
static XRAPI_ATTR XrResult XRAPI_CALL layer_create_api_layer_instance(const XrInstanceCreateInfo *info,
const XrApiLayerCreateInfo *layer_info, XrInstance *instance)
{
XrApiLayerCreateInfo next_layer_info;
if (!layer_info || !layer_info->nextInfo)
return XR_ERROR_INITIALIZATION_FAILED;
next_get_instance_proc_addr = layer_info->nextInfo->nextGetInstanceProcAddr;
next_layer_info = *layer_info;
next_layer_info.nextInfo = layer_info->nextInfo->next;
return layer_info->nextInfo->nextCreateApiLayerInstance(info, &next_layer_info, instance);
}
__attribute__((visibility("default")))
XRAPI_ATTR XrResult XRAPI_CALL xrNegotiateLoaderApiLayerInterface(const XrNegotiateLoaderInfo *loader_info,
const char *layer_name, XrNegotiateApiLayerRequest *request)
{
(void)layer_name;
if (!loader_info || !request || loader_info->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
request->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST ||
loader_info->minInterfaceVersion > XR_CURRENT_LOADER_API_LAYER_VERSION ||
loader_info->maxInterfaceVersion < XR_CURRENT_LOADER_API_LAYER_VERSION)
{
return XR_ERROR_INITIALIZATION_FAILED;
}
request->layerInterfaceVersion = XR_CURRENT_LOADER_API_LAYER_VERSION;
request->layerApiVersion = loader_info->maxApiVersion < XR_CURRENT_API_VERSION ? loader_info->maxApiVersion :
XR_CURRENT_API_VERSION;
request->getInstanceProcAddr = layer_get_instance_proc_addr;
request->createApiLayerInstance = layer_create_api_layer_instance;
return XR_SUCCESS;
}
-26
View File
@@ -231,7 +231,6 @@ symbols in this file:
#include "units/units.h"
#ifdef HALO_ANDROID
#include "halo_vr.h"
#include "physics/collisions.h"
#endif
/* port/linux/game/pal_tags.c's */
@@ -2206,10 +2205,6 @@ static void trigger_create_projectiles(
long target_object_index= NONE;
long projectile_definition_index;
short projectile_count;
#ifdef HALO_ANDROID
/* VR: fired from the muzzle of the weapon in the hand */
boolean vr_from_muzzle= FALSE;
#endif
if (!TEST_FLAG(trigger_definition->flags, _weapon_trigger_projectiles_cannot_be_aimed_bit) &&
unit &&
@@ -2287,7 +2282,6 @@ static void trigger_create_projectiles(
origin.x = vr_origin[0];
origin.y = vr_origin[1];
origin.z = vr_origin[2];
vr_from_muzzle = TRUE;
}
}
#endif
@@ -2389,26 +2383,6 @@ static void trigger_create_projectiles(
real_point3d camera_position;
unit_get_camera_position(owner_object_index, &camera_position);
#ifdef HALO_ANDROID
if (vr_from_muzzle)
{
/* VR: from the eyes down to the hand the line runs through
the player's own body, which the game's test hits, moving the
shot up towards the eyes (it then flies parallel to the aim,
above it): only the level stops it, a hand through a wall */
struct collision_result collision;
struct object_datum *projectile_object= object_get(projectile_object_index);
if (collision_test_line(FLAG(_collision_test_structure_bit) | FLAG(_collision_test_front_facing_surfaces_bit),
&camera_position, &projectile_object->object.position, owner_object_index, &collision) &&
collision.location.cluster_index!=NONE)
{
object_translate(projectile_object_index, &collision.point, &collision.location);
object_compute_node_matrices(projectile_object_index);
}
}
else
#endif
object_force_inside_bsp(projectile_object_index, &camera_position);
}
if (target_object_index!=NONE)
+53 -2
View File
@@ -169,6 +169,45 @@ def _find_ndk() -> Optional[Path]:
return None
# SDL3's Java takes the clipboard service for granted: Lepton, the Steam
# Frame's Android, has none, and the activity died starting. These edits let
# it do without one (the clipboard is then empty and keeps nothing).
SDL_JAVA_PATCHES = [
(" mClipMgr = (ClipboardManager) SDL.getContext().getSystemService(Context.CLIPBOARD_SERVICE);\n"
" mClipMgr.addPrimaryClipChangedListener(this);\n",
" mClipMgr = (ClipboardManager) SDL.getContext().getSystemService(Context.CLIPBOARD_SERVICE);\n"
" if (mClipMgr != null) /* halo-ce-vr: none in Lepton */\n"
" mClipMgr.addPrimaryClipChangedListener(this);\n"),
(" public boolean clipboardHasText() {\n",
" public boolean clipboardHasText() {\n"
" if (mClipMgr == null) /* halo-ce-vr */\n"
" return false;\n"),
(" public String clipboardGetText() {\n",
" public String clipboardGetText() {\n"
" if (mClipMgr == null) /* halo-ce-vr */\n"
" return null;\n"),
(" public void clipboardSetText(String string) {\n",
" public void clipboardSetText(String string) {\n"
" if (mClipMgr == null) /* halo-ce-vr */\n"
" return;\n"),
]
def patch_sdl_java() -> None:
"""SDL_JAVA_PATCHES, once, into the cloned SDL3's Java."""
path = SDL_DIR / "android-project" / "app" / "src" / "main" / "java" / "org" / "libsdl" / "app" / "SDLActivity.java"
if not path.is_file():
return
text = path.read_text()
if "halo-ce-vr: none in Lepton" in text:
return
for old, new in SDL_JAVA_PATCHES:
if text.count(old) != 1:
raise SystemExit(f"cannot patch {path}: SDL3's clipboard handler has changed")
text = text.replace(old, new)
path.write_text(text)
def fetch_third_party() -> None:
"""Download musl and SDL3 (configure time, once)."""
THIRD_PARTY.mkdir(parents=True, exist_ok=True)
@@ -182,6 +221,7 @@ def fetch_third_party() -> None:
print(f"Cloning SDL3 {SDL_TAG}")
subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", SDL_TAG, SDL_URL, str(SDL_DIR)],
check=True)
patch_sdl_java()
if not OPENXR_DIR.is_dir():
print(f"Downloading {OPENXR_URL}")
import zipfile
@@ -571,6 +611,17 @@ def generate_android_build(n: Writer, sln: Any) -> None:
)
n.build(outputs=libmain, rule="android_host_link", inputs=host_objects, implicit=[libsdl])
# a do-nothing OpenXR layer under the Steam Frame's implicit layer's
# library name (port/android/xr_layer/fdm_injection_stub.c)
fdm_stub = jni_dir / "libVkLayer_VALVE_fdm_injection.so"
n.rule(
name="android_xr_layer",
command=(f"$android_host_cc -O2 -fPIC -shared -fvisibility=hidden -I{OPENXR_DIR}/include -o $out $in "
"-Wl,-z,max-page-size=16384"),
description="ANDROID XR LAYER $out",
)
n.build(outputs=fdm_stub, rule="android_xr_layer", inputs=PORT_DIR / "xr_layer" / "fdm_injection_stub.c")
# ---------- staging for Gradle
staged_sdl = jni_dir / "libSDL3.so"
@@ -578,7 +629,7 @@ def generate_android_build(n: Writer, sln: Any) -> None:
n.rule(name="android_copy", command="cp $in $out", description="ANDROID STAGE $out")
n.build(outputs=staged_sdl, rule="android_copy", inputs=libsdl)
n.build(outputs=staged_image, rule="android_copy", inputs=image)
n.build(outputs="android", rule="phony", inputs=[libmain, staged_sdl, staged_image])
n.build(outputs="android", rule="phony", inputs=[libmain, staged_sdl, staged_image, fdm_stub])
apk = PORT_DIR / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
n.rule(
@@ -589,6 +640,6 @@ def generate_android_build(n: Writer, sln: Any) -> None:
description="ANDROID GRADLE $out",
pool="console",
)
n.build(outputs=apk, rule="android_gradle", inputs=[libmain, staged_sdl, staged_image])
n.build(outputs=apk, rule="android_gradle", inputs=[libmain, staged_sdl, staged_image, fdm_stub])
n.build(outputs="android_apk", rule="phony", inputs=apk)
n.newline()