Harden VITURE stereo negotiation and output refresh tracking

This commit is contained in:
MaxsTechReview committed 2026-10-14 09:51:56 -04:00
1 parent 4e75d53a71
commit 539a9e7ec4
17 files changed
+726 -83

No files matched your search

+34 -8
View File
@@ -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,
)
+1
View File
@@ -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)
+4
View File
@@ -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
+6
View File
@@ -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
+103
View File
@@ -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.
+153
View File
@@ -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()