Files
mitch030504--AstroQuest-Frame/quest-host/java/com/astrobotquest/vrhost/MainActivity.java
T
bigmak94andClaude Opus 5.5 b5f400bbbd AstroQuest 0.11: ASTRO BOT Rescue Mission in VR on Meta Quest 3 and PC VR
On top of the shadps4-arm64 core imported in the previous commit:

- PlayStation VR emulation: headset, tracker, reprojection, camera and social screen; the
  player's seat and view reset; head velocity for head-butts; field of view settings
- Sound: a native Ngs2 engine (samplers, submixers, effects), the Audio3d spatializer, virtual
  surround for headset speakers, microphone pass-through; libSceJson2; libSceFiber on FEX
- Fixes for CUSA12392 under emulation: real-time step, pace and resolution governor, larger
  render sizes on the PC, save loading at start
- Renderer: geometry shaders fed with fans/polygons/loops, cube-array layers of light probes,
  sample-count mismatches on Adreno, timeline semaphore hang, FXAA in place of the MSAA
  resolve, sharpening, VR frame export
- quest-host: the Quest app (OpenXR host that runs the glibc core as a child process, frames
  through AHardwareBuffers, DualSense mapping, hand-tracked controller placement, KGSL shim)
- PC: in-process OpenXR host (Virtual Desktop or any OpenXR runtime), Touch controllers as the
  gamepad, launcher with a settings window; vendored OpenXR SDK 1.1.63
