From 539a9e7ec442fb080fc717fa890788f4788a530c Mon Sep 17 00:00:00 2001 From: MaxsTechReview <6025299+maxjivi05@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:51:56 -0400 Subject: [PATCH] Harden VITURE stereo negotiation and output refresh tracking --- app/src/main/cpp/waylandcomp/src/compositor.c | 42 ++++- .../cpp/waylandcomp/src/waylandcomp_jni.c | 4 +- .../com/droiddeck/launcher/SessionActivity.kt | 15 +- .../com/droiddeck/launcher/glasses/Glasses.kt | 126 ++++++++------ .../launcher/glasses/GlassesModeState.kt | 84 +++++++++ .../launcher/glasses/GlassesScreen.kt | 5 +- .../launcher/glasses/VitureProtocol.kt | 4 +- .../com/droiddeck/launcher/ui/GlassesPanel.kt | 3 +- .../launcher/wayland/CompositorHost.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../launcher/glasses/GlassesCallbacksTest.kt | 86 ++++++++++ .../launcher/glasses/GlassesModeStateTest.kt | 162 ++++++++++++++++++ .../launcher/glasses/VitureProtocolTest.kt | 9 +- docs/agent-control.md | 4 + docs/cardboard-vr.md | 6 + docs/development/astra-viture-stereo.md | 103 +++++++++++ tools/tests/test_output_refresh.py | 153 +++++++++++++++++ 17 files changed, 726 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt create mode 100644 docs/development/astra-viture-stereo.md create mode 100644 tools/tests/test_output_refresh.py diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 512809de..6f50a889 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -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); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 64eca97b..5b6f87b4 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -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); diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index ea53ddd5..48c43aa1 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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() { diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt index 0485742e..3c20e3ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt @@ -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)) } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt new file mode 100644 index 00000000..2bf61b81 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt @@ -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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt index b814c030..7ac335a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt index 219e9c34..45c115ee 100644 --- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt index 6d76c575..aa19c6fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 9df4d4df..0d9a82f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -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, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d5c780ae..7075de27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2280,6 +2280,7 @@ 3D (side-by-side) Each eye gets its own half of a 3840×1080 picture. If the picture drops out, hold the glasses\' 2D/3D button. This phone can\'t send the 3840×1080 signal 3D needs. VR games show one eye\'s view, with head tracking. + The 3D signal was not confirmed. Try again once the 2D picture returns. No picture: the glasses are in a mode this phone can\'t send. Hold their 2D/3D button, or unplug and replug them. 3D refresh rate 3D for VR games diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt new file mode 100644 index 00000000..c2d9cce4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt @@ -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 + 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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt new file mode 100644 index 00000000..0ed56298 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt index 3aeba76c..cea0a0db 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt @@ -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) diff --git a/docs/agent-control.md b/docs/agent-control.md index 27c1175c..3539708c 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -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 diff --git a/docs/cardboard-vr.md b/docs/cardboard-vr.md index ca5b2cbc..4b35d7cb 100644 --- a/docs/cardboard-vr.md +++ b/docs/cardboard-vr.md @@ -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 diff --git a/docs/development/astra-viture-stereo.md b/docs/development/astra-viture-stereo.md new file mode 100644 index 00000000..0db5b31c --- /dev/null +++ b/docs/development/astra-viture-stereo.md @@ -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. diff --git a/tools/tests/test_output_refresh.py b/tools/tests/test_output_refresh.py new file mode 100644 index 00000000..c0289fd0 --- /dev/null +++ b/tools/tests/test_output_refresh.py @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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()