mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 11:17:16 -04:00
Harden VITURE stereo negotiation and output refresh tracking
This commit is contained in:
1 parent
4e75d53a71
commit
539a9e7ec4
17 files changed
+726
-83
No files matched your search
@@ -6,6 +6,7 @@
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
@@ -271,7 +272,9 @@ volatile int g_ubwc = 1;
|
||||
|
||||
volatile int g_no_render_node;
|
||||
|
||||
volatile int g_output_refresh_mhz;
|
||||
static atomic_int g_output_refresh_mhz = 60000;
|
||||
static atomic_int g_output_refresh_pending;
|
||||
static int g_output_announced_refresh_mhz = 60000;
|
||||
|
||||
volatile int g_output_w, g_output_h;
|
||||
struct pending_release {
|
||||
@@ -794,7 +797,7 @@ static void feedback_discard_all(struct wl_list *list) {
|
||||
}
|
||||
|
||||
static uint32_t nominal_refresh_ns(void) {
|
||||
int mhz = g_output_refresh_mhz;
|
||||
int mhz = atomic_load(&g_output_refresh_mhz);
|
||||
if (mhz <= 0) mhz = 60000;
|
||||
return (uint32_t)(1000000000000LL / (int64_t)mhz);
|
||||
}
|
||||
@@ -1507,10 +1510,10 @@ static const struct wl_output_interface output_impl = { .release = output_releas
|
||||
static struct wl_list g_outputs;
|
||||
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
|
||||
|
||||
static void output_send_mode(struct wl_resource *r) {
|
||||
static void output_send_mode(struct wl_resource *r, int mhz) {
|
||||
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
|
||||
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
|
||||
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
|
||||
mhz);
|
||||
}
|
||||
|
||||
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
|
||||
@@ -1520,7 +1523,7 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
|
||||
wl_list_insert(&g_outputs, wl_resource_get_link(r));
|
||||
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
|
||||
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
|
||||
output_send_mode(r);
|
||||
output_send_mode(r, atomic_load(&g_output_refresh_mhz));
|
||||
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
|
||||
wl_output_send_name(r, "DroidDeck-1");
|
||||
wl_output_send_description(r, "DroidDeck display");
|
||||
@@ -2876,13 +2879,19 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
static void output_notify_mode(void) {
|
||||
int mhz = atomic_load(&g_output_refresh_mhz);
|
||||
struct wl_resource *r;
|
||||
wl_resource_for_each(r, &g_outputs) {
|
||||
output_send_mode(r);
|
||||
output_send_mode(r, mhz);
|
||||
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
|
||||
}
|
||||
g_output_announced_refresh_mhz = mhz;
|
||||
}
|
||||
|
||||
static void output_resized(void) {
|
||||
struct surface *s;
|
||||
output_notify_mode();
|
||||
wl_list_for_each(s, &g_surfaces, link)
|
||||
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
|
||||
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
|
||||
@@ -2903,9 +2912,15 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
case 9: break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
if (atomic_exchange(&g_output_refresh_pending, 0) &&
|
||||
atomic_load(&g_output_refresh_mhz) != g_output_announced_refresh_mhz) {
|
||||
output_notify_mode();
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
if (vsync) on_vsync(vsync_ns);
|
||||
return 0;
|
||||
}
|
||||
@@ -2938,6 +2953,16 @@ void droiddeck_wayland_output_resized(void) {
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_set_output_refresh_rate(int mhz) {
|
||||
if (mhz <= 0) mhz = 60000;
|
||||
if (atomic_exchange(&g_output_refresh_mhz, mhz) == mhz || g_input_pipe[1] < 0) return;
|
||||
atomic_store(&g_output_refresh_pending, 1);
|
||||
struct input_msg m = { 9, 0, 0, 0, 0 };
|
||||
ssize_t n;
|
||||
do { n = write(g_input_pipe[1], &m, sizeof(m)); } while (n < 0 && errno == EINTR);
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 };
|
||||
@@ -3172,6 +3197,7 @@ int droiddeck_wayland_run(void) {
|
||||
if ((g_stats_timer = wl_event_loop_add_timer(wl_display_get_event_loop(display), on_stats_timer, NULL)))
|
||||
wl_event_source_timer_update(g_stats_timer, 10000);
|
||||
|
||||
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
|
||||
wl_display_run(display);
|
||||
|
||||
wl_display_destroy(display);
|
||||
|
||||
@@ -26,13 +26,13 @@ extern void droiddeck_wayland_send_key(int evdev, int state);
|
||||
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
|
||||
extern void droiddeck_wayland_output_resized(void);
|
||||
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
|
||||
extern volatile int g_fps_limit;
|
||||
extern volatile int g_hide_shell;
|
||||
extern volatile int g_zero_copy;
|
||||
extern volatile unsigned g_zero_copy_last;
|
||||
extern volatile int g_ubwc;
|
||||
extern volatile int g_no_render_node;
|
||||
extern volatile int g_output_refresh_mhz;
|
||||
extern volatile int g_output_w, g_output_h;
|
||||
|
||||
#define TAG "DroidDeckWayland"
|
||||
@@ -327,7 +327,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JN
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
|
||||
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
|
||||
droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0);
|
||||
}
|
||||
|
||||
extern void vkp_reset_first_frame(void);
|
||||
|
||||
@@ -315,9 +315,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
private fun followPanelRefresh(displayId: Int) {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (display.displayId != displayId) return
|
||||
val hz = display.refreshRate
|
||||
val output = glassesScreen?.display
|
||||
?: (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (output.displayId != displayId) return
|
||||
val hz = output.mode.refreshRate
|
||||
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
@@ -2515,13 +2516,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun onGlassesChanged() {
|
||||
val g = glasses
|
||||
val s = glassesSettings
|
||||
val key = listOf(g.present, g.usable, g.displayId, g.sbs, g.canControl, g.width, g.height, g.hz, s)
|
||||
val key = listOf(g.present, g.usable, g.displayId, g.sbs, g.switching3d, g.canControl, g.width, g.height, g.hz, s)
|
||||
if (key == glassesKey) return
|
||||
glassesKey = key
|
||||
updateGlassesScreen()
|
||||
applyCardboard(cardboard)
|
||||
if (glassesPresent && !g.present) onGlassesUnplugged()
|
||||
glassesPresent = g.present
|
||||
if (!g.switching3d) {
|
||||
if (glassesPresent && !g.present) onGlassesUnplugged()
|
||||
glassesPresent = g.present
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGlassesUnplugged() {
|
||||
|
||||
@@ -34,6 +34,7 @@ data class GlassesState(
|
||||
val height: Int = 0,
|
||||
val hz: Float = 0f,
|
||||
val sbs: Boolean = false,
|
||||
val switching3d: Boolean = false,
|
||||
val brightness: Int = -1,
|
||||
val volume: Int = -1,
|
||||
val brightnessMax: Int = 8,
|
||||
@@ -109,15 +110,15 @@ object Glasses {
|
||||
private lateinit var usb: UsbManager
|
||||
private lateinit var displays: DisplayManager
|
||||
@Volatile private var link: VitureLink? = null
|
||||
@Volatile private var linkGeneration = 0L
|
||||
private var linkDevice: UsbDevice? = null
|
||||
private var model: VitureProtocol.Model? = null
|
||||
private var asked = false
|
||||
private var lastModeSentAt = 0L
|
||||
private var sbsAskedAt = 0L
|
||||
private val modeState = GlassesModeState(SBS_CONFIRM_MS)
|
||||
private val confirmMode = Runnable { refresh() }
|
||||
private var glassesMode = 0
|
||||
private var autoModeSent = false
|
||||
private var vr3d = false
|
||||
private var vr3dRevert = false
|
||||
|
||||
@Volatile private var sink: PoseSink? = null
|
||||
@Volatile private var probe: ((ByteArray) -> Unit)? = null
|
||||
@@ -132,7 +133,7 @@ object Glasses {
|
||||
usb = app.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
displays = app.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
_settings.value = load()
|
||||
_state.value = _state.value.copy(sbsSupport = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_SBS_SUPPORT, GlassesState.SBS_UNKNOWN))
|
||||
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_SBS_SUPPORT).apply()
|
||||
handler = Handler(HandlerThread("droiddeck-glasses").apply { start() }.looper)
|
||||
handler.post {
|
||||
val filter = IntentFilter(ACTION_PERMISSION).apply {
|
||||
@@ -176,25 +177,20 @@ object Glasses {
|
||||
}
|
||||
|
||||
fun set3d(on: Boolean) = post {
|
||||
vr3d = false
|
||||
vr3dRevert = false
|
||||
modeState.manual()
|
||||
val s = _state.value
|
||||
if (if (on) s.can3d else s.canControl) sendMode(on)
|
||||
if (!s.canControl || (on && s.noSignal)) return@post
|
||||
if (on) rememberSbs(GlassesState.SBS_UNKNOWN)
|
||||
sendMode(on)
|
||||
}
|
||||
|
||||
fun set3dForVr(on: Boolean) = post {
|
||||
val s = _state.value
|
||||
if (on) {
|
||||
vr3dRevert = false
|
||||
if (!s.can3d || s.sbs) return@post
|
||||
vr3d = true
|
||||
sendMode(true)
|
||||
} else if (vr3d) {
|
||||
vr3d = false
|
||||
when {
|
||||
!s.canControl -> vr3dRevert = true
|
||||
s.sbs -> sendMode(false)
|
||||
}
|
||||
if (!s.can3d || s.sbs || modeState.pendingStereo) return@post
|
||||
if (sendMode(true)) modeState.startVr()
|
||||
} else if (modeState.stopVr() && s.canControl) {
|
||||
sendMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +222,11 @@ object Glasses {
|
||||
fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } }
|
||||
fun setVrFovDeg(deg: Int) = post { update { it.copy(vrFovDeg = deg.coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX)) } }
|
||||
|
||||
fun outputHz(): Int = _settings.value.let { if (_state.value.sbs) it.sbsHz else it.refreshHz }
|
||||
fun outputHz(): Int {
|
||||
val s = _state.value
|
||||
val g = _settings.value
|
||||
return if (s.sbs) if (s.sbs90) g.sbsHz else 60 else g.refreshHz
|
||||
}
|
||||
|
||||
fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) }
|
||||
|
||||
@@ -274,7 +274,8 @@ object Glasses {
|
||||
.put("present", s.present).put("model", s.model).put("controllable", s.controllable)
|
||||
.put("permission", s.permission.name).put("connected", s.connected)
|
||||
.put("display", if (s.hasDisplay) JSONObject().put("id", s.displayId).put("width", s.width).put("height", s.height).put("hz", s.hz.toDouble()) else JSONObject.NULL)
|
||||
.put("sbs", s.sbs).put("brightness", s.brightness).put("volume", s.volume)
|
||||
.put("sbs", s.sbs).put("deviceMode", glassesMode).put("switching3d", s.switching3d)
|
||||
.put("brightness", s.brightness).put("volume", s.volume)
|
||||
.put("brightnessMax", s.brightnessMax).put("volumeMax", s.volumeMax).put("sunblock", s.sunblock)
|
||||
.put("tracking", s.tracking).put("simulating", simulating.get() != null).put("imuPackets", imuPackets)
|
||||
.put("pose", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL)
|
||||
@@ -321,7 +322,6 @@ object Glasses {
|
||||
refresh(ask = true)
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> if (intent.device()?.vendorId == VitureProtocol.VENDOR_ID) {
|
||||
closeLink()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,13 @@ object Glasses {
|
||||
|
||||
private fun refresh(ask: Boolean = false) {
|
||||
val device = usb.deviceList.values.firstOrNull { it.vendorId == VitureProtocol.VENDOR_ID }
|
||||
val permitted = device != null && usb.hasPermission(device)
|
||||
val identity = device?.let {
|
||||
GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null)
|
||||
}
|
||||
if (modeState.updateDevice(identity, SystemClock.uptimeMillis())) {
|
||||
rememberSbs(GlassesState.SBS_UNKNOWN)
|
||||
}
|
||||
val m = device?.let { VitureProtocol.model(it.productId, runCatching { it.productName }.getOrNull()) }
|
||||
if (device == null || (linkDevice != null && linkDevice?.deviceName != device.deviceName)) closeLink()
|
||||
model = m
|
||||
@@ -347,23 +354,14 @@ object Glasses {
|
||||
val height = mode?.physicalHeight ?: 0
|
||||
val sbsByDisplay = width >= 3000
|
||||
val noSignal = display != null && width in 1 until 1280
|
||||
if (sbsAskedAt != 0L) {
|
||||
when {
|
||||
SystemClock.uptimeMillis() - sbsAskedAt > SBS_CONFIRM_MS -> sbsAskedAt = 0L
|
||||
display == null -> Unit
|
||||
sbsByDisplay -> {
|
||||
sbsAskedAt = 0L
|
||||
rememberSbs(GlassesState.SBS_WORKS)
|
||||
}
|
||||
noSignal -> {
|
||||
sbsAskedAt = 0L
|
||||
Log.w(TAG, "3D came back as ${width}x$height: this phone can't send the side-by-side signal")
|
||||
rememberSbs(GlassesState.SBS_FAILS)
|
||||
handler.postDelayed({ sendMode(false) }, 500)
|
||||
}
|
||||
when (modeState.observe(sbsByDisplay, SystemClock.uptimeMillis())) {
|
||||
GlassesModeState.Confirmation.FAILED -> {
|
||||
Log.w(TAG, "3D signal was not confirmed at ${width}x$height; restoring 2D")
|
||||
rememberSbs(GlassesState.SBS_FAILS)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
val permitted = device != null && usb.hasPermission(device)
|
||||
if (sbsByDisplay) rememberSbs(GlassesState.SBS_WORKS)
|
||||
if (device != null && !permitted && (ask || !asked) && _state.value.permission != Permission.ASKING) askPermission(device)
|
||||
if (device != null && permitted && link == null && m?.controllable == true) openLink(device)
|
||||
publish {
|
||||
@@ -380,7 +378,8 @@ object Glasses {
|
||||
connected = link != null,
|
||||
displayId = display?.displayId ?: Display.INVALID_DISPLAY,
|
||||
width = width, height = height, hz = mode?.refreshRate ?: 0f,
|
||||
sbs = if (display != null) sbsByDisplay else it.sbs && link != null,
|
||||
sbs = sbsByDisplay,
|
||||
switching3d = modeState.pendingStereo,
|
||||
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
|
||||
sunblock = _settings.value.sunblock ?: it.sunblock,
|
||||
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
|
||||
@@ -389,7 +388,8 @@ object Glasses {
|
||||
sbs90 = m?.sbs90 == true,
|
||||
)
|
||||
}
|
||||
if (link != null && display != null && !noSignal) applyMode(force = false)
|
||||
if (link != null) applyMode(force = false)
|
||||
scheduleModeCheck()
|
||||
}
|
||||
|
||||
private fun findDisplay(usbPresent: Boolean): Display? {
|
||||
@@ -419,7 +419,9 @@ object Glasses {
|
||||
}
|
||||
|
||||
private fun openLink(device: UsbDevice) {
|
||||
val l = VitureLink.open(usb, device, linkListener) ?: run {
|
||||
val generation = ++linkGeneration
|
||||
val l = VitureLink.open(usb, device, linkListener(generation)) ?: run {
|
||||
linkGeneration++
|
||||
Log.w(TAG, "could not open ${device.deviceName}")
|
||||
return
|
||||
}
|
||||
@@ -442,6 +444,7 @@ object Glasses {
|
||||
|
||||
private fun closeLink() {
|
||||
val l = link ?: return
|
||||
linkGeneration++
|
||||
link = null
|
||||
linkDevice = null
|
||||
glassesMode = 0
|
||||
@@ -452,22 +455,18 @@ object Glasses {
|
||||
|
||||
private fun rememberSbs(support: Int) {
|
||||
if (_state.value.sbsSupport == support) return
|
||||
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_SBS_SUPPORT, support).apply()
|
||||
publish { it.copy(sbsSupport = support) }
|
||||
}
|
||||
|
||||
private fun applyMode(force: Boolean) {
|
||||
val s = _state.value
|
||||
val m = model ?: return
|
||||
if (!s.canControl || !s.usable) return
|
||||
if (vr3dRevert) {
|
||||
vr3dRevert = false
|
||||
if (s.sbs) {
|
||||
sendMode(false)
|
||||
return
|
||||
}
|
||||
if (!s.canControl) return
|
||||
if (modeState.restorePending) {
|
||||
sendMode(false)
|
||||
return
|
||||
}
|
||||
if (s.sbs) return
|
||||
if (!s.usable || s.sbs || modeState.pendingStereo) return
|
||||
val hz = _settings.value.refreshHz
|
||||
if (glassesMode == m.mode(hz, false)) return
|
||||
if (!force) {
|
||||
@@ -478,14 +477,24 @@ object Glasses {
|
||||
sendMode(false)
|
||||
}
|
||||
|
||||
private fun sendMode(sbs: Boolean) {
|
||||
val m = model ?: return
|
||||
if (sbs && _state.value.sbsSupport == GlassesState.SBS_FAILS) return
|
||||
private fun sendMode(sbs: Boolean): Boolean {
|
||||
val m = model ?: return false
|
||||
val g = _settings.value
|
||||
val mode = m.mode(if (sbs) g.sbsHz else g.refreshHz, sbs)
|
||||
if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) {
|
||||
lastModeSentAt = SystemClock.uptimeMillis()
|
||||
sbsAskedAt = if (sbs) lastModeSentAt else 0L
|
||||
modeState.sent(sbs, lastModeSentAt)
|
||||
publish { it.copy(switching3d = modeState.pendingStereo) }
|
||||
scheduleModeCheck()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun scheduleModeCheck() {
|
||||
handler.removeCallbacks(confirmMode)
|
||||
modeState.nextCheckAt?.let { at ->
|
||||
handler.postDelayed(confirmMode, (at - SystemClock.uptimeMillis()).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,8 +512,9 @@ object Glasses {
|
||||
publish { it.copy(tracking = want) }
|
||||
}
|
||||
|
||||
private val linkListener = object : VitureLink.Listener {
|
||||
override fun onFrame(frame: VitureProtocol.Frame) {
|
||||
private fun linkListener(generation: Long) = object : VitureLink.Listener {
|
||||
override fun onFrame(frame: VitureProtocol.Frame) = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
val line = "%s 0x%04X %s".format(
|
||||
when (frame.magic) { VitureProtocol.MAGIC_REPORT -> "FD"; VitureProtocol.MAGIC_COMMAND -> "FE"; else -> "FC" },
|
||||
frame.msgId, frame.payload.joinToString("") { "%02X".format(it) },
|
||||
@@ -514,10 +524,11 @@ object Glasses {
|
||||
while (frames.size > 64) frames.removeFirst()
|
||||
}
|
||||
Log.i(TAG, "frame $line")
|
||||
post { onReport(frame) }
|
||||
onReport(frame)
|
||||
}
|
||||
|
||||
override fun onImu(payload: ByteArray, timeNs: Long) {
|
||||
if (generation != linkGeneration) return
|
||||
imuPackets++
|
||||
probe?.invoke(payload)
|
||||
if (simulating.get() != null) return
|
||||
@@ -525,6 +536,7 @@ object Glasses {
|
||||
}
|
||||
|
||||
override fun onLost() = post {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
refresh()
|
||||
}
|
||||
@@ -543,7 +555,6 @@ object Glasses {
|
||||
VitureProtocol.EVENT_DISPLAY -> {
|
||||
Log.i(TAG, "glasses switched to mode 0x%02X".format(event))
|
||||
glassesMode = event
|
||||
publish { it.copy(sbs = VitureProtocol.modeIsSbs(event)) }
|
||||
}
|
||||
VitureProtocol.EVENT_DISPLAY_STEP -> model?.modeForStep(event)?.let { mode ->
|
||||
Log.i(TAG, "glasses report mode 0x%02X".format(mode))
|
||||
@@ -555,11 +566,12 @@ object Glasses {
|
||||
VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_VOLUME -> answer?.let { v -> publish { it.copy(volume = v) } }
|
||||
VitureProtocol.MSG_DISPLAY -> if (event != 0) {
|
||||
Log.w(TAG, "glasses refused display mode 0x%02X".format(p.getOrNull(1)?.toInt()?.and(0xFF) ?: 0))
|
||||
sbsAskedAt = 0L
|
||||
modeState.cancelPending()
|
||||
publish { it.copy(switching3d = false) }
|
||||
scheduleModeCheck()
|
||||
} else answer?.let { glassesMode = it }
|
||||
VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v ->
|
||||
glassesMode = v
|
||||
if (VitureProtocol.modeIsSbs(v) != _state.value.sbs && !_state.value.hasDisplay) publish { it.copy(sbs = VitureProtocol.modeIsSbs(v)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
internal class GlassesModeState(private val timeoutMs: Long) {
|
||||
enum class Confirmation { NONE, WAITING, CONFIRMED, FAILED }
|
||||
data class Device(val productId: Int, val serial: String?)
|
||||
|
||||
private var deadline: Long? = null
|
||||
private var device: Device? = null
|
||||
private var attached = false
|
||||
private var reconnectDeadline: Long? = null
|
||||
private var vrOwned = false
|
||||
var restorePending = false
|
||||
private set
|
||||
val pendingStereo: Boolean get() = deadline != null
|
||||
val nextCheckAt: Long? get() = listOfNotNull(deadline, reconnectDeadline).minOrNull()
|
||||
|
||||
fun updateDevice(current: Device?, now: Long): Boolean {
|
||||
if (reconnectDeadline?.let { now >= it } == true) abandon()
|
||||
if (current == null) {
|
||||
if (attached) reconnectDeadline = now + timeoutMs
|
||||
attached = false
|
||||
return false
|
||||
}
|
||||
val previous = device
|
||||
val changed = previous == null || previous.productId != current.productId ||
|
||||
(previous.serial != null && current.serial != null && previous.serial != current.serial)
|
||||
if (changed) abandon()
|
||||
device = if (!changed && current.serial == null) previous else current
|
||||
attached = true
|
||||
reconnectDeadline = null
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun abandon() {
|
||||
deadline = null
|
||||
reconnectDeadline = null
|
||||
vrOwned = false
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun manual() {
|
||||
vrOwned = false
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun startVr() {
|
||||
vrOwned = true
|
||||
restorePending = false
|
||||
}
|
||||
|
||||
fun stopVr(): Boolean {
|
||||
if (vrOwned) {
|
||||
vrOwned = false
|
||||
restorePending = true
|
||||
}
|
||||
return restorePending
|
||||
}
|
||||
|
||||
fun sent(stereo: Boolean, now: Long) {
|
||||
deadline = if (stereo) now + timeoutMs else null
|
||||
if (!stereo) restorePending = false
|
||||
}
|
||||
|
||||
fun cancelPending() {
|
||||
deadline = null
|
||||
}
|
||||
|
||||
fun observe(stereo: Boolean, now: Long): Confirmation {
|
||||
val until = deadline ?: return Confirmation.NONE
|
||||
if (stereo) {
|
||||
deadline = null
|
||||
return Confirmation.CONFIRMED
|
||||
}
|
||||
if (now < until) return Confirmation.WAITING
|
||||
if (!attached) {
|
||||
abandon()
|
||||
return Confirmation.NONE
|
||||
}
|
||||
deadline = null
|
||||
vrOwned = false
|
||||
restorePending = true
|
||||
return Confirmation.FAILED
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,7 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private
|
||||
val holder: SurfaceHolder get() = surfaceView.holder
|
||||
private var wantHz = refreshHz
|
||||
|
||||
val hz: Float get() {
|
||||
val id = window?.attributes?.preferredDisplayModeId ?: 0
|
||||
return display.supportedModes.firstOrNull { id != 0 && it.modeId == id }?.refreshRate ?: display.refreshRate
|
||||
}
|
||||
val hz: Float get() = display.mode.refreshRate
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -36,7 +36,7 @@ object VitureProtocol {
|
||||
class Model(val name: String, val family: Family) {
|
||||
val controllable: Boolean get() = family != Family.BEAST
|
||||
val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA
|
||||
val sbs90: Boolean get() = tall
|
||||
val sbs90: Boolean get() = family == Family.ULTRA
|
||||
val brightnessMax: Int get() = 8
|
||||
val volumeMax: Int get() = 8
|
||||
val diagonalFovDeg: Float get() = when (family) {
|
||||
@@ -47,7 +47,7 @@ object VitureProtocol {
|
||||
}
|
||||
|
||||
fun mode(hz: Int, sbs: Boolean): Int = when {
|
||||
tall && sbs -> if (hz >= 90) 0x45 else 0x42
|
||||
tall && sbs -> if (hz >= 90 && sbs90) 0x45 else 0x42
|
||||
tall -> when { hz >= 120 -> 0x44; hz >= 90 -> 0x43; else -> 0x41 }
|
||||
sbs -> if (hz >= 90 && sbs90) 0x35 else 0x32
|
||||
else -> when { hz >= 120 -> 0x34; hz >= 90 -> 0x33; else -> 0x31 }
|
||||
|
||||
@@ -108,7 +108,8 @@ internal fun GlassesGroups(
|
||||
ChoiceRow(host, "glasses-hz", stringResource(R.string.glasses_refresh), null,
|
||||
listOf(60, 90, 120).map { it to "$it Hz" }, s.refreshHz, chipModifier = track("glasses-hz")) { Glasses.setRefreshHz(it) }
|
||||
if (g.sbsSupport == GlassesState.SBS_FAILS && !g.sbs) {
|
||||
SettingsRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_unsupported)) {}
|
||||
ActionRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_failed),
|
||||
stringResource(R.string.stores_dl_retry), { Glasses.set3d(true) })
|
||||
} else {
|
||||
if (inSession || g.sbs) ToggleRow(host, "glasses-3d", stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_hint),
|
||||
g.sbs, chipModifier = track("glasses-3d")) { Glasses.set3d(it) }
|
||||
|
||||
@@ -29,6 +29,7 @@ object CompositorHost {
|
||||
fpsLimit: Int,
|
||||
): Boolean {
|
||||
pace(refreshHz, fpsLimit)
|
||||
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
|
||||
if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
|
||||
if (started) {
|
||||
attached = surface
|
||||
@@ -36,7 +37,6 @@ object CompositorHost {
|
||||
resumeVsync()
|
||||
return false
|
||||
}
|
||||
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
|
||||
WaylandCompositor.nativeStartWithSurface(
|
||||
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
|
||||
)
|
||||
|
||||
@@ -2280,6 +2280,7 @@
|
||||
<string name="glasses_3d">3D (side-by-side)</string>
|
||||
<string name="glasses_3d_hint">Each eye gets its own half of a 3840×1080 picture. If the picture drops out, hold the glasses\' 2D/3D button.</string>
|
||||
<string name="glasses_3d_unsupported">This phone can\'t send the 3840×1080 signal 3D needs. VR games show one eye\'s view, with head tracking.</string>
|
||||
<string name="glasses_3d_failed">The 3D signal was not confirmed. Try again once the 2D picture returns.</string>
|
||||
<string name="glasses_no_signal">No picture: the glasses are in a mode this phone can\'t send. Hold their 2D/3D button, or unplug and replug them.</string>
|
||||
<string name="glasses_3d_refresh">3D refresh rate</string>
|
||||
<string name="glasses_auto3d">3D for VR games</string>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.LooperMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
@LooperMode(LooperMode.Mode.PAUSED)
|
||||
class GlassesCallbacksTest {
|
||||
private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true }
|
||||
private fun generation(value: Long) = field("linkGeneration").setLong(null, value)
|
||||
private fun listener(generation: Long): VitureLink.Listener =
|
||||
Glasses::class.java.getDeclaredMethod("linkListener", Long::class.javaPrimitiveType).apply { isAccessible = true }
|
||||
.invoke(Glasses, generation) as VitureLink.Listener
|
||||
|
||||
@Before fun prepare() {
|
||||
field("handler").set(null, Handler(Looper.getMainLooper()))
|
||||
generation(1)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val state = field("_state").get(null) as MutableStateFlow<GlassesState>
|
||||
state.value = GlassesState(connected = true, brightness = 2, displayId = 2, width = 640, height = 480, noSignal = true)
|
||||
field("imuPackets").setLong(null, 0)
|
||||
field("probe").set(null, null)
|
||||
}
|
||||
|
||||
private fun brightness(value: Int) = VitureProtocol.Frame(
|
||||
VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_BRIGHTNESS, byteArrayOf(value.toByte()), true,
|
||||
)
|
||||
|
||||
@Test fun queuedDisconnectCannotCloseAReplacementLink() {
|
||||
val old = listener(1)
|
||||
old.onLost()
|
||||
old.onLost()
|
||||
generation(2)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertTrue(Glasses.state.value.connected)
|
||||
}
|
||||
|
||||
@Test fun queuedReportCannotOverwriteReplacementState() {
|
||||
listener(1).onFrame(brightness(7))
|
||||
generation(2)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertEquals(2, Glasses.state.value.brightness)
|
||||
listener(2).onFrame(brightness(4))
|
||||
listener(1).onFrame(brightness(8))
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertEquals(4, Glasses.state.value.brightness)
|
||||
}
|
||||
|
||||
@Test fun oldImuPacketsCannotReachTheNewProbe() {
|
||||
var samples = 0
|
||||
val probe: (ByteArray) -> Unit = { samples++ }
|
||||
field("probe").set(null, probe)
|
||||
generation(2)
|
||||
listener(1).onImu(ByteArray(12), 0)
|
||||
assertEquals(0, samples)
|
||||
assertEquals(0L, field("imuPackets").getLong(null))
|
||||
listener(2).onImu(ByteArray(12), 0)
|
||||
assertEquals(1, samples)
|
||||
assertEquals(1L, field("imuPackets").getLong(null))
|
||||
}
|
||||
|
||||
@Test fun deviceModeReportsCannotConfirmThePhysicalStereoSignal() {
|
||||
listener(1).onFrame(VitureProtocol.Frame(
|
||||
VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_DISPLAY, byteArrayOf(0x32), true,
|
||||
))
|
||||
listener(1).onFrame(VitureProtocol.Frame(
|
||||
VitureProtocol.MAGIC_REPORT, VitureProtocol.MSG_GET_DISPLAY, byteArrayOf(0, 0x32), true,
|
||||
))
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertFalse(Glasses.state.value.sbs)
|
||||
assertTrue(Glasses.state.value.noSignal)
|
||||
assertEquals(640, Glasses.state.value.width)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import com.droiddeck.launcher.glasses.GlassesModeState.Confirmation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GlassesModeStateTest {
|
||||
private val device = GlassesModeState.Device(0x101d, "pro")
|
||||
private val state = GlassesModeState(20_000).apply { updateDevice(device, 0) }
|
||||
|
||||
@Test fun transientFallbackDoesNotRejectAWorkingStereoTransition() {
|
||||
state.sent(true, 100)
|
||||
assertEquals(Confirmation.WAITING, state.observe(false, 2_000))
|
||||
assertEquals(Confirmation.WAITING, state.observe(false, 19_000))
|
||||
assertFalse(state.restorePending)
|
||||
assertEquals(Confirmation.CONFIRMED, state.observe(true, 19_500))
|
||||
assertFalse(state.pendingStereo)
|
||||
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
|
||||
}
|
||||
|
||||
@Test fun missingSignalAtDeadlineRequestsOneFallback() {
|
||||
state.sent(true, 100)
|
||||
assertEquals(Confirmation.WAITING, state.observe(false, 20_099))
|
||||
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
|
||||
assertTrue(state.restorePending)
|
||||
assertEquals(Confirmation.NONE, state.observe(false, 40_000))
|
||||
state.sent(false, 40_001)
|
||||
assertFalse(state.restorePending)
|
||||
assertFalse(state.stopVr())
|
||||
}
|
||||
|
||||
@Test fun stoppingVrBeforeStereoAppearsStillRestores2d() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
assertTrue(state.stopVr())
|
||||
assertTrue(state.restorePending)
|
||||
state.sent(false, 200)
|
||||
assertFalse(state.pendingStereo)
|
||||
assertFalse(state.restorePending)
|
||||
assertFalse(state.stopVr())
|
||||
assertEquals(Confirmation.NONE, state.observe(true, 400))
|
||||
}
|
||||
|
||||
@Test fun failedRevertRemainsPendingUntilAWriteSucceeds() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.observe(true, 200)
|
||||
state.stopVr()
|
||||
state.cancelPending()
|
||||
assertTrue(state.restorePending)
|
||||
assertTrue(state.stopVr())
|
||||
state.sent(false, 300)
|
||||
assertFalse(state.restorePending)
|
||||
}
|
||||
|
||||
@Test fun manualModeTakesOwnershipFromVr() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.manual()
|
||||
state.sent(true, 200)
|
||||
assertFalse(state.stopVr())
|
||||
assertFalse(state.restorePending)
|
||||
assertEquals(Confirmation.CONFIRMED, state.observe(true, 300))
|
||||
}
|
||||
|
||||
@Test fun manualRetryGetsItsOwnConfirmationWindow() {
|
||||
state.sent(true, 100)
|
||||
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
|
||||
state.sent(false, 20_101)
|
||||
state.manual()
|
||||
state.sent(true, 30_000)
|
||||
assertEquals(Confirmation.WAITING, state.observe(false, 49_999))
|
||||
assertEquals(Confirmation.CONFIRMED, state.observe(true, 50_000))
|
||||
assertFalse(state.restorePending)
|
||||
}
|
||||
|
||||
@Test fun cancelledOrDisconnectedRequestCannotFailLater() {
|
||||
state.sent(true, 100)
|
||||
state.cancelPending()
|
||||
assertFalse(state.pendingStereo)
|
||||
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
|
||||
assertFalse(state.restorePending)
|
||||
}
|
||||
|
||||
@Test fun manual2dCancelsThePendingStereoDeadline() {
|
||||
state.sent(true, 100)
|
||||
state.manual()
|
||||
state.sent(false, 200)
|
||||
assertFalse(state.pendingStereo)
|
||||
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
|
||||
}
|
||||
|
||||
@Test fun modeReenumerationPreservesDeadlineAndVrOwnership() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
assertFalse(state.updateDevice(null, 1_000))
|
||||
assertEquals(Confirmation.WAITING, state.observe(false, 1_000))
|
||||
assertFalse(state.updateDevice(device, 2_000))
|
||||
assertEquals(20_100L, state.nextCheckAt)
|
||||
assertEquals(Confirmation.CONFIRMED, state.observe(true, 3_000))
|
||||
assertTrue(state.stopVr())
|
||||
}
|
||||
|
||||
@Test fun absentAtDeadlineCancelsWithoutFailureOrLateRestore() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.updateDevice(null, 1_000)
|
||||
state.stopVr()
|
||||
assertEquals(Confirmation.NONE, state.observe(false, 20_100))
|
||||
assertFalse(state.restorePending)
|
||||
assertNull(state.nextCheckAt)
|
||||
assertFalse(state.updateDevice(device, 40_000))
|
||||
assertFalse(state.stopVr())
|
||||
}
|
||||
|
||||
@Test fun differentModelOrKnownSerialCannotInheritARequest() {
|
||||
listOf(GlassesModeState.Device(0x1131, "pro"), device.copy(serial = "other")).forEach { other ->
|
||||
state.updateDevice(device, 0)
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.updateDevice(null, 1_000)
|
||||
assertTrue(state.updateDevice(other, 2_000))
|
||||
assertFalse(state.pendingStereo)
|
||||
assertFalse(state.stopVr())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun permissionPendingReconnectKeepsTheKnownIdentity() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.updateDevice(null, 1_000)
|
||||
assertFalse(state.updateDevice(device.copy(serial = null), 2_000))
|
||||
assertTrue(state.pendingStereo)
|
||||
assertTrue(state.updateDevice(device.copy(serial = "other"), 3_000))
|
||||
assertFalse(state.pendingStereo)
|
||||
assertFalse(state.stopVr())
|
||||
}
|
||||
|
||||
@Test fun sameDeviceReconnectDoesNotResetFailureStatus() {
|
||||
state.sent(true, 100)
|
||||
assertEquals(Confirmation.FAILED, state.observe(false, 20_100))
|
||||
state.sent(false, 20_101)
|
||||
assertFalse(state.updateDevice(null, 21_000))
|
||||
assertFalse(state.updateDevice(device, 22_000))
|
||||
}
|
||||
|
||||
@Test fun confirmedVrCannotRestoreAfterAProlongedUnplug() {
|
||||
state.sent(true, 100)
|
||||
state.startVr()
|
||||
state.observe(true, 200)
|
||||
state.updateDevice(null, 1_000)
|
||||
assertTrue(state.stopVr())
|
||||
assertEquals(21_000L, state.nextCheckAt)
|
||||
state.updateDevice(device, 22_000)
|
||||
assertFalse(state.restorePending)
|
||||
assertFalse(state.stopVr())
|
||||
assertNull(state.nextCheckAt)
|
||||
}
|
||||
}
|
||||
@@ -70,11 +70,16 @@ class VitureProtocolTest {
|
||||
|
||||
val luma = VitureProtocol.model(0x1131, null)!!
|
||||
assertEquals("VITURE Luma", luma.name)
|
||||
assertTrue(luma.sbs90)
|
||||
assertFalse(luma.sbs90)
|
||||
assertEquals(0x44, luma.mode(120, false))
|
||||
assertEquals(0x45, luma.mode(90, true))
|
||||
assertEquals(0x42, luma.mode(90, true))
|
||||
assertEquals(0x42, luma.mode(60, true))
|
||||
|
||||
val ultra = VitureProtocol.model(0x1101, null)!!
|
||||
assertTrue(ultra.sbs90)
|
||||
assertEquals(0x45, ultra.mode(90, true))
|
||||
assertEquals(0x42, ultra.mode(60, true))
|
||||
|
||||
assertFalse(VitureProtocol.model(0x1201, null)!!.controllable)
|
||||
assertNull(VitureProtocol.model(0x9999, "VITURE"))
|
||||
assertEquals("VITURE One", VitureProtocol.model(0x1011, "Something else")!!.name)
|
||||
|
||||
@@ -129,6 +129,10 @@ tools/droiddeckctl glasses mode 0x34
|
||||
tools/droiddeckctl glasses raw 0x0007
|
||||
```
|
||||
|
||||
Glasses status reports `deviceMode` from the MCU separately from `sbs`, which
|
||||
requires a physical stereo display mode. `switching3d` marks the bounded
|
||||
confirmation window. A successful mode-command reply alone does not confirm 3D.
|
||||
|
||||
A sleeping device suspends the session and hides the app's windows from `ui dump`; `wake` first.
|
||||
|
||||
`input` needs a session that has drawn its first frame. Pad input goes through the session screen's
|
||||
|
||||
@@ -143,6 +143,12 @@ With VITURE glasses connected, the Steam page also offers **Play SteamVR**, whic
|
||||
SteamVR (installing it first if needed), and stops SteamVR when the glasses are unplugged. **Play
|
||||
Steam** never starts it: while SteamVR runs, Steam keeps its own window in front of flat games.
|
||||
|
||||
Stereo also requires a physical side-by-side display mode. On the tested Astra 2
|
||||
firmware the Pro XR command succeeds but DisplayPort falls back to 640×480;
|
||||
direct-connection stereo remains unresolved. See the
|
||||
[Astra/VITURE investigation](development/astra-viture-stereo.md) for measured
|
||||
results, app recovery behavior, and the unverified powered-adapter design.
|
||||
|
||||
### Not done yet
|
||||
|
||||
- **Controllers**: the driver adds two Vive-profile hands driven by the session's game pad. It reads
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Astra 2 and VITURE stereo
|
||||
|
||||
## Tested connection
|
||||
|
||||
2026-10-14: REDMAGIC Astra 2, model `NP06J`, Android 16,
|
||||
`RedMagicOS11.5.16_NP06J_GB`, Snapdragon `SM8850P`; VITURE Pro XR
|
||||
(`35ca:101d`), firmware `07.0.03.013_20250422`. The tablet reports a locked
|
||||
bootloader and green verified boot. Tests used the ordinary ADB shell, without root.
|
||||
|
||||
2D negotiates **1920×1080 at 120 Hz**. The glasses accept stereo command
|
||||
`0x0008 [32]` with reply `00 32`, but Android then exposes **only 640×480 at
|
||||
60 Hz**. An accepted USB command is not proof of a working stereo signal.
|
||||
In this state the glasses reject a 2D command (`01 31` or `01 34`) and their
|
||||
mode query returns `01 00`. A physical cable reconnect is the next recovery step;
|
||||
that recovery has not yet been retested with this update.
|
||||
|
||||
## What was checked
|
||||
|
||||
| Route | Result on this firmware |
|
||||
| --- | --- |
|
||||
| Pro full SBS command `0x32` | Accepted by MCU; physical output falls to 640×480 |
|
||||
| Android preferred mode 3840×1080/60 | Preference saved; physical supported modes remain 640×480 only; preference cleared after test |
|
||||
| USB source/host → source/device | Request issued; actual port stayed source/host |
|
||||
| USB data disable/enable | Data status changed and was restored; physical display remained 640×480 |
|
||||
| `IUsbManager.resetUsbPort` through ADB | HAL callback reported success (`0`); display and pinned mode did not change |
|
||||
| External-display power off/reset | Did not recover the signal; glasses still rejected 2D |
|
||||
| OEM mirror/3D controls, source review | Change virtual-display routing, not physical stereo timings |
|
||||
| OEM resolution selection, source review | Chooses cached resolution candidates and prefers stale 16:9 mode |
|
||||
| Pro startup mode / half SBS, SDK review | No supported Pro startup-mode setting or half-SBS input |
|
||||
|
||||
Public driver decompilations from the related REDMAGIC 11 Pro (`NX809J`, SM8850)
|
||||
[retain an active mode override](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_get_modes.c#L37-L44)
|
||||
across a glasses hotplug. Their [USB-PD disconnect path](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_usbpd_disconnect_cb.c#L127-L147)
|
||||
clears that override; their simulated hotplug path does not. This is consistent
|
||||
with the Astra's measured behavior, but does not prove its exact kernel
|
||||
implementation. That driver also [validates saved user modes against EDID](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/nubia_edid_modes.c#L226-L250),
|
||||
so active overrides and saved preferences must not be treated as the same
|
||||
mechanism. The independent [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md)
|
||||
documents a related failure on its separately identified REDMAGIC firmware.
|
||||
|
||||
The [VITURE SDK matrix](https://www.viture.com/en-SG/developer/glasses-sdk/glasses)
|
||||
specifies full SBS at 3840×1080/60 for Pro. Persistent startup mode is restricted
|
||||
to Luma/Luma Pro. Among the Luma families supported here, 90 Hz stereo is an
|
||||
Ultra capability. The connected Pro firmware matches the N6PL entry in the
|
||||
[public updater catalog](https://static.viture.com/dfu-util/js/vers.js); that
|
||||
fallback catalog does not establish whether the update service offers another build.
|
||||
|
||||
## No-root hardware design to test
|
||||
|
||||
A powered pass-through connection may let the tablet see the stereo EDID on a
|
||||
fresh USB-PD connection while the glasses retain their stereo mode:
|
||||
|
||||
1. Power the adapter and glasses independently of the tablet connection.
|
||||
2. Enable stereo on the glasses.
|
||||
3. Disconnect only the tablet's upstream USB-C connection, keeping the glasses powered.
|
||||
4. Reconnect the tablet and check its physical supported modes before starting a game.
|
||||
|
||||
This requires an adapter that preserves glasses power and mode during the
|
||||
upstream disconnect, passes 3840×1080/60 video, and forwards USB HID for controls
|
||||
and head tracking. The [VITURE Adapter Ultra specifications](https://www.viture.com/academy/adapters/usb-c-xr-charging-adapter-ultra)
|
||||
describe separate power, DP1.4 and USB2 host support, but do not promise that
|
||||
mode-retention behavior. This design remains **unverified**, not a purchasing
|
||||
recommendation. An intermediary presenting the stereo EDID before first host
|
||||
connection is another possible design; it must pass the actual stereo timing
|
||||
and preserve HID access, not merely advertise a wider desktop.
|
||||
|
||||
Success requires Android to expose and select 3840×1080/60, different left/right
|
||||
eye images to reach the lenses, live head tracking, and repeatable return to 2D.
|
||||
A wide screenshot or `wm size` override alone does not meet those conditions.
|
||||
|
||||
## App safeguards
|
||||
|
||||
DroidDeck waits up to 20 seconds for physical stereo confirmation. A temporary
|
||||
low-resolution mode does not permanently blacklist the device. Failed attempts
|
||||
offer manual retry once the 2D signal returns; automatic retries do not loop.
|
||||
USB mode reports are tracked separately from the physical display. When a session
|
||||
enabled 3D, stopping it requests a return to 2D, including during negotiation;
|
||||
this firmware can still require a cable reconnect. Output timing follows
|
||||
the active glasses display rather than the tablet's requested refresh rate.
|
||||
|
||||
Keep hardware experiments separate from game loads. End the session, force-stop
|
||||
DroidDeck, and reopen its home screen after each game test. Read current HAL
|
||||
temperatures rather than cached thermal-service samples.
|
||||
|
||||
## Validation of this update
|
||||
|
||||
- Full debug APK build, including the Rust store engine; native-library, session
|
||||
asset and runtime-input packaging checks passed.
|
||||
- 840 Android unit tests across 140 suites passed, including mode deadlines,
|
||||
reconnect ownership, stale USB callbacks and model-specific protocol cases.
|
||||
- Four host tests passed, including native refresh notification, event coalescing,
|
||||
full-pipe handling and Cardboard output checks.
|
||||
- On the Astra: brightness changed and was restored; IMU delivered 400 packets
|
||||
in two seconds; Steam reached its first frame in 4.9 seconds and exited cleanly.
|
||||
Wayland reported the actual 120 Hz tablet mode. Four complete steady-state
|
||||
ten-second windows recorded 116.8–119.5 screen frames/s in Steam's UI.
|
||||
- DroidDeck was force-stopped and relaunched after testing. Debugging tools and
|
||||
temporary logging/display overrides were restored. Current HAL temperatures
|
||||
after the session were 25°C battery and 28.3°C skin.
|
||||
|
||||
These checks do not establish stereo-game performance or a sustained thermal
|
||||
baseline. Direct-cable stereo and the powered-adapter design remain unverified
|
||||
as working solutions on this Astra firmware.
|
||||
@@ -0,0 +1,153 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'app/src/main/cpp/waylandcomp/src/compositor.c'
|
||||
|
||||
|
||||
def extract_function(source, name):
|
||||
match = re.search(r'^(?:static )?(?:void|int|uint32_t) ' + re.escape(name) + r'\([^;]*?\) \{',
|
||||
source, re.MULTILINE)
|
||||
if match is None:
|
||||
raise AssertionError(f'Missing compositor function: {name}')
|
||||
cursor, depth = match.end(), 1
|
||||
while depth:
|
||||
depth += (source[cursor] == '{') - (source[cursor] == '}')
|
||||
cursor += 1
|
||||
return source[match.start():cursor]
|
||||
|
||||
|
||||
PREAMBLE = r'''
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/ioctl.h>
|
||||
|
||||
struct wl_resource { int version, modes, done, hz, w, h; struct wl_resource *next; };
|
||||
struct surface { int xdg_toplevel, toplevel_committed; struct surface *next; };
|
||||
struct input_msg { int type, p1, p2, p3, p4; };
|
||||
static struct wl_resource second = { .version = 1 };
|
||||
static struct wl_resource first = { .version = 4, .next = &second };
|
||||
static struct surface surface = { .xdg_toplevel = 1, .toplevel_committed = 1 };
|
||||
static int g_input_pipe[2] = {-1, -1};
|
||||
static atomic_int g_output_refresh_mhz = 60000;
|
||||
static atomic_int g_output_refresh_pending;
|
||||
static int g_output_announced_refresh_mhz = 60000;
|
||||
static int g_output_w = 1920, g_output_h = 1080;
|
||||
static int flushes, resizes, pointers, ticks;
|
||||
static void *g_display;
|
||||
#define WL_OUTPUT_MODE_CURRENT 1
|
||||
#define WL_OUTPUT_MODE_PREFERRED 2
|
||||
#define wl_resource_for_each(r, list) for ((r) = &first; (r); (r) = (r)->next)
|
||||
#define wl_list_for_each(s, list, link) for ((s) = &surface; (s); (s) = (s)->next)
|
||||
static int wl_resource_get_version(struct wl_resource *r) { return r->version; }
|
||||
static void wl_output_send_mode(struct wl_resource *r, int flags, int w, int h, int hz) {
|
||||
assert(flags == 3); r->modes++; r->hz = hz; r->w = w; r->h = h;
|
||||
}
|
||||
static void wl_output_send_done(struct wl_resource *r) { r->done++; }
|
||||
static void wl_display_flush_clients(void *display) { (void)display; flushes++; }
|
||||
static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; }
|
||||
#define droiddeck_log(...) ((void)0)
|
||||
#define deliver_key(m) ((void)0)
|
||||
#define pointer_event(...) ((void)0)
|
||||
#define pointer_button(...) ((void)0)
|
||||
#define scroll_event(...) ((void)0)
|
||||
#define pointer_delta(...) ((void)0)
|
||||
#define deliver_touch(m) ((void)0)
|
||||
#define deliver_pointer(m) (pointers++)
|
||||
static void on_vsync(int64_t ns) { assert(ns > 0); ticks++; }
|
||||
'''
|
||||
|
||||
CHECKS = r'''
|
||||
static int queued(void) { int n; assert(ioctl(g_input_pipe[0], FIONREAD, &n) == 0); return n; }
|
||||
static void drain(void) { on_input_readable(g_input_pipe[0], 0, NULL); }
|
||||
static void expect(int modes, int hz, int resize_count) {
|
||||
assert(first.modes == modes && second.modes == modes);
|
||||
assert(first.hz == hz && second.hz == hz);
|
||||
assert(first.w == 1920 && second.w == 1920 && first.h == 1080 && second.h == 1080);
|
||||
assert(first.done == modes && second.done == 0);
|
||||
assert(resizes == resize_count);
|
||||
assert(pointers == 0);
|
||||
}
|
||||
int main(void) {
|
||||
droiddeck_wayland_set_output_refresh_rate(120000);
|
||||
assert(atomic_load(&g_output_refresh_mhz) == 120000);
|
||||
assert(!atomic_load(&g_output_refresh_pending));
|
||||
assert(first.modes == 0 && second.modes == 0);
|
||||
assert(pipe2(g_input_pipe, O_CLOEXEC | O_NONBLOCK) == 0);
|
||||
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
|
||||
droiddeck_wayland_set_output_refresh_rate(120000);
|
||||
assert(queued() == 0);
|
||||
droiddeck_wayland_set_output_refresh_rate(60000);
|
||||
assert(first.modes == 0 && queued() == sizeof(struct input_msg));
|
||||
drain(); expect(1, 60000, 0); assert(flushes == 1);
|
||||
droiddeck_wayland_set_output_refresh_rate(60000);
|
||||
assert(queued() == 0); drain(); expect(1, 60000, 0);
|
||||
droiddeck_wayland_set_output_refresh_rate(90000);
|
||||
droiddeck_wayland_set_output_refresh_rate(120000);
|
||||
drain(); expect(2, 120000, 0);
|
||||
droiddeck_wayland_set_output_refresh_rate(90000);
|
||||
droiddeck_wayland_set_output_refresh_rate(120000);
|
||||
drain(); expect(2, 120000, 0);
|
||||
droiddeck_wayland_set_output_refresh_rate(60000);
|
||||
droiddeck_wayland_output_resized();
|
||||
drain(); expect(3, 60000, 1); assert(flushes == 3);
|
||||
struct input_msg wake = {9, 0, 0, 0, 0};
|
||||
int n = 0;
|
||||
while (write(g_input_pipe[1], &wake, sizeof(wake)) == sizeof(wake)) n++;
|
||||
assert(n > 0 && errno == EAGAIN);
|
||||
droiddeck_wayland_set_output_refresh_rate(120000);
|
||||
drain(); expect(4, 120000, 1);
|
||||
droiddeck_wayland_set_output_refresh_rate(0);
|
||||
drain(); expect(5, 60000, 1);
|
||||
droiddeck_wayland_set_output_refresh_rate(-1);
|
||||
assert(queued() == 0);
|
||||
struct input_msg vsync = {5, 0, 16000000, 0, 0};
|
||||
assert(write(g_input_pipe[1], &vsync, sizeof(vsync)) == sizeof(vsync));
|
||||
drain(); expect(5, 60000, 1); assert(ticks == 1);
|
||||
assert(nominal_refresh_ns() == 16666666);
|
||||
assert(flushes == 5);
|
||||
close(g_input_pipe[0]); close(g_input_pipe[1]);
|
||||
puts("PASS");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'needs Linux nonblocking pipes')
|
||||
class OutputRefreshTest(unittest.TestCase):
|
||||
def test_refresh_notifications_preserve_client_pacing_without_resizing(self):
|
||||
compiler = shutil.which(os.environ.get('CC', 'cc'))
|
||||
if compiler is None:
|
||||
self.skipTest('A host C compiler is required')
|
||||
source = SOURCE.read_text()
|
||||
functions = ('nominal_refresh_ns', 'output_send_mode', 'output_notify_mode',
|
||||
'output_resized', 'on_input_readable', 'droiddeck_wayland_output_resized',
|
||||
'droiddeck_wayland_set_output_refresh_rate')
|
||||
harness = PREAMBLE + '\n'.join(extract_function(source, name) for name in functions) + CHECKS
|
||||
with tempfile.TemporaryDirectory(prefix='droiddeck-output-refresh-') as folder:
|
||||
path = Path(folder)
|
||||
(path / 'refresh.c').write_text(harness)
|
||||
binary = path / 'refresh-test'
|
||||
build = subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||
'-Wno-unused-parameter', '-O2', str(path / 'refresh.c'),
|
||||
'-o', str(binary)], capture_output=True, text=True)
|
||||
self.assertEqual(0, build.returncode, build.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual('PASS', result.stdout.strip())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user