Files
mitch030504--AstroQuest-Frame/quest-host/cpp/main.cpp
T
bigmak94andClaude Opus 5.5 9a2b8b8178 The touchpad's gestures on buttons, and a stick's finger the game can follow
The first worn try of the Quest app with the headset's own controllers ended at the
catapult of the first level: the right stick pulled back and let go did not shoot it. And
nothing told the player that the pad is pressed (the water cannon) by pressing that stick in.

Why the catapult failed: the game reads the pad once for every frame it draws (the newest of
the states it is handed), and takes the place it first finds the finger at for the start of
a pull. A stick is at its end within a few hundredths of a second, so the finger that
followed it there had, for the game, begun where it ended. By a model of the game's rule
(tools/tests/touch_game_model.h: read at 0xc04cc3, y = 1 - raw / 950, the catapult of
0x436fc0 wants more than 0.25 s and more than 0.4 of the pad) one fast pull in four failed
at 60 frames a second and two in three at 30, which is what the reports of Issue #2 say and
what the headset draws.

- Input::MovedFinger: a finger that stays where it lands until the game has seen it there,
  goes no faster than a finger flicks and counts as arrived once it was seen at its end.
- Input::StickFinger is built on it: the finger comes down behind the pad's middle, on the
  side the stick comes from, and is dragged to where the stick points.
- Input::PadGestures: what the game asks of the pad, done by buttons it has no use for in
  play (it only asks about them in a few menus, and is still told of them): the pad pressed
  while a button is held, a swipe forward for each press of another, a pull back that is
  held and let go with a third. On the headset's own controllers the trigger and the grip of
  the hand that holds the controller in the game and the other hand's trigger; on a gamepad
  without a touchpad R2, R1 and L2. In the Quest app's router, the PC host's code for the
  headset's controllers and the PC's gamepads.
