Add controller input and a laser-pointer-driven menu quad
build / build (push) Successful in 2m26s

- xr_input.c/h: an OpenXR action set (aim pose, trigger/select click, a
  menu-toggle button) plus ray/quad hit-testing, and a visible
  laser-pointer line drawn from the controller toward the hit point.
- xr_menu.c/h + MenuOverlay.java: a second composition-layer quad,
  toggled by a controller button, showing an off-screen, never-attached
  Android View tree (one "Keyboard" button so far) driven by synthetic
  touch events computed from the laser ray's hit UV.
- Migrate gl4es from a prebuilt binary baked into the Docker build-image
  to a vendored source snapshot (android/gl4es-src/) compiled from
  source at APK build time via CMake add_subdirectory(), patched through
  a new android/gl4es-patches/ (mirrors the existing engine-patches/
  pattern). This was needed to track down and fix a bug hit while
  building the menu: gl4es's fixed-pipeline emulation (fpe.c) was
  unconditionally substituting its own shader onto the menu's draw call
  (fpe_ReleventState() always sets alphafunc to a nonzero sentinel, so
  its fpe_IsEmpty() check could never see the state as empty), making
  the menu quad show the game's own rendering instead of its own
  content. Fixed by routing the menu's blit through a real
  glBlitFramebuffer() call instead of a shader-based draw, which gl4es's
  fpe.c has nothing to intercept - documented in README.md's new
  "Debugging notes" section.
- Renumber android/engine-patches/ to close the gap left by removing an
  unrelated diagnostic-only patch, and strip investigation-journal
  comments and dead diagnostic code (temporary tracing, env-var probes)
  left over from finding the bug above.
- Restructure README.md into numbered sections, document every
  engine/gl4es patch, add Android Studio dev/testing-cycle instructions,
  and generalize Quest-specific wording to any OpenXR headset. Update
  NOTICE.txt to match (gl4es is now vendored/patched source, not a
  prebuilt binary; add the OpenXR-SDK loader).
This commit is contained in:
ml
2026-08-02 06:27:49 +02:00
parent 2300d081c3
commit d2dc58391b
310 changed files with 141458 additions and 226 deletions
@@ -0,0 +1,132 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.SystemClock;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
import java.nio.ByteBuffer;
/**
* An off-screen, never-attached-to-a-window View tree for questshock's
* OpenXR menu quad (see android/app/src/main/cpp/xr_menu.c, which owns the
* quad's swapchain and drives this class entirely via JNI - construction,
* touch input, and pixel readback). Rendering to a Bitmap and dispatching
* synthetic MotionEvents into an unattached hierarchy both work the same
* way they would for an attached View - draw(Canvas)/dispatchTouchEvent()
* don't require a ViewRootImpl/window, just a measured+laid-out tree.
*/
public class MenuOverlay {
private static final String TAG = "QuestShock";
// Matches xr_menu.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
// not tied to any real display metric, since this tree is never shown
// in a real window.
static final int WIDTH = 1024;
static final int HEIGHT = 768;
private static MenuOverlay instance;
private final Activity activity;
private final FrameLayout root;
private final Button keyboardButton;
private final Bitmap bitmap;
private final Canvas canvas;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
private MenuOverlay(Activity activity) {
this.activity = activity;
root = new FrameLayout(activity);
root.setBackgroundColor(0xFF202020);
keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> Log.i(TAG, "MenuOverlay: Keyboard button clicked"));
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
lp.gravity = Gravity.CENTER;
root.addView(keyboardButton, lp);
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, WIDTH, HEIGHT);
forceRedraw();
}
// Called from xr_menu.c's xr_menu_init(), on the render thread, right
// after it resolves this class via the activity's own ClassLoader
// (plain FindClass() can't see app classes from a thread that was
// attached to the JVM rather than spawned from Java - see that file's
// xr_menu_find_class()).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new MenuOverlay(activity);
}
}
// Polled once per frame while the menu quad is visible (see
// xr_menu_render_if_visible()) - returns the current pixels only once
// per redraw (null otherwise), so the native side knows when it can
// skip re-uploading a texture that hasn't actually changed.
public static byte[] nativeTakePixelsIfDirty() {
if (instance == null) {
return null;
}
synchronized (instance.pixelLock) {
byte[] pixels = instance.pendingPixels;
instance.pendingPixels = null;
return pixels;
}
}
// u/v are the menu quad's own hit-test coordinates (0..1, top-left
// origin) - computed by xr_input.c's ray/quad intersection against
// xr_menu.c's reported quad geometry, forwarded here as a synthetic
// tap. Runs on the UI thread since the View/Bitmap/Canvas objects here
// are otherwise only ever touched from there.
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
}
private void handleTouch(float u, float v, boolean down) {
float x = u * WIDTH;
float y = v * HEIGHT;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
forceRedraw();
}
private void forceRedraw() {
canvas.drawColor(0xFF202020);
root.draw(canvas);
byte[] pixels = new byte[WIDTH * HEIGHT * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
}
}
}
@@ -47,7 +47,7 @@ public class QuestShockActivity extends SDLActivity {
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
// purely to get nativeChdir() below - Android's dynamic linker resolves
// "main"'s own dependencies (SDL2, SDL2_mixer, fluidsynth) from the
// APK's native library directory regardless of Java-side load order, so
// APK's native library directory regardless of Java-side load order, sox
// loading it early here is safe. SDLActivity loading "main" again later
// is a harmless no-op (System.loadLibrary is idempotent per
// ClassLoader).
@@ -74,11 +74,9 @@ public class QuestShockActivity extends SDLActivity {
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
// first. Since surfaceChanged() fires on essentially every launch
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
// setUpGameDirAndContinue()) does NOT actually stop the engine from
// starting - confirmed on-device: the missing-assets crash still happened
// with mBrokenLibraries set, from exactly this path. Route through a
// subclass that adds the missing check instead of patching the vendored
// file directly.
// setUpGameDirAndContinue()) does not actually stop the engine from
// starting. Route through a subclass that adds the missing check instead
// of patching the vendored file directly.
private static class GameSurface extends SDLSurface {
GameSurface(Context context) {
super(context);
@@ -91,20 +89,6 @@ public class QuestShockActivity extends SDLActivity {
}
super.surfaceChanged(holder, format, width, height);
}
// Diagnostic only (see OpenGL.cc's matching log) - Android's real,
// legitimate signal for "this View's laid-out size actually
// changed" is onSizeChanged(), fired by the framework itself, not
// something we have to poll or guess about. If Quest's Home shell
// settles a freshly-launched panel into its final <layout> size via
// a genuine later layout pass, this is where that would show up -
// confirming whether a real event exists to hook, before writing
// any fix around it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.i(TAG, "GameSurface.onSizeChanged() " + oldw + "x" + oldh + " -> " + w + "x" + h);
}
}
@Override