- Builds keep the build machine's folders out of the binaries (-ffile-prefix-map); machine
  settings (Python, headset serial, signing key) live in git-ignored tools/*.local files
- tools: build, packaging and test scripts for Windows and the headset
- README with installation, license (GPL-2.0-or-later), third-party notices and thanks

No game files are part of the repository; .gitignore only lets listed paths in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:00:43 +02:00

1218 lines
54 KiB
Java

// SPDX-License-Identifier: GPL-2.0-or-later
package com.astrobotquest.vrhost;
import android.Manifest;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.hardware.input.InputManager;
import android.hardware.lights.Light;
import android.hardware.lights.LightState;
import android.hardware.lights.LightsManager;
import android.hardware.lights.LightsRequest;
import android.os.Build;
import android.os.Bundle;
import android.os.CombinedVibration;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.os.VibrationEffect;
import android.os.VibratorManager;
import android.util.Log;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Hosts the PS4 emulator core on a Meta Quest: installs its Linux runtime, starts it, feeds it a
* paired DualSense as the DualShock 4 the game expects (buttons, sticks, touchpad, motion
* sensors in; rumble and light bar colour out) and lets the native side show its frames in the
* headset.
*
* Files the user provides live in the app's external folder
* (/sdcard/Android/data/com.astrobotquest.vrhost/files):
* games/SERIAL/eboot.bin ... the extracted game (or /data/local/tmp/astro/games/SERIAL)
* data/shadPS4/ the emulator's own folder (sys_modules, saves, logs)
* vrhost.txt optional settings, see readSettings
* and what the app leaves there for looking at afterwards:
* host.log, core.log this app's log and the emulator's output of the last start
* host.prev.log, core.prev.log the same of the start before
*/
public class MainActivity extends Activity
implements InputManager.InputDeviceListener, SensorEventListener {
private static final String TAG = "AstroVR";
// PS4 pad button bits (ORBIS_PAD_BUTTON_*).
private static final int L3 = 0x2, R3 = 0x4, OPTIONS = 0x8;
private static final int UP = 0x10, RIGHT = 0x20, DOWN = 0x40, LEFT = 0x80;
private static final int L2 = 0x100, R2 = 0x200, L1 = 0x400, R1 = 0x800;
private static final int TRIANGLE = 0x1000, CIRCLE = 0x2000, CROSS = 0x4000, SQUARE = 0x8000;
private static final int TOUCHPAD = 0x100000;
// The touchpad as the emulator takes it: the resolution of a DualSense's.
private static final int TOUCH_WIDTH = 1920, TOUCH_HEIGHT = 1080;
private static final int STATUS_WIDTH = 1024, STATUS_HEIGHT = 512;
/** Second place games are looked for; every app may read what adb puts there. */
private static final String SHELL_GAMES = "/data/local/tmp/astro/games";
// Mirrors CoreProcess::State.
private static final int CORE_IDLE = 0, CORE_STARTING = 1, CORE_RUNNING = 2, CORE_STOPPED = 3,
CORE_FAILED = 4;
/** How long a rumble command lasts if the game says nothing more, and how often it is renewed. */
private static final int RUMBLE_MILLIS = 1000, RUMBLE_RENEW_MILLIS = 400;
private static final int REQUEST_MICROPHONE = 1;
static {
System.loadLibrary("astrovr");
}
private native void nativeStartXr(float refreshRate, int eyeWidth, int eyeHeight,
boolean trackHands, int sharpen, boolean cubic, boolean showStats, float predictMs,
int dynamicResolution, boolean cpuBoost);
private native boolean nativeStartCore(String loader, String runtimeRoot, String storageRoot,
String game, String logFile, String[] extraArgs, String[] extraEnv);
private native void nativeStop();
private native int nativeCoreState();
private native String nativeCoreMessage();
private native long nativeFrameCount();
private native void nativeSetStatusImage(ByteBuffer pixels);
private native void nativeInput(int buttons, int leftX, int leftY, int rightX, int rightY,
int leftTrigger, int rightTrigger, boolean touchDown, int touchX, int touchY);
private native void nativeMotion(float gx, float gy, float gz, float ax, float ay, float az);
private native long nativePadFeedback();
private static final int VENDOR_SONY = 0x054c;
private static final int VENDOR_META = 0x2833;
/** What a reset of the view covers: the controller's heading, the player's seat. */
private static final int RECENTER_PAD = 1, RECENTER_SEAT = 2;
private native void nativeRecenter(int what);
private native int nativeXrStatus();
private native void nativeSetMicrophone(boolean enabled, float gain);
private static native void nativeSetLogFile(String path);
private static native void nativeLog(int priority, String message);
private final Handler handler = new Handler(Looper.getMainLooper());
private View inputView;
private volatile String setupStatus = "Starting...";
private volatile boolean setupFailed;
private String shownStatus = "";
// Controller state.
private int keyButtons;
private int hatButtons;
private int triggerButtons;
private float leftX, leftY, rightX, rightY, leftTrigger, rightTrigger;
private boolean padTouchDown;
private float padTouchX, padTouchY;
private boolean padTouchClick;
private final float[] acceleration = {0.0f, 9.81f, 0.0f};
// The controller itself: what the game's feedback goes to.
private InputDevice gamepad;
private SensorManager controllerSensors;
private VibratorManager controllerVibrators;
private LightsManager.LightsSession lightSession;
private Light lightBar;
private long shownFeedback = -1;
private long rumbleRenewAt;
// Settings.
private boolean stickTouchpad = true;
private boolean motion = true;
private boolean rumble = true;
private boolean trackHands = true;
private int sharpen = 1;
private boolean cubic;
private boolean antialias = true;
private int pace;
private int msaa = 1;
private boolean showStats;
private float predictMs = 25;
private boolean microphone = true;
private int dynamicResolution = 1;
private boolean cpuBoost = true;
/**
* How much of the PlayStation VR's field of view the game draws, in percent: less than all
* of it for more pixels to the degree, which is what the player asked for.
*/
private int fieldOfView = 85;
/**
* Set by the test that runs the activity without showing it (SandboxShell "drystart"):
* nothing is asked of the user or of the system's window manager then.
*/
private boolean dryRun;
private int statusPictures;
private float microphoneGain = 1.0f;
private boolean seatTaken;
/** How long OPTIONS is held for the view to be reset, as on a PlayStation VR. */
private static final long OPTIONS_HOLD_MS = 1000;
private final Runnable optionsHeld = () -> {
logInfo("OPTIONS held: view reset");
nativeRecenter(RECENTER_PAD | RECENTER_SEAT);
};
private long quietFrames = -1;
private long quietSince;
private long statsFrames = -1;
private long statsTime;
private String statsRate = "...";
private float refreshRate;
/** What SHADPS4_TITLE_RESOLUTION is set to, null to leave the choice to the emulator. */
private String resolution;
private boolean realTime = true;
private final List<String> extraArgs = new ArrayList<>();
private final List<String> extraEnv = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
dryRun = getIntent() != null && getIntent().getBooleanExtra("dry_run", false);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// Nothing is drawn here, the view only exists to receive controller events.
inputView = new View(this);
inputView.setFocusable(true);
inputView.setFocusableInTouchMode(true);
inputView.setOnCapturedPointerListener((view, event) -> handleTouchpad(event));
setContentView(inputView);
inputView.requestFocus();
// Kept as files: by the time somebody asks what happened in a session, the system's
// log has long moved on.
File external = getExternalFilesDir(null);
keepPrevious(new File(external, "host.log"), new File(external, "host.prev.log"));
nativeSetLogFile(new File(external, "host.log").getAbsolutePath());
logInfo("Astro VR Host " + versionName() + " on " + Build.MODEL + ", system "
+ Build.DISPLAY);
readSettings();
nativeStartXr(refreshRate, 1440, 1536, trackHands, sharpen, cubic, showStats, predictMs,
dynamicResolution, cpuBoost);
nativeSetMicrophone(microphone, microphoneGain);
if (microphone && !dryRun && checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
// The game listens to the headset's microphone, as it did to PlayStation VR's:
// blowing at things is one of the ways to play with its world.
logInfo("asking for the microphone");
requestPermissions(new String[] {Manifest.permission.RECORD_AUDIO},
REQUEST_MICROPHONE);
}
InputManager inputManager = getSystemService(InputManager.class);
inputManager.registerInputDeviceListener(this, handler);
findGamepad();
new Thread(this::setUpAndStart, "setup").start();
handler.post(this::refreshStatus);
handler.post(this::applyFeedback);
}
@Override
protected void onDestroy() {
handler.removeCallbacksAndMessages(null);
getSystemService(InputManager.class).unregisterInputDeviceListener(this);
detachGamepad();
nativeStop();
super.onDestroy();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions,
int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_MICROPHONE) {
// Nothing else to do: the native side keeps trying to open the microphone.
logInfo("microphone " + (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED
? "allowed" : "not allowed"));
}
}
/** Called by the native side when the headset session is over and will not come back. */
private void onSessionEnded() {
logInfo("the session is over: closing");
if (!dryRun) {
runOnUiThread(this::finish);
}
}
private String versionName() {
try {
return getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
return "?";
}
}
private static void keepPrevious(File current, File previous) {
if (current.isFile()) {
previous.delete();
current.renameTo(previous);
}
}
private static void logInfo(String message) {
nativeLog(Log.INFO, message);
}
private static void logWarning(String message, Throwable cause) {
nativeLog(Log.WARN, cause == null ? message : message + ": " + cause);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
logInfo(hasFocus ? "input focus gained" : "input focus lost");
if (hasFocus) {
// Gives the controller's touchpad to the game instead of a mouse pointer.
inputView.requestPointerCapture();
}
}
// --- settings -------------------------------------------------------------------------------
/**
* Reads the optional vrhost.txt: one "key=value" per line.
* refresh_rate=72 display refresh rate to ask for (72, 80, 90, 120). The game
* draws one frame for every two refreshes where the GPU manages
* that, and one for every three where it does not: 45 or 30
* frames a second at the 90 Hz asked for without this, and 60,
* 40 or 30 at 120. At 72 and 80 Hz there is only the one pace
* (36, 40): frames are never given more refreshes than still
* makes 30 a second, below that the game draws as it can
* pace=2 leave it to the game how many refreshes a frame takes, as up to
* version 0.6: it takes two, and one more whenever a frame is
* late, so frames last unevenly where it only just keeps up. A
* larger number holds every frame to that many refreshes. Without
* this the emulator chooses, see resolution
* resolution=960 the size the game draws its scene at, per eye: 816 (x870), 960
* (x1080), 1200 (x1280) or 1440 (x1536), or "game" for the game's
* own choice. Without this the emulator chooses: the fastest pace
* at which 960x1080 (the console's own smallest) fits the GPU's
* time, 816x870 only where that would mean fewer than 30 frames a
* second, and then the largest size that fits that pace
* antialias=0 leave edges as jagged as the game draws them here. The console
* multisamples (see msaa), which this GPU cannot afford; by
* default the emulator smooths edges in the finished scene
* instead, for a fraction of the cost
* real_time=0 let the game count time in frames, as it does on the console:
* it then runs in slow motion whenever a frame takes longer than
* a sixtieth of a second, which here is always. By default the
* emulator has it run by the clock
* hands=0 do not use hand tracking to place the controller; it is then held
* at a fixed spot in front of the player
* sharpen=0 leave the picture as soft as it gets from being shown larger
* than it was drawn. By default (1) the emulator sharpens it once
* per frame of the game. 2 has the headset do it instead, with its
* Super Resolution filter on every refresh: finer, but it takes
* about a twentieth of the GPU from the game. 3 is the same left
* to the system, which drops it when the GPU is busy (always, in
* a level). 4 is the headset's plainer filter
* cubic=1 have the headset enlarge the picture with a cubic filter instead
* of a linear one: sharper, for some GPU time on every refresh
* stick_touchpad=0 stop using the right stick as a finger on the touchpad
* motion=0 ignore the controller's motion sensors
* rumble=0 no vibration
* msaa=4 let the game multisample as it does on the console (1, 2 or 4).
* The default of 1 turns that off: it is by far the most expensive
* thing the headset's GPU is asked to do
* stats=1 keep a small panel in view with the game's frame rate, the
* display's refresh rate and whether the hands are seen
* predict_ms=25 how far beyond the next refresh the head pose given to the game
* is predicted (0 to 80). More if the edges of the picture show
* when turning the head, less if the world wobbles
* dynamic_resolution=0 do not ask the system what size it recommends for the
* picture. Asking is what the system wants of an app before it
* lets the GPU run at its faster clocks; the picture is shown at
* full size all the same (the game draws its scene smaller when
* the GPU is short, which is what saves time). 2 also shows the
* picture at the recommended size, as up to version 0.6
* cpu_boost=0 do not ask for the processor's fastest clock, which the system
* otherwise grants for the first 45 seconds and now and then
* fov=100 have the game draw that many percent of the field of view it
* draws on a PlayStation VR (100 by 103 degrees an eye), and show
* it over that much: the same pixels over fewer degrees is a sharper
* picture, with a black border where the rest was. Default 85 (18%
* more pixels to the degree); 100 is the console's own, 50 to 120
* mic=0 the game does not get to hear the headset's microphone
* mic_gain=2 make what it hears that many times louder (0.1 to 30), if
* blowing does too little or everything counts as blowing
* arg=... extra emulator argument (may repeat)
* env=NAME=value extra emulator environment variable (may repeat)
*/
private void readSettings() {
File file = new File(getExternalFilesDir(null), "vrhost.txt");
// The test that starts the activity without showing it may bring settings of its own.
String testSettings = dryRun ? getIntent().getStringExtra("settings") : null;
if (testSettings != null) {
file = new File(testSettings);
}
if (!file.isFile()) {
return;
}
try {
for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) {
line = line.trim();
int equals = line.indexOf('=');
if (line.isEmpty() || line.startsWith("#") || equals <= 0) {
continue;
}
String key = line.substring(0, equals).trim();
String value = line.substring(equals + 1).trim();
switch (key) {
case "refresh_rate":
refreshRate = Float.parseFloat(value);
break;
case "resolution":
switch (value) {
case "816":
resolution = "3";
break;
case "960":
resolution = "4";
break;
case "1200":
resolution = "5";
break;
case "1440":
resolution = "6";
break;
case "game":
resolution = "title";
break;
default:
resolution = null;
break;
}
break;
case "real_time":
realTime = !value.equals("0");
break;
case "hands":
trackHands = !value.equals("0");
break;
case "sharpen":
sharpen = Math.max(0, Math.min(4, Integer.parseInt(value)));
break;
case "cubic":
cubic = !value.equals("0");
break;
case "antialias":
antialias = !value.equals("0");
break;
case "pace":
pace = Math.max(0, Math.min(6, Integer.parseInt(value)));
break;
case "stick_touchpad":
stickTouchpad = !value.equals("0");
break;
case "motion":
motion = !value.equals("0");
break;
case "rumble":
rumble = !value.equals("0");
break;
case "stats":
showStats = !value.equals("0");
break;
case "predict_ms":
predictMs = Math.max(0, Math.min(80, Float.parseFloat(value)));
break;
case "msaa":
msaa = Math.max(1, Math.min(4, Integer.parseInt(value)));
break;
case "dynamic_resolution":
dynamicResolution = Math.max(0, Math.min(2, Integer.parseInt(value)));
break;
case "cpu_boost":
cpuBoost = !value.equals("0");
break;
case "fov":
fieldOfView = Math.max(50, Math.min(120, Integer.parseInt(value)));
break;
case "mic":
microphone = !value.equals("0");
break;
case "mic_gain":
microphoneGain = Math.max(0.1f, Math.min(30.0f, Float.parseFloat(value)));
break;
case "arg":
extraArgs.add(value);
break;
case "env":
extraEnv.add(value);
break;
default:
logWarning("unknown setting " + key, null);
continue;
}
logInfo("setting " + key + "=" + value);
}
} catch (IOException | NumberFormatException e) {
logWarning("unable to read " + file, e);
}
}
// --- runtime and game -----------------------------------------------------------------------
private void setUpAndStart() {
try {
// On the first start, and after every update of the app: a few seconds.
File runtime = new File(getFilesDir(), "runtime");
setupStatus = "Installing the emulator runtime...";
if (RuntimeInstaller.install(this, runtime)) {
logInfo("runtime unpacked");
}
File external = getExternalFilesDir(null);
File games = new File(external, "games");
games.mkdirs();
new File(external, "data/shadPS4/sys_modules").mkdirs();
File eboot = findGame(games);
if (eboot == null) {
// Where "adb push" can put it without caring who owns the files.
eboot = findGame(new File(SHELL_GAMES));
}
if (eboot == null) {
setupFailed = true;
setupStatus = "No game found.\n\nCopy the extracted game folder (the one that "
+ "contains eboot.bin) to the headset, to either of\n" + games
+ "/CUSA12392\n" + SHELL_GAMES + "/CUSA12392\nthen start this app again.";
return;
}
File storage = new File(getFilesDir(), "core");
storage.mkdirs();
// Settings from vrhost.txt come last: they replace what is set here.
List<String> env = new ArrayList<>();
// Saves, firmware modules and logs go where the user can reach them over USB.
env.add("XDG_DATA_HOME=" + new File(external, "data"));
env.add("SHADPS4_MAX_MSAA=" + msaa);
// The emulator has more busy threads than the headset has processor cores for it.
// The sound must not stutter for that (the game mixes it on two threads, the
// emulator passes it on with one per output), a frame should not wait behind the
// game's helpers on the two threads all of it passes through, and the two that
// keep time with the display (they have next to nothing to do) must do so on time.
env.add("SHADPS4_THREAD_NICE=Game:Main=-14,shadPS4:GpuComm=-14,sndx_out_thread=-16,"
+ "sndx_audio3d=-16,shadPS4:AudioOu=-16,shadPS4:VrHostL=-18,"
+ "shadPS4:Present=-18");
// On the GPU the emulator steps back behind this app: copying a finished picture
// to the display takes a millisecond, and must not wait until the GPU is through
// with everything the game has queued for the pictures after it.
env.add("KGSL_COMPAT_PRIORITY=12");
if (msaa == 1) {
// Without multisampling the GPU driver's direct rendering is the faster of its
// two ways to draw (about 5% of the GPU's time with this game); its own choice
// per render pass goes for the tiled way too often.
env.add("TU_DEBUG=sysmem");
}
// A few lines every five or ten seconds in core.log: the frame rate and where a
// frame's time goes, how well each sound port keeps up, what the microphone hears.
env.add("SHADPS4_FRAME_STATS=1");
env.add("SHADPS4_AUDIOOUT_STATS=1");
env.add("SHADPS4_AUDIOIN_STATS=1");
// The emulated headset refreshes as often as the display does (and, once frames
// are shown, when it does): the game draws one frame for every two refreshes, or
// for every three where that is all the GPU manages.
int displayRate = (nativeXrStatus() >> 8) & 0xff;
if (displayRate == 0) {
displayRate = refreshRate > 0 ? Math.round(refreshRate) : 90;
}
env.add("SHADPS4_VR_REFRESH_RATE=" + displayRate);
logInfo("the emulated headset refreshes " + displayRate + " times a second");
if (resolution != null) {
env.add("SHADPS4_TITLE_RESOLUTION=" + resolution);
}
if (!realTime) {
env.add("SHADPS4_TITLE_TIMESTEP=0");
}
if (pace >= 2) {
env.add("SHADPS4_VR_PACE=" + pace);
}
if (fieldOfView != 100) {
env.add("SHADPS4_VR_FOV=" + fieldOfView);
}
double angle = Math.toDegrees(Math.atan(1.2074 * fieldOfView / 100.0)
+ Math.atan(1.1813 * fieldOfView / 100.0));
logInfo("the game draws " + fieldOfView + "% of PlayStation VR's field of view: "
+ Math.round(angle) + " degrees across an eye, "
+ Math.round(1440 / angle * 10) / 10.0 + " pixels to the degree at most"
+ " (fov= in vrhost.txt; 100 is the console's own)");
if (!antialias) {
env.add("SHADPS4_RESOLVE_AA=0");
}
if (sharpen == 1) {
// The game draws 1440 pixels across for what the display shows with well over
// twice as many, and enlarges its scene to get even there.
env.add("SHADPS4_VR_SHARPEN=0.6");
}
env.addAll(extraEnv);
setupStatus = "Starting the emulator...";
keepPrevious(new File(external, "core.log"), new File(external, "core.prev.log"));
logInfo("game: " + eboot);
String loader = getApplicationInfo().nativeLibraryDir + "/libastro_ld.so";
boolean started = nativeStartCore(loader, runtime.getAbsolutePath(),
storage.getAbsolutePath(), eboot.getAbsolutePath(),
new File(external, "core.log").getAbsolutePath(),
extraArgs.toArray(new String[0]), env.toArray(new String[0]));
if (!started) {
setupFailed = true;
setupStatus = "The emulator could not be started.";
}
} catch (IOException e) {
nativeLog(Log.ERROR, "setup failed: " + e);
setupFailed = true;
setupStatus = "Setup failed: " + e.getMessage();
}
}
/** The game to run: games/CUSA12392 if present, otherwise the first folder with an eboot. */
private static File findGame(File games) {
File preferred = new File(games, "CUSA12392/eboot.bin");
if (preferred.isFile()) {
return preferred;
}
File[] folders = games.listFiles();
if (folders != null) {
for (File folder : folders) {
File eboot = new File(folder, "eboot.bin");
if (eboot.isFile()) {
return eboot;
}
}
}
return null;
}
// --- status panel ---------------------------------------------------------------------------
private void refreshStatus() {
String status = setupStatus;
if (!setupFailed) {
switch (nativeCoreState()) {
case CORE_STARTING:
status = "Starting the emulator...";
break;
case CORE_RUNNING:
status = nativeFrameCount() == 0 ? "Booting the game..." : "Loading...";
if (showStats && nativeFrameCount() > 0) {
status = statsLine();
}
// A game that stopped drawing looks like one that loads. Loads end.
long frames = nativeFrameCount();
long now = SystemClock.elapsedRealtime();
if (frames != quietFrames) {
quietFrames = frames;
quietSince = now;
}
long quiet = (now - quietSince) / 1000;
if (frames > 0 && quiet >= 20) {
status = "No new picture from the game for " + quiet / 5 * 5
+ " seconds.\nA long load looks like this too. Past a minute the "
+ "game is stuck: close the app with the Meta button and start it "
+ "again. What happened is kept in host.log and core.log.";
}
break;
case CORE_STOPPED:
case CORE_FAILED:
status = nativeCoreMessage() + "\n\nLog: "
+ new File(getExternalFilesDir(null), "core.log");
break;
default:
break;
}
// Waiting is the moment to say what the game will be played with.
if (gamepad == null) {
status += "\n\nNo controller found. Switch the DualSense on (PS button); pair it "
+ "first under Settings > Bluetooth if it never was.";
} else {
status += "\n\nController: " + gamepad.getName()
+ (controllerSensors != null ? "" : " (no motion sensors)")
+ "\nTo reset the view, hold OPTIONS for a second or press the PS button: "
+ "where you are then is your seat, and the way you face is straight "
+ "ahead.";
}
}
if (!status.equals(shownStatus)) {
shownStatus = status;
drawStatus(status);
}
handler.postDelayed(this::refreshStatus, 500);
}
/** What the game and the display are doing, for the panel that stats=1 keeps in view. */
private String statsLine() {
long frames = nativeFrameCount();
long now = SystemClock.elapsedRealtime();
if (statsFrames < 0) {
statsFrames = frames;
statsTime = now;
} else if (now - statsTime >= 2000) {
// Over two seconds: a rate counted over less jumps about.
statsRate = String.format(Locale.US, "%.0f",
(frames - statsFrames) * 1000.0 / (now - statsTime));
statsFrames = frames;
statsTime = now;
}
String rate = statsRate;
int xr = nativeXrStatus();
return "Game: " + rate + " frames a second\nDisplay: " + ((xr >> 8) & 0xff) + " Hz\n"
+ "Hands holding the controller: " + ((xr & 2) != 0 ? "seen" : "not seen");
}
private void drawStatus(String status) {
Bitmap bitmap = Bitmap.createBitmap(STATUS_WIDTH, STATUS_HEIGHT, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.argb(230, 16, 20, 32));
canvas.drawRoundRect(new RectF(0, 0, STATUS_WIDTH, STATUS_HEIGHT), 40, 40, paint);
paint.setColor(Color.rgb(90, 170, 255));
paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
paint.setTextSize(52);
canvas.drawText("Astro VR Host", 48, 86, paint);
paint.setColor(Color.WHITE);
paint.setTypeface(Typeface.DEFAULT);
paint.setTextSize(30);
float y = 160;
for (String paragraph : status.split("\n")) {
// Wrap by measuring: at a space where there is one, anywhere where there is none
// (paths have no spaces to break at).
String rest = paragraph;
do {
int count = paint.breakText(rest, true, STATUS_WIDTH - 96, null);
if (count < rest.length()) {
int space = rest.lastIndexOf(' ', count);
if (space > 0) {
count = space + 1;
}
}
canvas.drawText(rest, 0, count, 48, y, paint);
rest = rest.substring(count);
y += 40;
} while (!rest.isEmpty());
}
if (dryRun && statusPictures < 8) {
// Nobody sees the panel in a test: its pictures are kept for looking at, in the
// folder the test has made for them.
File picture = new File(new File(getFilesDir(), "drystart"),
"status-" + statusPictures++ + ".png");
try (FileOutputStream out = new FileOutputStream(picture)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
} catch (IOException e) {
logWarning("unable to write " + picture, e);
}
}
ByteBuffer pixels = ByteBuffer.allocateDirect(STATUS_WIDTH * STATUS_HEIGHT * 4);
bitmap.copyPixelsToBuffer(pixels);
nativeSetStatusImage(pixels);
bitmap.recycle();
}
// --- controller: buttons and sticks ---------------------------------------------------------
private static int buttonFor(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
return CROSS;
case KeyEvent.KEYCODE_BUTTON_B:
return CIRCLE;
case KeyEvent.KEYCODE_BUTTON_X:
return SQUARE;
case KeyEvent.KEYCODE_BUTTON_Y:
return TRIANGLE;
case KeyEvent.KEYCODE_BUTTON_L1:
return L1;
case KeyEvent.KEYCODE_BUTTON_R1:
return R1;
case KeyEvent.KEYCODE_BUTTON_L2:
return L2;
case KeyEvent.KEYCODE_BUTTON_R2:
return R2;
case KeyEvent.KEYCODE_BUTTON_THUMBL:
return L3;
case KeyEvent.KEYCODE_BUTTON_THUMBR:
return R3;
case KeyEvent.KEYCODE_BUTTON_START:
return OPTIONS;
// The Create button stands in for a click of the touchpad as well.
case KeyEvent.KEYCODE_BUTTON_SELECT:
case KeyEvent.KEYCODE_BUTTON_1:
return TOUCHPAD;
case KeyEvent.KEYCODE_DPAD_UP:
return UP;
case KeyEvent.KEYCODE_DPAD_RIGHT:
return RIGHT;
case KeyEvent.KEYCODE_DPAD_DOWN:
return DOWN;
case KeyEvent.KEYCODE_DPAD_LEFT:
return LEFT;
default:
return 0;
}
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) {
// The PS button resets the view: where the head is now is where the player sits,
// the way they face is straight ahead, and so is the way the controller points
// (which only matters while the headset does not see the hands holding it).
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
logInfo("PS button: view reset");
nativeRecenter(RECENTER_PAD | RECENTER_SEAT);
}
return true;
}
int button = buttonFor(event.getKeyCode());
if (button == 0) {
return super.dispatchKeyEvent(event);
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
noteInputFrom(event.getDevice());
if (button == CROSS && !seatTaken) {
// The first press of X is the player settled in, controller in hand, looking
// at the game: that, and not where they were when the app started, is their
// seat.
seatTaken = true;
logInfo("first press of X: the player's seat is where they are now");
nativeRecenter(RECENTER_SEAT);
}
if (button == OPTIONS && event.getRepeatCount() == 0) {
// What a PlayStation VR does when OPTIONS is held, and what the game tells its
// players to do when the view is off. The game still sees the button.
handler.removeCallbacks(optionsHeld);
handler.postDelayed(optionsHeld, OPTIONS_HOLD_MS);
}
keyButtons |= button;
} else if (event.getAction() == KeyEvent.ACTION_UP) {
if (button == OPTIONS) {
handler.removeCallbacks(optionsHeld);
}
keyButtons &= ~button;
}
sendInput();
return true;
}
private static float axis(MotionEvent event, int axis) {
InputDevice.MotionRange range = event.getDevice() != null
? event.getDevice().getMotionRange(axis, InputDevice.SOURCE_JOYSTICK) : null;
float value = event.getAxisValue(axis);
// Sticks rest slightly off centre.
return range != null && Math.abs(value) <= range.getFlat() ? 0.0f : value;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (event.isFromSource(InputDevice.SOURCE_JOYSTICK)
&& event.getAction() == MotionEvent.ACTION_MOVE) {
leftX = axis(event, MotionEvent.AXIS_X);
leftY = axis(event, MotionEvent.AXIS_Y);
rightX = axis(event, MotionEvent.AXIS_Z);
rightY = axis(event, MotionEvent.AXIS_RZ);
leftTrigger = Math.max(event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE));
rightTrigger = Math.max(event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS));
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
hatButtons = (hatX < -0.5f ? LEFT : 0) | (hatX > 0.5f ? RIGHT : 0)
| (hatY < -0.5f ? UP : 0) | (hatY > 0.5f ? DOWN : 0);
triggerButtons = (leftTrigger > 0.12f ? L2 : 0) | (rightTrigger > 0.12f ? R2 : 0);
sendInput();
return true;
}
if (handleTouchpad(event)) {
return true;
}
return super.dispatchGenericMotionEvent(event);
}
/** The controller's own touchpad, when the system exposes it with finger positions. */
private boolean handleTouchpad(MotionEvent event) {
if (!event.isFromSource(InputDevice.SOURCE_TOUCHPAD) || event.getDevice() == null) {
return false;
}
// Sticks and touchpad are one device with two meanings for X and Y.
InputDevice.MotionRange rangeX = event.getDevice().getMotionRange(MotionEvent.AXIS_X,
InputDevice.SOURCE_TOUCHPAD);
InputDevice.MotionRange rangeY = event.getDevice().getMotionRange(MotionEvent.AXIS_Y,
InputDevice.SOURCE_TOUCHPAD);
if (rangeX == null || rangeY == null || rangeX.getRange() <= 0 || rangeY.getRange() <= 0) {
return false;
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
padTouchDown = true;
padTouchX = (event.getX() - rangeX.getMin()) / rangeX.getRange();
padTouchY = (event.getY() - rangeY.getMin()) / rangeY.getRange();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
padTouchDown = false;
break;
default:
break;
}
padTouchClick = (event.getButtonState() & MotionEvent.BUTTON_PRIMARY) != 0;
sendInput();
return true;
}
private static int stick(float value) {
return Math.max(0, Math.min(255, Math.round(128.0f + value * 127.0f)));
}
private void sendInput() {
int buttons = keyButtons | hatButtons | triggerButtons | (padTouchClick ? TOUCHPAD : 0);
boolean touchDown = padTouchDown;
float touchX = padTouchX, touchY = padTouchY;
if (!touchDown && stickTouchpad) {
// This title has no use for the right stick, while several of its gadgets want
// swipes: the stick moves a finger that touches down at the pad's centre.
float deflection = (float) Math.hypot(rightX, rightY);
if (deflection > 0.25f) {
touchDown = true;
touchX = 0.5f + rightX * 0.45f;
touchY = 0.5f + rightY * 0.45f;
}
}
nativeInput(buttons, stick(leftX), stick(leftY), stick(rightX), stick(rightY),
Math.round(Math.max(0.0f, Math.min(1.0f, leftTrigger)) * 255.0f),
Math.round(Math.max(0.0f, Math.min(1.0f, rightTrigger)) * 255.0f), touchDown,
Math.max(0, Math.min(TOUCH_WIDTH - 1, Math.round(touchX * (TOUCH_WIDTH - 1)))),
Math.max(0, Math.min(TOUCH_HEIGHT - 1, Math.round(touchY * (TOUCH_HEIGHT - 1)))));
}
// --- controller: the device, its motion sensors, rumble and light ----------------------------
private static boolean isGamepad(InputDevice device) {
return device != null && !device.isVirtual()
&& device.supportsSource(InputDevice.SOURCE_GAMEPAD)
&& device.supportsSource(InputDevice.SOURCE_JOYSTICK);
}
/**
* How much a device looks like the controller the game is played with. The headset's own
* controllers also show up as gamepads (and come first in the list when the app was started
* with one of them): a controller from the console's maker goes before anything else, the
* headset's own after everything else, and motion sensors and motors count for the rest.
*/
private int rank(InputDevice device) {
int rank = 0;
if (device.getVendorId() == VENDOR_SONY) {
rank += 8;
} else if (device.getVendorId() == VENDOR_META) {
rank -= 8;
}
try {
if (findSensors(device) != null) {
rank += 2;
}
if (device.getVibratorManager().getVibratorIds().length > 0) {
rank += 1;
}
} catch (RuntimeException e) {
// A device that cannot say what it has is ranked by its maker alone.
}
return rank;
}
private void findGamepad() {
InputManager inputManager = getSystemService(InputManager.class);
InputDevice best = gamepad;
for (int id : inputManager.getInputDeviceIds()) {
InputDevice device = inputManager.getInputDevice(id);
if (isGamepad(device) && (best == null || rank(device) > rank(best))) {
best = device;
}
}
useGamepad(best);
}
/** Makes `device` the controller, if it is not that already. */
private void useGamepad(InputDevice device) {
if (device == null || (gamepad != null && gamepad.getId() == device.getId())) {
return;
}
if (gamepad != null) {
logInfo("another controller takes over");
detachGamepad();
}
attachGamepad(device);
}
/**
* Buttons and sticks are taken from whichever gamepad they come from. If that is not the
* device taken for the controller, and it looks no less like one, it is the controller from
* now on: its motion sensors, motors and light are the ones to use.
*/
private void noteInputFrom(InputDevice device) {
if (gamepad != null && device != null && device.getId() == gamepad.getId()) {
return;
}
if (isGamepad(device) && (gamepad == null || rank(device) >= rank(gamepad))) {
useGamepad(device);
}
}
private void attachGamepad(InputDevice device) {
gamepad = device;
logInfo("controller: " + device.getName() + " (vendor " + device.getVendorId()
+ ", product " + device.getProductId() + ", sources 0x"
+ Integer.toHexString(device.getSources()) + ")");
// Every part is optional: what a controller offers depends on it and on the system.
try {
SensorManager sensors = motion ? findSensors(device) : null;
if (sensors != null) {
sensors.registerListener(this, sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
SensorManager.SENSOR_DELAY_FASTEST);
sensors.registerListener(this, sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE),
SensorManager.SENSOR_DELAY_FASTEST);
controllerSensors = sensors;
}
} catch (RuntimeException e) {
logWarning("no motion sensors", e);
}
try {
VibratorManager vibrators = device.getVibratorManager();
if (rumble && vibrators.getVibratorIds().length > 0) {
controllerVibrators = vibrators;
}
} catch (RuntimeException e) {
logWarning("no rumble", e);
}
try {
LightsManager lights = device.getLightsManager();
for (Light light : lights.getLights()) {
if (light.hasRgbControl()) {
lightBar = light;
lightSession = lights.openSession();
break;
}
}
} catch (RuntimeException e) {
logWarning("no light bar", e);
}
logInfo("controller offers: motion " + (controllerSensors != null) + ", rumble "
+ (controllerVibrators != null) + ", light bar " + (lightBar != null)
+ ", touchpad " + device.supportsSource(InputDevice.SOURCE_TOUCHPAD));
shownFeedback = -1;
}
/**
* The motion sensors of a controller. The system normally presents them as part of the
* gamepad; where it lists them as a device of their own, that one is found by its maker
* and model.
*/
private SensorManager findSensors(InputDevice device) {
InputManager inputManager = getSystemService(InputManager.class);
List<InputDevice> candidates = new ArrayList<>();
candidates.add(device);
for (int id : inputManager.getInputDeviceIds()) {
InputDevice other = inputManager.getInputDevice(id);
if (other != null && other.getId() != device.getId()
&& other.getVendorId() == device.getVendorId()
&& other.getProductId() == device.getProductId()) {
candidates.add(other);
}
}
for (InputDevice candidate : candidates) {
SensorManager sensors = candidate.getSensorManager();
if (sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
&& sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
return sensors;
}
}
return null;
}
private void detachGamepad() {
if (controllerSensors != null) {
controllerSensors.unregisterListener(this);
controllerSensors = null;
}
if (controllerVibrators != null) {
try {
controllerVibrators.cancel();
} catch (RuntimeException e) {
// The controller is gone, and its motors with it.
}
controllerVibrators = null;
}
if (lightSession != null) {
try {
lightSession.close();
} catch (RuntimeException e) {
// Likewise.
}
lightSession = null;
}
lightBar = null;
gamepad = null;
}
/** Carries out what the game last asked of the controller: rumble and light bar colour. */
private void applyFeedback() {
long feedback = nativePadFeedback();
long now = android.os.SystemClock.uptimeMillis();
int small = (int) (feedback & 0xff);
int large = (int) ((feedback >> 8) & 0xff);
boolean changed = feedback != shownFeedback;
if (controllerVibrators != null
&& (changed || ((small | large) != 0 && now >= rumbleRenewAt))) {
try {
int[] ids = controllerVibrators.getVibratorIds();
if ((small | large) == 0) {
controllerVibrators.cancel();
} else {
// The first motor is the heavy one, as the system lists them.
CombinedVibration.ParallelCombination both = CombinedVibration.startParallel();
if (ids.length == 1) {
both.addVibrator(ids[0], VibrationEffect.createOneShot(RUMBLE_MILLIS,
Math.max(small, large)));
} else {
if (large != 0) {
both.addVibrator(ids[0],
VibrationEffect.createOneShot(RUMBLE_MILLIS, large));
}
if (small != 0) {
both.addVibrator(ids[1],
VibrationEffect.createOneShot(RUMBLE_MILLIS, small));
}
}
controllerVibrators.vibrate(both.combine());
rumbleRenewAt = now + RUMBLE_RENEW_MILLIS;
}
} catch (RuntimeException e) {
logWarning("rumble failed", e);
controllerVibrators = null;
}
}
if (changed && lightSession != null && lightBar != null) {
int red = (int) ((feedback >> 16) & 0xff);
int green = (int) ((feedback >> 24) & 0xff);
int blue = (int) ((feedback >> 32) & 0xff);
// Before the game has chosen a colour the bar keeps the one the system gave it.
if ((red | green | blue) != 0) {
try {
lightSession.requestLights(new LightsRequest.Builder()
.addLight(lightBar, new LightState.Builder()
.setColor(Color.rgb(red, green, blue)).build())
.build());
} catch (RuntimeException e) {
logWarning("light bar failed", e);
lightSession = null;
}
}
}
shownFeedback = feedback;
handler.postDelayed(this::applyFeedback, 16);
}
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
System.arraycopy(event.values, 0, acceleration, 0, 3);
} else if (event.sensor.getType() == Sensor.TYPE_GYROSCOPE) {
nativeMotion(event.values[0], event.values[1], event.values[2], acceleration[0],
acceleration[1], acceleration[2]);
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
@Override
public void onInputDeviceAdded(int deviceId) {
// A controller that is switched on later takes over from a lesser one found before.
findGamepad();
adoptLateSensors();
}
/** The motion sensors of a controller may be listed a moment after the controller. */
private void adoptLateSensors() {
if (gamepad == null || controllerSensors != null || !motion) {
return;
}
InputDevice device = getSystemService(InputManager.class).getInputDevice(gamepad.getId());
if (device != null && findSensors(device) != null) {
detachGamepad();
attachGamepad(device);
}
}
@Override
public void onInputDeviceRemoved(int deviceId) {
if (gamepad != null && gamepad.getId() == deviceId) {
logInfo("controller gone");
detachGamepad();
// Nothing is held down on a controller that is gone.
keyButtons = 0;
hatButtons = 0;
triggerButtons = 0;
leftX = leftY = rightX = rightY = leftTrigger = rightTrigger = 0.0f;
padTouchDown = false;
padTouchClick = false;
sendInput();
findGamepad();
}
}
@Override
public void onInputDeviceChanged(int deviceId) {
adoptLateSensors();
}
}