- The Quest app shows a card over the game that names those buttons, when the game starts
  and whenever OPTIONS is pressed (the game's pause).
- SHADPS4_PAD_TOUCH_TRACE=1 has the log say what the game was given of the touchpad at each
  of its reads; input scripts can press the gesture buttons (gesture=press|swipe|pull); the
  Quest app's dry start pulls the right stick and presses R1.

Tried: the tests (stick_finger_test and pad_gestures_test against the model at 26 to 60
frames a second, pad_router_test); with the trace, in the game on the PC (62 reads a
second) and on a Quest 3 without wearing it (48 in its sandbox, 36 through the whole app):
a pull is given as 0.74 to 0.76 of the pad over 0.34 s and more, a swipe as 0.67 over a
dozen reads, a press for as long as it is held. Not tried: the catapult itself going off
(a script did not get through the level to it), and all of it worn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:42:32 +02:00

366 lines
15 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
// JNI surface of com.astrobotquest.vrhost.MainActivity. The activity owns the lifecycle and
// everything that is easier in Java (controller events, drawing text, unpacking the runtime);
// this side owns the OpenXR session and the emulator process.
#include <algorithm>
#include <atomic>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <jni.h>
#include <sys/resource.h>
#include "core_process.h"
#include "log.h"
#include "pad_router.h"
#include "xr_host.h"
namespace {
struct App {
JavaVM* vm{};
jobject activity{};
CoreProcess core;
StatusImage status;
XrHostStatus xr_status;
std::thread xr_thread;
std::atomic<bool> quit{};
std::atomic<uint32_t> recenter_requests{};
/// What the game is played with: a gamepad the activity hears of, the headset's own
/// controllers the session reads, or both.
PadRouter pads;
};
std::unique_ptr<App> g_app;
std::string ToString(JNIEnv* env, jstring text) {
if (text == nullptr) {
return {};
}
const char* chars = env->GetStringUTFChars(text, nullptr);
std::string result{chars != nullptr ? chars : ""};
env->ReleaseStringUTFChars(text, chars);
return result;
}
std::vector<std::string> ToStrings(JNIEnv* env, jobjectArray array) {
std::vector<std::string> result;
const jsize count = array != nullptr ? env->GetArrayLength(array) : 0;
for (jsize i = 0; i < count; ++i) {
const auto element = static_cast<jstring>(env->GetObjectArrayElement(array, i));
result.push_back(ToString(env, element));
env->DeleteLocalRef(element);
}
return result;
}
} // namespace
extern "C" {
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStartXr(
JNIEnv* env, jobject activity, jfloat refresh_rate, jint eye_width, jint eye_height,
jboolean track_hands, jint sharpen, jboolean cubic, jboolean show_stats, jfloat predict_ms,
jint dynamic_resolution, jboolean cpu_boost, jboolean own_controllers, jint pad_hand,
jfloat pad_tilt, jboolean rumble, jboolean stick_touchpad) {
if (g_app) {
return;
}
g_app = std::make_unique<App>();
env->GetJavaVM(&g_app->vm);
g_app->activity = env->NewGlobalRef(activity);
g_app->pads.SetStickTouchpad(stick_touchpad == JNI_TRUE);
g_app->pads.SetPadHand(pad_hand);
g_app->pads.SetNotice([](const char* text) { LOGI("%s", text); });
g_app->pads.SetSink([app = g_app.get()](const PadRouter::Output& output) {
app->core.SetPad(output.pad);
app->core.SetBlowing(output.blowing);
});
XrHostOptions options;
options.refresh_rate = refresh_rate;
options.eye_width = static_cast<uint32_t>(eye_width);
options.eye_height = static_cast<uint32_t>(eye_height);
options.track_hands = track_hands == JNI_TRUE;
options.sharpen = sharpen;
options.cubic = cubic == JNI_TRUE;
options.show_stats = show_stats == JNI_TRUE;
options.predict_ms = predict_ms;
options.dynamic_resolution = dynamic_resolution != 0;
options.follow_layer_size = dynamic_resolution == 2;
options.cpu_boost = cpu_boost == JNI_TRUE;
options.own_controllers = own_controllers == JNI_TRUE;
options.pad_hand = pad_hand == 0 ? 0 : 1;
options.pad_tilt = pad_tilt;
options.rumble = rumble == JNI_TRUE;
g_app->xr_thread = std::thread{[options] {
App& app = *g_app;
JNIEnv* thread_env = nullptr;
app.vm->AttachCurrentThread(&thread_env, nullptr);
// This thread has little to do, but it has to do it every refresh: it must not wait
// for the emulator's threads, which outnumber the processor cores.
::setpriority(PRIO_PROCESS, 0, -16);
RunXrHost(app.vm, app.activity, app.core, app.pads, app.status, options, app.xr_status,
app.quit, app.recenter_requests);
LOGI("XR thread finished");
if (!app.quit) {
// The system ended the session (the user quit from its menu), or there never was
// one: without a headset to show it in there is no point in the game running on.
const jclass activity_class = thread_env->GetObjectClass(app.activity);
const jmethodID ended =
thread_env->GetMethodID(activity_class, "onSessionEnded", "()V");
if (ended != nullptr) {
thread_env->CallVoidMethod(app.activity, ended);
}
if (thread_env->ExceptionCheck()) {
thread_env->ExceptionClear();
}
}
app.vm->DetachCurrentThread();
}};
}
JNIEXPORT jboolean JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStartCore(
JNIEnv* env, jobject, jstring loader, jstring runtime_root, jstring storage_root, jstring game,
jstring log_file, jobjectArray extra_args, jobjectArray extra_env) {
if (!g_app) {
return JNI_FALSE;
}
CoreLaunch launch;
launch.loader = ToString(env, loader);
launch.runtime_root = ToString(env, runtime_root);
launch.storage_root = ToString(env, storage_root);
launch.game = ToString(env, game);
launch.log_file = ToString(env, log_file);
launch.extra_args = ToStrings(env, extra_args);
// NAME=value pairs.
for (const std::string& entry : ToStrings(env, extra_env)) {
const size_t equals = entry.find('=');
if (equals != std::string::npos && equals > 0) {
launch.extra_env.emplace_back(entry.substr(0, equals), entry.substr(equals + 1));
}
}
return g_app->core.Start(launch) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeStop(JNIEnv* env, jobject) {
if (!g_app) {
return;
}
g_app->quit = true;
g_app->core.Stop();
if (g_app->xr_thread.joinable()) {
g_app->xr_thread.join();
}
env->DeleteGlobalRef(g_app->activity);
g_app.reset();
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetFieldOfView(
JNIEnv*, jobject, jint percent) {
if (g_app) {
g_app->xr_status.reduced_fov = percent < 100;
}
}
JNIEXPORT jfloatArray JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeHeadsetFov(
JNIEnv* env, jobject) {
if (!g_app) {
return nullptr;
}
std::scoped_lock lock{g_app->xr_status.optics_mutex};
const auto& fov = g_app->xr_status.headset_fov;
if (fov[0] <= 0.0f) {
return nullptr;
}
const jfloatArray result = env->NewFloatArray(4);
if (result != nullptr) {
env->SetFloatArrayRegion(result, 0, 4, fov.data());
}
return result;
}
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeCoreState(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->core.GetState()) : 0;
}
JNIEXPORT jstring JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeCoreMessage(JNIEnv* env,
jobject) {
return env->NewStringUTF(g_app ? g_app->core.GetMessage().c_str() : "");
}
JNIEXPORT jlong JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeFrameCount(JNIEnv*,
jobject) {
return g_app ? static_cast<jlong>(g_app->core.GetPresentedFrames()) : 0;
}
/// `pixels` is a direct buffer of StatusImage::Width x Height RGBA pixels, top row first.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetStatusImage(
JNIEnv* env, jobject, jobject pixels) {
if (!g_app) {
return;
}
const auto* data = static_cast<const uint8_t*>(env->GetDirectBufferAddress(pixels));
const jlong size = env->GetDirectBufferCapacity(pixels);
const jlong expected = jlong{StatusImage::Width} * StatusImage::Height * 4;
if (data == nullptr || size < expected) {
return;
}
std::scoped_lock lock{g_app->status.mutex};
g_app->status.pixels.assign(data, data + expected);
++g_app->status.version;
}
/// What the gamepad says: buttons in the PS4 pad's bit layout, sticks from -1 to 1 (to the
/// right and towards the player), triggers from 0 to 1, and the finger on its own touchpad
/// if it has one (0 to 1 from the pad's left and from its far edge).
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeInput(
JNIEnv*, jobject, jint buttons, jfloat left_x, jfloat left_y, jfloat right_x, jfloat right_y,
jfloat left_trigger, jfloat right_trigger, jboolean touch_down, jfloat touch_x,
jfloat touch_y) {
if (!g_app) {
return;
}
GamepadState state;
state.buttons = static_cast<uint32_t>(buttons);
state.left_x = left_x;
state.left_y = left_y;
state.right_x = right_x;
state.right_y = right_y;
state.left_trigger = left_trigger;
state.right_trigger = right_trigger;
state.touch_down = touch_down == JNI_TRUE;
state.touch_x = touch_x;
state.touch_y = touch_y;
g_app->pads.SetGamepad(state, PadRouter::Now());
}
/// Gyroscope in rad/s and accelerometer in m/s², in the controller's frame.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeMotion(
JNIEnv*, jobject, jfloat gx, jfloat gy, jfloat gz, jfloat ax, jfloat ay, jfloat az) {
if (!g_app) {
return;
}
const float gyro[3]{gx, gy, gz};
const float accel[3]{ax, ay, az};
g_app->pads.SetGamepadMotion(gyro, accel, PadRouter::Now());
}
/// A gamepad is there to play with (again, or another one than before), or none is any more.
/// `touchpad`: it has a touchpad of its own.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetGamepad(
JNIEnv*, jobject, jboolean connected, jboolean touchpad) {
if (g_app) {
g_app->pads.SetGamepadConnected(connected == JNI_TRUE, touchpad == JNI_TRUE,
PadRouter::Now());
}
}
/// How often OPTIONS was pressed in the game so far.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeOptionsPresses(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->pads.Look().options_presses) : 0;
}
/// Shows the panel over the game although the game has a picture (for what the player
/// should read while they play), or no longer.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeShowPanel(JNIEnv*, jobject,
jboolean show) {
if (g_app) {
g_app->xr_status.show_panel = show == JNI_TRUE;
}
}
/// The host's own menu is over (or not yet): what the player presses is the game's.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetPlaying(
JNIEnv*, jobject, jboolean playing) {
if (g_app) {
g_app->pads.SetPlaying(playing == JNI_TRUE, PadRouter::Now());
}
}
/// What the game is played with: 0 nothing, 1 the gamepad, 2 the headset's own controllers.
/// 4 is added while those are in the player's hands, 8 where what the game is played with
/// has no touchpad and buttons do its gestures.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeController(JNIEnv*,
jobject) {
if (!g_app) {
return 0;
}
const PadRouter::View view = g_app->pads.Look();
return static_cast<jint>(view.source) | (view.touch_present ? 4 : 0) |
(view.gesture_buttons ? 8 : 0);
}
/// The player chose with it in the host's menu: 1 the gamepad, 2 the headset's controllers.
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeUseController(
JNIEnv*, jobject, jint which) {
if (g_app) {
g_app->pads.Use(which == 2 ? PadRouter::Source::Touch : PadRouter::Source::Gamepad,
PadRouter::Now());
}
}
/// What the headset's own controllers say to the host's menu: 1 a stick held to the left, 2
/// to the right, 4 what confirms (A or a trigger).
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeTouchMenu(JNIEnv*,
jobject) {
return g_app ? static_cast<jint>(g_app->pads.TouchMenu()) : 0;
}
/// What the game asks of the controller: see CoreProcess::GetPadFeedback for the layout.
JNIEXPORT jlong JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativePadFeedback(JNIEnv*,
jobject) {
return g_app ? static_cast<jlong>(g_app->core.GetPadFeedback()) : 0;
}
/// The player asks for the view to be reset (see XrRecenter in xr_host.h for what of it).
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeRecenter(JNIEnv*, jobject,
jint what) {
if (g_app) {
g_app->recenter_requests.fetch_or(static_cast<uint32_t>(what));
}
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetMicrophone(
JNIEnv*, jobject, jboolean enabled, jfloat gain) {
if (g_app) {
g_app->core.SetMicrophone(enabled == JNI_TRUE, gain);
}
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeSetLogFile(JNIEnv* env,
jclass,
jstring path) {
SetLogFile(ToString(env, path).c_str());
}
JNIEXPORT void JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeLog(JNIEnv* env, jclass,
jint priority,
jstring message) {
HostLog(priority, "%s", ToString(env, message).c_str());
}
/// Bit 0: the headset session is running. Bit 1: the hands holding the controller are seen.
/// Bit 2: the one of the headset's own controllers that is the controller in the game is.
/// Bits 8 to 15: the display's refresh rate in Hz.
JNIEXPORT jint JNICALL Java_com_astrobotquest_vrhost_MainActivity_nativeXrStatus(JNIEnv*,
jobject) {
if (!g_app) {
return 0;
}
const int refresh_rate =
std::clamp(static_cast<int>(g_app->xr_status.refresh_rate.load() + 0.5f), 0, 255);
return (g_app->xr_status.session_running ? 1 : 0) | (g_app->xr_status.hands_tracked ? 2 : 0) |
(g_app->xr_status.controller_tracked ? 4 : 0) | (refresh_rate << 8);
}
} // extern "C"