From 1e66ff17c186cdcb798a050ca2d6ba69cd3c278e Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 13 Oct 2026 08:32:48 -0400 Subject: [PATCH] Add VITURE glasses support: hardware control, 3D, head tracking Glasses get their own controller over the MCU (permission, 120 Hz default, brightness, volume, sunblock, 2D/3D) and the game moves onto them when plugged in. The IMU drives Cardboard head tracking and the SteamVR driver. 3D falls back to one eye's view on phones that can't send the 3840x1080 signal. Options open from a glasses button in game and on the Steam page. --- app/src/main/cpp/waylandcomp/src/cardboard.c | 24 +- .../main/cpp/waylandcomp/src/cardboard.frag | 8 +- app/src/main/cpp/waylandcomp/src/cardboard.h | 2 + .../main/cpp/waylandcomp/src/cardboard_frag.h | 526 ++++++++-------- .../main/cpp/waylandcomp/src/headpose_shm.h | 4 +- .../cpp/waylandcomp/src/waylandcomp_jni.c | 5 +- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../com/droiddeck/launcher/SessionActivity.kt | 192 +++++- .../launcher/agent/AgentBridgeProvider.kt | 47 ++ .../com/droiddeck/launcher/glasses/Glasses.kt | 562 ++++++++++++++++++ .../launcher/glasses/GlassesScreen.kt | 33 + .../droiddeck/launcher/glasses/VitureLink.kt | 120 ++++ .../launcher/glasses/VitureProtocol.kt | 169 ++++++ .../com/droiddeck/launcher/gpu/Cardboard.kt | 13 +- .../com/droiddeck/launcher/gpu/HeadTracker.kt | 37 +- .../launcher/input/OnScreenControls.kt | 1 + .../com/droiddeck/launcher/ui/DropSheet.kt | 353 +++++++++++ .../com/droiddeck/launcher/ui/GlassesPanel.kt | 175 ++++++ .../com/droiddeck/launcher/ui/LiftSheet.kt | 36 +- .../launcher/ui/SessionDrawerPage.kt | 7 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 43 +- .../com/droiddeck/launcher/ui/SteamHome.kt | 10 + .../launcher/wayland/WaylandCompositor.java | 2 +- app/src/main/res/values-es/strings.xml | 37 ++ app/src/main/res/values-fr/strings.xml | 37 ++ app/src/main/res/values-ja/strings.xml | 37 ++ app/src/main/res/values-ko/strings.xml | 37 ++ app/src/main/res/values-ru/strings.xml | 37 ++ app/src/main/res/values-zh-rCN/strings.xml | 37 ++ app/src/main/res/values-zh-rHK/strings.xml | 37 ++ app/src/main/res/values-zh-rTW/strings.xml | 37 ++ app/src/main/res/values/strings.xml | 40 ++ .../launcher/glasses/VitureProtocolTest.kt | 111 ++++ .../droiddeck/launcher/gpu/CardboardTest.kt | 33 + tools/cardboard/test_cardboard_params.c | 6 + tools/droiddeckctl | 33 + tools/vr/src/driver_droiddeck.cpp | 53 +- 37 files changed, 2624 insertions(+), 318 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.c b/app/src/main/cpp/waylandcomp/src/cardboard.c index ffb1918b0..39ad49004 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.c +++ b/app/src/main/cpp/waylandcomp/src/cardboard.c @@ -43,6 +43,8 @@ void cardboard_clamp(struct cardboard_params *p) { p->fov_deg = clampf(p->fov_deg, 30.0f, 140.0f); p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f); p->chroma = clampf(p->chroma, 0.0f, 0.05f); + p->mono = p->mono ? 1 : 0; + p->display_hz = clampf(p->display_hz, 0.0f, 240.0f); } void cardboard_push_constants(const struct cardboard_params *p, const float pose[9], @@ -50,7 +52,7 @@ void cardboard_push_constants(const struct cardboard_params *p, const float pose /* rot0..rot2: the pose's rows, each with one scalar riding in w. */ for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c]; - out[3] = p->ipd_m * 0.5f; + out[3] = p->mono ? 0.0f : p->ipd_m * 0.5f; out[7] = p->screen_dist_m; out[11] = p->screen_width_m * 0.5f; /* lens */ @@ -63,8 +65,9 @@ void cardboard_push_constants(const struct cardboard_params *p, const float pose out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f; if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f; /* one eye's half */ out[18] = (float)p->source; - out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f; - out[20] = out[21] = out[22] = out[23] = 0.0f; /* reserved */ + out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * (p->mono ? 1.0f : 0.5f)) / (float)out_h : 1.0f; + out[20] = p->mono ? 1.0f : 0.0f; + out[21] = out[22] = out[23] = 0.0f; /* reserved */ } #ifndef CARDBOARD_HOST_TEST @@ -91,9 +94,9 @@ void vkp_cardboard_set(const struct cardboard_params *p) { shm_publish(g_live); pthread_mutex_unlock(&g_lock); if (was != c.enabled || c.enabled) - droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f", + droiddeck_log("cardboard", "%s: %s%s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f", c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen", - c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma); + c.mono ? " (one eye)" : "", c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma); } /* The guest's copy of the pose (headpose_shm.h), mapped once a path is known. */ @@ -131,7 +134,9 @@ static void shm_publish(int live) { atomic_thread_fence(memory_order_release); memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot)); g_shm->ipd_m = g_set.ipd_m; - g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u); + g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u) | + (g_set.mono ? DROIDDECK_HEADPOSE_MONO : 0u); + g_shm->display_hz = g_set.display_hz; g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f); g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0); @@ -418,14 +423,15 @@ VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); } return VK_NULL_HANDLE; } - if (g_eye_w != out_w / 2 || g_eye_h != out_h) { + snapshot(); + int eye_w = g_cur.mono ? out_w : out_w / 2; + if (g_eye_w != eye_w || g_eye_h != out_h) { pthread_mutex_lock(&g_lock); - g_eye_w = out_w / 2; + g_eye_w = eye_w; g_eye_h = out_h; shm_publish(g_live); pthread_mutex_unlock(&g_lock); } - snapshot(); struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}}; cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest); diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.frag b/app/src/main/cpp/waylandcomp/src/cardboard.frag index 55167fc88..7d0411ea0 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.frag +++ b/app/src/main/cpp/waylandcomp/src/cardboard.frag @@ -24,6 +24,7 @@ layout(push_constant) uniform PC { vec4 lens; // x: k1, y: k2, z: tan of half the eye's vertical field of view, w: chromatic spread vec4 view; // x: lens centre inset (fraction of an eye viewport), y: source aspect w/h, // z: source mode, w: eye viewport aspect w/h + vec4 extra; } pc; const vec3 BACKDROP = vec3(0.015, 0.015, 0.02); @@ -59,10 +60,11 @@ vec3 eyeSample(vec2 tan, float eye) { } void main() { - float eye = uv.x < 0.5 ? 0.0 : 1.0; - vec2 local = vec2(uv.x * 2.0 - eye, uv.y); + bool mono = pc.extra.x > 0.5; + float eye = mono || uv.x < 0.5 ? 0.0 : 1.0; + vec2 local = mono ? uv : vec2(uv.x * 2.0 - eye, uv.y); // Lens centres sit inset towards the nose by view.x of a viewport. - float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x); + float cx = mono ? 0.5 : 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x); vec2 p = local - vec2(cx, 0.5); float tanH = pc.lens.z; vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH; diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.h b/app/src/main/cpp/waylandcomp/src/cardboard.h index ce39d8645..527dfa16d 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.h +++ b/app/src/main/cpp/waylandcomp/src/cardboard.h @@ -30,6 +30,8 @@ struct cardboard_params { float fov_deg; /* vertical field of view of one eye before distortion */ float lens_inset; /* lens centre inset towards the nose, fraction of an eye viewport */ float chroma; /* chromatic aberration pre-correction (0 = off) */ + int mono; + float display_hz; }; /* Any thread. Values are clamped to sane ranges. */ diff --git a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h index 7b60cd1f0..43ebde085 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h +++ b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h @@ -1,9 +1,9 @@ - // 1116.2.0 + // 1115.1.0 #pragma once const uint32_t cardboard_frag_code[] = { - 0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b, + 0x07230203,0x00010000,0x0008000b,0x00000185,0x00000000,0x00020011,0x00000001,0x0006000b, 0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001, - 0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010, + 0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000f8,0x0000017d,0x00030010, 0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d, 0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b, 0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005, @@ -12,255 +12,273 @@ const uint32_t cardboard_frag_code[] = { 0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e, 0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000, 0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004, - 0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005, - 0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042, - 0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052, - 0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3, - 0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057, - 0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005, - 0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c, - 0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005, - 0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125, - 0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170, - 0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170, - 0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170, - 0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170, - 0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170, - 0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170, - 0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170, - 0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170, - 0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000, - 0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002, - 0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001, - 0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048, - 0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023, - 0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068, - 0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee, - 0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002, - 0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007, - 0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009, - 0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017, - 0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009, - 0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004, - 0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d, - 0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009, - 0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004, - 0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000, - 0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001, - 0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002, - 0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023, - 0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007, - 0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c, - 0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054, - 0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000, - 0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067, - 0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075, - 0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021, - 0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021, - 0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093, - 0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006, - 0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006, - 0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0, - 0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001, - 0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000, - 0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d, - 0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000, - 0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b, - 0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b, - 0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b, - 0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b, - 0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b, - 0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b, - 0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b, - 0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b, - 0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b, - 0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b, - 0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b, - 0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b, - 0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b, - 0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024, - 0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1, - 0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e, - 0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d, - 0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7, - 0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8, - 0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006, - 0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e, - 0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b, - 0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100, - 0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020, - 0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101, - 0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107, - 0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f, - 0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103, - 0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006, - 0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007, - 0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007, - 0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f, - 0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022, - 0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113, - 0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116, - 0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d, - 0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118, - 0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b, - 0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007, - 0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e, - 0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041, - 0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121, - 0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124, - 0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041, - 0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127, - 0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007, - 0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128, - 0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d, - 0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f, - 0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006, - 0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024, - 0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e, - 0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136, - 0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006, - 0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a, - 0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001, - 0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b, - 0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006, - 0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d, - 0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142, - 0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e, - 0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145, - 0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051, - 0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125, - 0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a, - 0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e, - 0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d, - 0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e, - 0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150, - 0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d, - 0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050, - 0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156, - 0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157, - 0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024, - 0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020, - 0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006, - 0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b, - 0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001, - 0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031, - 0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f, - 0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f, - 0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000, - 0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b, - 0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b, - 0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007, - 0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009, - 0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d, - 0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094, - 0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d, - 0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e, - 0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020, - 0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006, - 0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081, - 0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020, - 0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006, - 0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d, - 0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030, - 0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033, - 0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013, - 0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012, - 0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008, - 0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093, - 0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009, - 0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009, - 0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008, - 0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037, - 0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039, - 0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e, - 0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037, - 0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041, - 0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043, - 0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020, - 0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006, - 0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049, - 0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c, - 0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e, - 0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e, - 0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a, - 0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042, - 0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057, - 0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b, - 0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a, - 0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba, - 0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e, - 0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058, - 0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060, - 0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062, - 0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042, - 0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c, - 0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006, - 0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b, - 0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e, - 0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f, - 0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073, - 0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d, - 0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b, - 0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d, - 0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f, - 0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020, - 0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084, - 0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085, - 0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051, - 0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080, - 0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006, - 0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000, - 0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d, - 0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087, - 0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f, - 0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e, - 0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077, - 0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096, - 0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009, - 0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f, - 0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a, - 0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091, - 0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d, - 0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8, - 0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2, - 0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046, - 0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3, - 0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091, - 0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041, - 0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9, - 0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000, - 0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1, - 0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046, - 0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5, - 0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8, - 0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009, - 0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088, - 0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d, - 0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d, - 0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0, - 0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2, - 0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006, - 0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7, - 0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d, - 0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9, - 0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024, - 0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3, - 0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0, - 0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006, - 0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006, - 0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4, - 0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7, - 0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e, - 0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056, - 0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8, - 0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc, - 0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb, - 0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1, - 0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2, - 0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa, - 0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8, - 0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7, - 0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f, - 0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9, - 0x00010038 + 0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00050006, + 0x0000001e,0x00000006,0x72747865,0x00000061,0x00030005,0x00000020,0x00006370,0x00040005, + 0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,0x00000073,0x00040005,0x00000068, + 0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,0x00030005,0x00000094,0x00000064, + 0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,0x00000074,0x00030005,0x000000bd, + 0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,0x00040005,0x000000c6,0x666c6168, + 0x00000048,0x00030005,0x000000cb,0x00000073,0x00040005,0x000000ed,0x6f6e6f6d,0x00000000, + 0x00030005,0x000000f2,0x00657965,0x00030005,0x000000f8,0x00007675,0x00040005,0x000000ff, + 0x61636f6c,0x0000006c,0x00030005,0x00000110,0x00007863,0x00030005,0x00000124,0x00000070, + 0x00040005,0x00000129,0x486e6174,0x00000000,0x00030005,0x0000012c,0x00000074,0x00030005, + 0x0000013d,0x00000063,0x00040005,0x00000141,0x61726170,0x0000006d,0x00040005,0x00000143, + 0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,0x0000006d,0x00040005,0x00000146, + 0x61726170,0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d, + 0x61726170,0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150, + 0x61726170,0x0000006d,0x00040005,0x00000158,0x61726170,0x0000006d,0x00040005,0x0000015a, + 0x61726170,0x0000006d,0x00040005,0x0000015c,0x61726170,0x0000006d,0x00040005,0x0000015d, + 0x61726170,0x0000006d,0x00040005,0x00000163,0x61726170,0x0000006d,0x00040005,0x00000165, + 0x61726170,0x0000006d,0x00040005,0x00000167,0x61726170,0x0000006d,0x00040005,0x00000168, + 0x61726170,0x0000006d,0x00030005,0x0000016b,0x00000065,0x00040005,0x00000171,0x65646166, + 0x00000000,0x00050005,0x0000017d,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e, + 0x00000002,0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e, + 0x00000001,0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020, + 0x00050048,0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004, + 0x00000023,0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00050048, + 0x0000001e,0x00000006,0x00000023,0x00000060,0x00040047,0x00000068,0x00000021,0x00000000, + 0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000f8,0x0000001e,0x00000000, + 0x00040047,0x0000017d,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003, + 0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000002, + 0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,0x00000007,0x00000006, + 0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,0x0000000f,0x00000006, + 0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,0x0004002b,0x00000006, + 0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,0x0009001e,0x0000001e, + 0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x00040020, + 0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,0x00040015, + 0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,0x00040015, + 0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,0x00040020, + 0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,0x0004002b, + 0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,0x0004002b, + 0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,0x00000046, + 0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,0x00000055, + 0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,0x00000007, + 0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,0x00000054, + 0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000, + 0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,0x00000000, + 0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,0x0000000f, + 0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,0x00000078, + 0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,0x0000007d, + 0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,0x00000007, + 0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,0x000000ab, + 0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,0x000000b0, + 0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,0x00040020, + 0x000000ec,0x00000007,0x0000003b,0x0004002b,0x00000021,0x000000ee,0x00000006,0x00040020, + 0x000000f7,0x00000001,0x00000007,0x0004003b,0x000000f7,0x000000f8,0x00000001,0x00040020, + 0x000000f9,0x00000001,0x00000006,0x0004002b,0x00000006,0x00000108,0x40000000,0x0004002b, + 0x00000006,0x00000172,0x3cf5c28f,0x00040020,0x0000017c,0x00000003,0x0000001d,0x0004003b, + 0x0000017c,0x0000017d,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003, + 0x000200f8,0x00000005,0x0004003b,0x000000ec,0x000000ed,0x00000007,0x0004003b,0x00000009, + 0x000000f2,0x00000007,0x0004003b,0x00000008,0x000000ff,0x00000007,0x0004003b,0x00000008, + 0x00000101,0x00000007,0x0004003b,0x00000009,0x00000110,0x00000007,0x0004003b,0x00000009, + 0x00000112,0x00000007,0x0004003b,0x00000009,0x00000118,0x00000007,0x0004003b,0x00000008, + 0x00000124,0x00000007,0x0004003b,0x00000009,0x00000129,0x00000007,0x0004003b,0x00000008, + 0x0000012c,0x00000007,0x0004003b,0x00000093,0x0000013d,0x00000007,0x0004003b,0x00000008, + 0x00000141,0x00000007,0x0004003b,0x00000009,0x00000143,0x00000007,0x0004003b,0x00000008, + 0x00000145,0x00000007,0x0004003b,0x00000009,0x00000146,0x00000007,0x0004003b,0x00000008, + 0x0000014b,0x00000007,0x0004003b,0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008, + 0x0000014f,0x00000007,0x0004003b,0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008, + 0x00000158,0x00000007,0x0004003b,0x00000009,0x0000015a,0x00000007,0x0004003b,0x00000008, + 0x0000015c,0x00000007,0x0004003b,0x00000009,0x0000015d,0x00000007,0x0004003b,0x00000008, + 0x00000163,0x00000007,0x0004003b,0x00000009,0x00000165,0x00000007,0x0004003b,0x00000008, + 0x00000167,0x00000007,0x0004003b,0x00000009,0x00000168,0x00000007,0x0004003b,0x00000008, + 0x0000016b,0x00000007,0x0004003b,0x00000009,0x00000171,0x00000007,0x00060041,0x00000025, + 0x000000ef,0x00000020,0x000000ee,0x00000024,0x0004003d,0x00000006,0x000000f0,0x000000ef, + 0x000500ba,0x0000003b,0x000000f1,0x000000f0,0x0000003a,0x0003003e,0x000000ed,0x000000f1, + 0x0004003d,0x0000003b,0x000000f3,0x000000ed,0x000400a8,0x0000003b,0x000000f4,0x000000f3, + 0x000300f7,0x000000f6,0x00000000,0x000400fa,0x000000f4,0x000000f5,0x000000f6,0x000200f8, + 0x000000f5,0x00050041,0x000000f9,0x000000fa,0x000000f8,0x00000024,0x0004003d,0x00000006, + 0x000000fb,0x000000fa,0x000500b8,0x0000003b,0x000000fc,0x000000fb,0x0000003a,0x000200f9, + 0x000000f6,0x000200f8,0x000000f6,0x000700f5,0x0000003b,0x000000fd,0x000000f3,0x00000005, + 0x000000fc,0x000000f5,0x000600a9,0x00000006,0x000000fe,0x000000fd,0x00000054,0x0000001c, + 0x0003003e,0x000000f2,0x000000fe,0x0004003d,0x0000003b,0x00000100,0x000000ed,0x000300f7, + 0x00000103,0x00000000,0x000400fa,0x00000100,0x00000102,0x00000105,0x000200f8,0x00000102, + 0x0004003d,0x00000007,0x00000104,0x000000f8,0x0003003e,0x00000101,0x00000104,0x000200f9, + 0x00000103,0x000200f8,0x00000105,0x00050041,0x000000f9,0x00000106,0x000000f8,0x00000024, + 0x0004003d,0x00000006,0x00000107,0x00000106,0x00050085,0x00000006,0x00000109,0x00000107, + 0x00000108,0x0004003d,0x00000006,0x0000010a,0x000000f2,0x00050083,0x00000006,0x0000010b, + 0x00000109,0x0000010a,0x00050041,0x000000f9,0x0000010c,0x000000f8,0x0000002b,0x0004003d, + 0x00000006,0x0000010d,0x0000010c,0x00050050,0x00000007,0x0000010e,0x0000010b,0x0000010d, + 0x0003003e,0x00000101,0x0000010e,0x000200f9,0x00000103,0x000200f8,0x00000103,0x0004003d, + 0x00000007,0x0000010f,0x00000101,0x0003003e,0x000000ff,0x0000010f,0x0004003d,0x0000003b, + 0x00000111,0x000000ed,0x000300f7,0x00000114,0x00000000,0x000400fa,0x00000111,0x00000113, + 0x00000115,0x000200f8,0x00000113,0x0003003e,0x00000112,0x0000003a,0x000200f9,0x00000114, + 0x000200f8,0x00000115,0x0004003d,0x00000006,0x00000116,0x000000f2,0x000500b8,0x0000003b, + 0x00000117,0x00000116,0x0000003a,0x000300f7,0x0000011a,0x00000000,0x000400fa,0x00000117, + 0x00000119,0x0000011d,0x000200f8,0x00000119,0x00060041,0x00000025,0x0000011b,0x00000020, + 0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011c,0x0000011b,0x0003003e,0x00000118, + 0x0000011c,0x000200f9,0x0000011a,0x000200f8,0x0000011d,0x00060041,0x00000025,0x0000011e, + 0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011f,0x0000011e,0x0004007f, + 0x00000006,0x00000120,0x0000011f,0x0003003e,0x00000118,0x00000120,0x000200f9,0x0000011a, + 0x000200f8,0x0000011a,0x0004003d,0x00000006,0x00000121,0x00000118,0x00050081,0x00000006, + 0x00000122,0x0000003a,0x00000121,0x0003003e,0x00000112,0x00000122,0x000200f9,0x00000114, + 0x000200f8,0x00000114,0x0004003d,0x00000006,0x00000123,0x00000112,0x0003003e,0x00000110, + 0x00000123,0x0004003d,0x00000007,0x00000125,0x000000ff,0x0004003d,0x00000006,0x00000126, + 0x00000110,0x00050050,0x00000007,0x00000127,0x00000126,0x0000003a,0x00050083,0x00000007, + 0x00000128,0x00000125,0x00000127,0x0003003e,0x00000124,0x00000128,0x00060041,0x00000025, + 0x0000012a,0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x0000012b,0x0000012a, + 0x0003003e,0x00000129,0x0000012b,0x00050041,0x00000009,0x0000012d,0x00000124,0x00000024, + 0x0004003d,0x00000006,0x0000012e,0x0000012d,0x00060041,0x00000025,0x0000012f,0x00000020, + 0x00000036,0x00000046,0x0004003d,0x00000006,0x00000130,0x0000012f,0x00050085,0x00000006, + 0x00000131,0x0000012e,0x00000130,0x00050041,0x00000009,0x00000132,0x00000124,0x0000002b, + 0x0004003d,0x00000006,0x00000133,0x00000132,0x00050050,0x00000007,0x00000134,0x00000131, + 0x00000133,0x0005008e,0x00000007,0x00000135,0x00000134,0x00000108,0x0004003d,0x00000006, + 0x00000136,0x00000129,0x0005008e,0x00000007,0x00000137,0x00000135,0x00000136,0x0003003e, + 0x0000012c,0x00000137,0x00060041,0x00000025,0x00000138,0x00000020,0x00000022,0x00000046, + 0x0004003d,0x00000006,0x00000139,0x00000138,0x000500ba,0x0000003b,0x0000013a,0x00000139, + 0x00000054,0x000300f7,0x0000013c,0x00000000,0x000400fa,0x0000013a,0x0000013b,0x00000162, + 0x000200f8,0x0000013b,0x00060041,0x00000025,0x0000013e,0x00000020,0x00000022,0x00000046, + 0x0004003d,0x00000006,0x0000013f,0x0000013e,0x00050083,0x00000006,0x00000140,0x0000001c, + 0x0000013f,0x0004003d,0x00000007,0x00000142,0x0000012c,0x0003003e,0x00000141,0x00000142, + 0x0003003e,0x00000143,0x00000140,0x00060039,0x00000007,0x00000144,0x0000000d,0x00000141, + 0x00000143,0x0003003e,0x00000145,0x00000144,0x0004003d,0x00000006,0x00000147,0x000000f2, + 0x0003003e,0x00000146,0x00000147,0x00060039,0x0000000f,0x00000148,0x00000013,0x00000145, + 0x00000146,0x00050051,0x00000006,0x00000149,0x00000148,0x00000000,0x00050041,0x00000009, + 0x0000014a,0x0000013d,0x00000024,0x0003003e,0x0000014a,0x00000149,0x0004003d,0x00000007, + 0x0000014c,0x0000012c,0x0003003e,0x0000014b,0x0000014c,0x0003003e,0x0000014d,0x0000001c, + 0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,0x0003003e,0x0000014f, + 0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000f2,0x0003003e,0x00000150,0x00000151, + 0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,0x00050051,0x00000006, + 0x00000153,0x00000152,0x00000001,0x00050041,0x00000009,0x00000154,0x0000013d,0x0000002b, + 0x0003003e,0x00000154,0x00000153,0x00060041,0x00000025,0x00000155,0x00000020,0x00000022, + 0x00000046,0x0004003d,0x00000006,0x00000156,0x00000155,0x00050081,0x00000006,0x00000157, + 0x0000001c,0x00000156,0x0004003d,0x00000007,0x00000159,0x0000012c,0x0003003e,0x00000158, + 0x00000159,0x0003003e,0x0000015a,0x00000157,0x00060039,0x00000007,0x0000015b,0x0000000d, + 0x00000158,0x0000015a,0x0003003e,0x0000015c,0x0000015b,0x0004003d,0x00000006,0x0000015e, + 0x000000f2,0x0003003e,0x0000015d,0x0000015e,0x00060039,0x0000000f,0x0000015f,0x00000013, + 0x0000015c,0x0000015d,0x00050051,0x00000006,0x00000160,0x0000015f,0x00000002,0x00050041, + 0x00000009,0x00000161,0x0000013d,0x00000037,0x0003003e,0x00000161,0x00000160,0x000200f9, + 0x0000013c,0x000200f8,0x00000162,0x0004003d,0x00000007,0x00000164,0x0000012c,0x0003003e, + 0x00000163,0x00000164,0x0003003e,0x00000165,0x0000001c,0x00060039,0x00000007,0x00000166, + 0x0000000d,0x00000163,0x00000165,0x0003003e,0x00000167,0x00000166,0x0004003d,0x00000006, + 0x00000169,0x000000f2,0x0003003e,0x00000168,0x00000169,0x00060039,0x0000000f,0x0000016a, + 0x00000013,0x00000167,0x00000168,0x0003003e,0x0000013d,0x0000016a,0x000200f9,0x0000013c, + 0x000200f8,0x0000013c,0x0004003d,0x00000007,0x0000016c,0x000000ff,0x0004003d,0x00000007, + 0x0000016d,0x000000ff,0x00050050,0x00000007,0x0000016e,0x0000001c,0x0000001c,0x00050083, + 0x00000007,0x0000016f,0x0000016e,0x0000016d,0x0007000c,0x00000007,0x00000170,0x00000001, + 0x00000025,0x0000016c,0x0000016f,0x0003003e,0x0000016b,0x00000170,0x00050041,0x00000009, + 0x00000173,0x0000016b,0x00000024,0x0004003d,0x00000006,0x00000174,0x00000173,0x00060041, + 0x00000025,0x00000175,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000176, + 0x00000175,0x00050085,0x00000006,0x00000177,0x00000174,0x00000176,0x00050041,0x00000009, + 0x00000178,0x0000016b,0x0000002b,0x0004003d,0x00000006,0x00000179,0x00000178,0x0007000c, + 0x00000006,0x0000017a,0x00000001,0x00000025,0x00000177,0x00000179,0x0008000c,0x00000006, + 0x0000017b,0x00000001,0x00000031,0x00000054,0x00000172,0x0000017a,0x0003003e,0x00000171, + 0x0000017b,0x0004003d,0x0000000f,0x0000017e,0x0000013d,0x0004003d,0x00000006,0x0000017f, + 0x00000171,0x0005008e,0x0000000f,0x00000180,0x0000017e,0x0000017f,0x00050051,0x00000006, + 0x00000181,0x00000180,0x00000000,0x00050051,0x00000006,0x00000182,0x00000180,0x00000001, + 0x00050051,0x00000006,0x00000183,0x00000180,0x00000002,0x00070050,0x0000001d,0x00000184, + 0x00000181,0x00000182,0x00000183,0x0000001c,0x0003003e,0x0000017d,0x00000184,0x000100fd, + 0x00010038,0x00050036,0x00000007,0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008, + 0x0000000b,0x00030037,0x00000009,0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009, + 0x00000015,0x00000007,0x0004003d,0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007, + 0x00000017,0x0000000b,0x00050094,0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e, + 0x00000015,0x00000018,0x0004003d,0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006, + 0x0000001a,0x0000000c,0x0005008e,0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041, + 0x00000025,0x00000026,0x00000020,0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027, + 0x00000026,0x0004003d,0x00000006,0x00000028,0x00000015,0x00050085,0x00000006,0x00000029, + 0x00000027,0x00000028,0x00050081,0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041, + 0x00000025,0x0000002c,0x00000020,0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d, + 0x0000002c,0x0004003d,0x00000006,0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f, + 0x0000002d,0x0000002e,0x0004003d,0x00000006,0x00000030,0x00000015,0x00050085,0x00000006, + 0x00000031,0x0000002f,0x00000030,0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031, + 0x0005008e,0x00000007,0x00000033,0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038, + 0x00050036,0x0000000f,0x00000013,0x00000000,0x00000010,0x00030037,0x00000008,0x00000011, + 0x00030037,0x00000009,0x00000012,0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f, + 0x00000007,0x0004003b,0x00000008,0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077, + 0x00000007,0x0004003b,0x00000093,0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f, + 0x00000007,0x0004003b,0x00000009,0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd, + 0x00000007,0x0004003b,0x00000009,0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6, + 0x00000007,0x0004003b,0x00000008,0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038, + 0x00000020,0x00000036,0x00000037,0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba, + 0x0000003b,0x0000003c,0x00000039,0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa, + 0x0000003c,0x0000003d,0x0000003e,0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040, + 0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e, + 0x0000003f,0x00000041,0x00050041,0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d, + 0x00000006,0x00000044,0x00000043,0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041, + 0x00000025,0x00000047,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048, + 0x00000047,0x00050085,0x00000006,0x00000049,0x00000045,0x00000048,0x00050088,0x00000006, + 0x0000004a,0x00000044,0x00000049,0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b, + 0x0004003d,0x00000006,0x0000004c,0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f, + 0x00050088,0x00000006,0x0000004e,0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f, + 0x0000004a,0x0000004e,0x0005008e,0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050, + 0x00000007,0x00000051,0x0000003a,0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050, + 0x00000051,0x0003003e,0x00000042,0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042, + 0x000500b8,0x00000056,0x00000057,0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058, + 0x00000057,0x000400a8,0x0000003b,0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000, + 0x000400fa,0x00000059,0x0000005a,0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007, + 0x0000005c,0x00000042,0x000500ba,0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a, + 0x0000003b,0x0000005f,0x0000005e,0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5, + 0x0000003b,0x00000060,0x00000058,0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062, + 0x00000000,0x000400fa,0x00000060,0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe, + 0x00000063,0x000200f8,0x00000062,0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041, + 0x00000009,0x0000006a,0x00000042,0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a, + 0x0004003d,0x00000006,0x0000006c,0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b, + 0x0000006c,0x00050085,0x00000006,0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009, + 0x0000006f,0x00000042,0x0000002b,0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050, + 0x00000007,0x00000071,0x0000006e,0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069, + 0x00000071,0x0008004f,0x0000000f,0x00000073,0x00000072,0x00000072,0x00000000,0x00000001, + 0x00000002,0x000200fe,0x00000073,0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a, + 0x00000020,0x00000078,0x0004003d,0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f, + 0x0000007c,0x0000007b,0x0000007b,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079, + 0x0000007e,0x00000020,0x0000007d,0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f, + 0x0000000f,0x00000080,0x0000007f,0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041, + 0x00000079,0x00000082,0x00000020,0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082, + 0x0008004f,0x0000000f,0x00000084,0x00000083,0x00000083,0x00000000,0x00000001,0x00000002, + 0x00050051,0x00000006,0x00000085,0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086, + 0x0000007c,0x00000001,0x00050051,0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051, + 0x00000006,0x00000088,0x00000080,0x00000000,0x00050051,0x00000006,0x00000089,0x00000080, + 0x00000001,0x00050051,0x00000006,0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006, + 0x0000008b,0x00000084,0x00000000,0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001, + 0x00050051,0x00000006,0x0000008d,0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e, + 0x00000085,0x00000086,0x00000087,0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089, + 0x0000008a,0x00060050,0x0000000f,0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050, + 0x00000075,0x00000091,0x0000008e,0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092, + 0x00000091,0x0003003e,0x00000077,0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077, + 0x00050041,0x00000009,0x00000096,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097, + 0x00000096,0x00050041,0x00000009,0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006, + 0x00000099,0x00000098,0x0004007f,0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f, + 0x0000009c,0x00000097,0x0000009a,0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001, + 0x00000045,0x0000009c,0x00050091,0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e, + 0x00000094,0x0000009e,0x0004003d,0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006, + 0x000000a1,0x00000012,0x000500b8,0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9, + 0x00000006,0x000000a3,0x000000a2,0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4, + 0x00000020,0x00000078,0x00000046,0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085, + 0x00000006,0x000000a6,0x000000a3,0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6, + 0x00000054,0x00000054,0x00050091,0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e, + 0x0000009f,0x000000a8,0x00050041,0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d, + 0x00000006,0x000000aa,0x000000a9,0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab, + 0x000300f7,0x000000ae,0x00000000,0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8, + 0x000000ad,0x000200fe,0x000000b1,0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4, + 0x00000020,0x0000007d,0x00000046,0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f, + 0x00000006,0x000000b6,0x000000b5,0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037, + 0x0004003d,0x00000006,0x000000b8,0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6, + 0x000000b8,0x00050041,0x00000009,0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006, + 0x000000bb,0x000000ba,0x00050088,0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e, + 0x000000b3,0x000000bc,0x0004003d,0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f, + 0x000000bf,0x00000094,0x0004003d,0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f, + 0x000000c1,0x000000bf,0x000000c0,0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1, + 0x0003003e,0x000000bd,0x000000c2,0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081, + 0x00000046,0x0004003d,0x00000006,0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5, + 0x0004003d,0x00000006,0x000000c7,0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020, + 0x00000036,0x0000002b,0x0004003d,0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006, + 0x000000ca,0x000000c7,0x000000c9,0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009, + 0x000000cc,0x000000bd,0x00000024,0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d, + 0x00000006,0x000000ce,0x000000c3,0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce, + 0x00050041,0x00000009,0x000000d0,0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1, + 0x000000d0,0x0004007f,0x00000006,0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3, + 0x000000c6,0x00050088,0x00000006,0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007, + 0x000000d5,0x000000cf,0x000000d4,0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a, + 0x00050050,0x00000007,0x000000d7,0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8, + 0x000000d6,0x000000d7,0x0003003e,0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9, + 0x000000cb,0x000500b8,0x00000056,0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b, + 0x000000db,0x000000da,0x000400a8,0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de, + 0x00000000,0x000400fa,0x000000dc,0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d, + 0x00000007,0x000000df,0x000000cb,0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d, + 0x0004009a,0x0000003b,0x000000e1,0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de, + 0x000700f5,0x0000003b,0x000000e2,0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7, + 0x000000e4,0x00000000,0x000400fa,0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3, + 0x000200fe,0x000000b1,0x000200f8,0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068, + 0x0004003d,0x00000007,0x000000e7,0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6, + 0x000000e7,0x0008004f,0x0000000f,0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001, + 0x00000002,0x000200fe,0x000000e9,0x00010038 }; diff --git a/app/src/main/cpp/waylandcomp/src/headpose_shm.h b/app/src/main/cpp/waylandcomp/src/headpose_shm.h index 13c71a7f8..2c56dc7f7 100644 --- a/app/src/main/cpp/waylandcomp/src/headpose_shm.h +++ b/app/src/main/cpp/waylandcomp/src/headpose_shm.h @@ -22,6 +22,7 @@ enum { DROIDDECK_HEADPOSE_LIVE = 1u << 0, /* a rotation sensor is feeding it (else identity) */ DROIDDECK_HEADPOSE_ENABLED = 1u << 1, /* Cardboard mode is on */ + DROIDDECK_HEADPOSE_MONO = 1u << 2, }; struct droiddeck_headpose { @@ -37,7 +38,8 @@ struct droiddeck_headpose { float tan_half_fov; float eye_aspect; uint32_t eye_w, eye_h; /* one eye's viewport on the panel, pixels (0 = not drawn yet) */ - uint32_t reserved[12]; + float display_hz; + uint32_t reserved[11]; }; #ifdef __cplusplus diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 4a9dd4d23..03d538c2f 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -467,10 +467,11 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI /* Cardboard mode - WaylandCompositor.nativeSetCardboard. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled, - jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) { + jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma, + jboolean mono, jfloat hz) { struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist, .screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset, - .chroma = chroma}; + .chroma = chroma, .mono = mono ? 1 : 0, .display_hz = hz}; vkp_cardboard_set(&p); } diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 1b1a2fd82..6560c11d7 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -26,6 +26,7 @@ class App : Application() { com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) com.droiddeck.launcher.update.AppUpdates.init(this) GpuClockPin.clearLeftover(this) + com.droiddeck.launcher.glasses.Glasses.init(this) BwrapSpawner.start(this) com.droiddeck.launcher.frontend.GameFileSync.start(this) // The game stores the Stores section can talk to; the native engine loads only when one needs it. diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index a8d9ccd0a..a9ba8f539 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -52,6 +52,11 @@ import com.droiddeck.launcher.ui.SessionAchievements import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.HeadTracker +import com.droiddeck.launcher.glasses.Glasses +import com.droiddeck.launcher.glasses.GlassesScreen +import com.droiddeck.launcher.glasses.GlassesSettings +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import com.droiddeck.launcher.gpu.SteamVr import com.droiddeck.launcher.gpu.ScreenEffectLooks import com.droiddeck.launcher.gpu.ScreenEffects @@ -308,6 +313,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var steamVrSwitchedPicture by mutableStateOf(false) /** SteamVR has been seen running since the switch: only then does "not running" mean it stopped. */ private var steamVrSeenRunning = false + private var activeCardboard = Cardboard.OFF + private var glasses by mutableStateOf(Glasses.state.value) + private var glassesSettings by mutableStateOf(Glasses.settings.value) + private var glassesScreen: GlassesScreen? = null + private var glassesOutputActive by mutableStateOf(false) + private var glasses3dForVr = false + private var glassesSlot by mutableStateOf(0) + private var glassesKey: List = emptyList() + private val glassesScope = kotlinx.coroutines.MainScope() private val hasRotationSensor by lazy { (getSystemService(SENSOR_SERVICE) as android.hardware.SensorManager).let { it.getDefaultSensor(android.hardware.Sensor.TYPE_GAME_ROTATION_VECTOR) @@ -510,6 +524,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { sessionOverlay = createSessionOverlay() root.addView(sessionOverlay) setContentView(root) + glassesScope.launch { Glasses.state.collect { glasses = it; onGlassesChanged() } } + glassesScope.launch { Glasses.settings.collect { glassesSettings = it; onGlassesChanged() } } pip = com.droiddeck.launcher.session.SessionPipController(this, ::pipBounds, ::showPipUi) surfaceView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> pip.refresh() } pip.refresh() @@ -565,6 +581,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) + if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model) + if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot, + controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) { + drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES + drawerOpen = true + } if (loading.visible) LoadingOverlay( loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended, topic = loading.topic, readable = loading.readable, @@ -596,8 +618,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // SteamVR's state for the Cardboard group, refreshed while the drawer shows it. // Watched for as long as Cardboard mode is on (or the picture is switched for it), drawer open or // not: however SteamVR is started or quit, the picture follows it. - LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) { - while (cardboard.enabled || steamVrSwitchedPicture) { + val glassesVr = glassesOutputActive && glasses.canControl + LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture, glassesVr) { + while (cardboard.enabled || steamVrSwitchedPicture || glassesVr) { val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { SteamVr.status(this@SessionActivity) } @@ -634,6 +657,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cardboardSensor = hasRotationSensor, onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) }, onCardboardRecenter = { headTracker?.recenter() }, + glassesPresent = glasses.present, + glassesOutput = glassesOutputActive, + onGlassesRecenter = { headTracker?.recenter() }, steamVr = steamVrStatus, onSteamVr = { action -> steamVrAction(action) }, textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy, @@ -1337,6 +1363,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun surfaceCreated(holder: SurfaceHolder) { + if (holder !== outputHolder()) return if (installingRuntime || SessionState.stopRequested) return if (!SessionState.running) { SessionEvents.transition(SessionPhase.STARTING_COMPOSITOR, "compositor.starting") @@ -1361,7 +1388,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val live = if (SessionState.followScreen && !isInPictureInPictureMode) com.droiddeck.launcher.session.SessionDisplay.followSize(holder.surfaceFrame.width(), holder.surfaceFrame.height()) else null - val size = live ?: if (SessionState.running) previous else outputSize() + val size = live ?: if (SessionState.running) previous else glassesOutputSize() ?: outputSize() SessionState.outputSize = size if (!SessionState.running) SessionState.refreshHz = refreshHz() // How far the panel enlarges the session, for texture sharpness "Auto" (TextureFiltering): @@ -1378,7 +1405,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this)) WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this)) SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this)) - panelSizeMm().let { (w, h) -> SessionPrefs.cardboard(this).push(w, h) } + activeCardboard = effectiveCardboard(SessionPrefs.cardboard(this)) + pushCardboard(activeCardboard) WaylandCompositor.nativeSetCardboardPoseFile(File(filesDir, ".wayland-rt/droiddeck-headpose").path) // The session's folder, claimed here because the compositor starts before the service and // opens its log once. The compositor reads the path from its environment; setting it after @@ -1448,6 +1476,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var surfaceH = 0 override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (holder !== outputHolder()) return val resized = surfaceW != 0 && (width != surfaceW || height != surfaceH) surfaceW = width surfaceH = height @@ -1542,6 +1571,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * active; it is the rate gamescope gives Steam, and the QAM's Frame Limit divides it. */ private fun refreshHz(): Float { + if (glassesScreen != null) glasses.hz.takeIf { it > 1f }?.let { return it } val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay val wanted = window.attributes.preferredDisplayModeId val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate @@ -2005,6 +2035,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!::displayManager.isInitialized) return val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId val candidates = SecondScreenDisplays.available(displayManager, primaryId) + .filter { glassesScreen == null || it.id != glasses.displayId } val oldIds = secondScreenDisplays.map { it.id }.toSet() secondScreenDisplays = candidates val newIds = candidates.map { it.id }.toSet() @@ -2357,6 +2388,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun updateOnScreenControls() { + updateOnScreenPad() + val c = onScreenControls + glassesSlot = when { + c == null || c.visibility != View.VISIBLE -> 0 + c.isButtonsOnly -> -1 + controllerSettings?.keyboardButton != false -> 2 + else -> 1 + } + } + + private fun updateOnScreenPad() { val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc") .takeIf { it.isFile } ?.let { FileUtils.readString(it)?.trim()?.lowercase() } @@ -2487,6 +2529,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // The panel may have changed while the session was a thumbnail (folded or unfolded in PiP). if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH) applyUnfoldedControls() + updateGlassesScreen() } override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { @@ -2506,6 +2549,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) SessionService.setActivityVisible(this, true) + Glasses.rescan() + updateGlassesScreen() } override fun onStop() { @@ -2515,6 +2560,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Closed while the session is out of sight (sleep, a closed lid, another app), and kept: // onStart puts it back. closeSecondScreen(reset = false) + updateGlassesScreen() SessionService.setActivityVisible(this, false) super.onStop() } @@ -2591,6 +2637,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** SteamVR for Cardboard mode: install, start or stop it in the guest. Starting switches the * picture to side-by-side, which is what SteamVR draws. */ private fun steamVrAction(action: String) { + if (glassesOutputActive) { + if (action == "start" && glassesSettings.auto3d && glasses.can3d && !glasses.sbs) { + glasses3dForVr = true + Glasses.set3d(true) + } + if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING + Thread({ + val ok = SteamVr.run(this, action) + val now = SteamVr.status(this) + uiHandler.post { + onSteamVrStatus(now) + if (!ok) Toast.makeText(this, getString(R.string.cardboard_steamvr_failed), Toast.LENGTH_LONG).show() + } + }, "steamvr-$action").start() + return + } if (action == "start") { switchPictureForSteamVr() steamVrSeenRunning = false @@ -2621,7 +2683,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun onSteamVrStatus(now: SteamVr.Status) { + val was = steamVrStatus steamVrStatus = now + if (glassesOutputActive) { + onGlassesSteamVr(now) + if ((was == SteamVr.Status.RUNNING) != (now == SteamVr.Status.RUNNING)) applyCardboard(cardboard) + return + } // Started some other way than the drawer (a script, a VR game): the picture follows it all the same. if (now == SteamVr.Status.RUNNING && cardboard.enabled && !steamVrSwitchedPicture && cardboard.source != Cardboard.SOURCE_SBS) { @@ -2647,25 +2715,124 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** Cardboard mode: hand the settings to the compositor and run the head tracker while it needs one. */ private fun applyCardboard(c: Cardboard) { - val was = cardboard + val was = activeCardboard cardboard = c - if (CompositorHost.isStarted) panelSizeMm().let { (w, h) -> c.push(w, h) } - if (c.enabled && !was.enabled) headTracker?.recenter() + val e = effectiveCardboard(c) + activeCardboard = e + if (CompositorHost.isStarted) pushCardboard(e) + if (e.enabled && !was.enabled) headTracker?.recenter() updateHeadTracker() } + private fun pushCardboard(c: Cardboard) { + val fov = if (c.viewer == Cardboard.Viewer.VITURE.id) glasses.verticalFovDeg else 0f + panelSizeMm().let { (w, h) -> c.push(w, h, fov, refreshHz()) } + } + + private fun effectiveCardboard(c: Cardboard): Cardboard { + if (glassesScreen == null) return c + val s = glassesSettings + val source = when (s.picture3d) { + GlassesSettings.PICTURE_SBS -> Cardboard.SOURCE_SBS + GlassesSettings.PICTURE_SCREEN -> Cardboard.SOURCE_SCREEN + else -> when { + steamVrStatus == SteamVr.Status.RUNNING -> Cardboard.SOURCE_SBS + glasses.sbs -> Cardboard.SOURCE_SCREEN + else -> return Cardboard.OFF + } + } + return Cardboard( + enabled = true, source = source, + viewer = Cardboard.Viewer.VITURE.id, ipdMm = c.ipdMm, + screenWidthDm = s.screenWidthDm, screenDistanceDm = s.screenDistanceDm, + headTracking = s.headTracking && glasses.canControl, + mono = !glasses.sbs, + ) + } + private fun updateHeadTracker() { // Side-by-side sources track too: the compositor leaves their picture alone, but whoever renders // it (the SteamVR driver, tools/vr) reads the pose from the shared file. - val want = resumed && cardboard.enabled && cardboard.headTracking + val onGlasses = glassesScreen != null + val c = activeCardboard + val want = (resumed || (onGlasses && started)) && c.enabled && c.headTracking + val current = headTracker + if (current != null && (!want || current.usesGlasses != onGlasses)) { + current.stop() + headTracker = null + } if (want) { - val t = headTracker ?: HeadTracker(this) { + val t = headTracker ?: HeadTracker(this, glasses = onGlasses) { @Suppress("DEPRECATION") (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 }.also { headTracker = it } t.start() - } else { - headTracker?.stop() + } + } + + private fun outputHolder(): SurfaceHolder = glassesScreen?.holder ?: surfaceView.holder + + private fun glassesOutputSize(): Pair? { + if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null + val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height + val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, SessionPrefs.resolutionChoice(this, mode, panel)) + } + + private fun onGlassesChanged() { + val g = glasses + val s = glassesSettings + val key = listOf(g.usable, g.displayId, g.sbs, g.canControl, g.width, g.height, g.hz, s) + if (key == glassesKey) return + glassesKey = key + updateGlassesScreen() + applyCardboard(cardboard) + } + + private fun updateGlassesScreen() { + if (!::surfaceView.isInitialized) return + val g = glasses + val want = started && !isInPictureInPictureMode && !runtimeRemovalBlocked && g.usable && glassesSettings.gameOnGlasses + val target = if (want) Glasses.glassesDisplay()?.takeIf { it.isValid } else null + val current = glassesScreen + if (current != null && current.display.displayId == target?.displayId) return + if (current != null) { + glassesScreen = null + glassesOutputActive = false + surfaceW = 0 + surfaceH = 0 + runCatching { current.dismiss() } + CompositorHost.detach(current.holder.surface) + if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder) + } + if (target != null) { + val screen = GlassesScreen(this, target, this) + try { + screen.show() + } catch (e: Exception) { + Log.w(TAG, "could not show the game on ${target.name}", e) + return + } + glassesScreen = screen + glassesOutputActive = true + surfaceW = 0 + surfaceH = 0 + Log.i(TAG, "game output on ${g.model} (display ${target.displayId})") + } + refreshSecondScreenDisplays() + applyCardboard(cardboard) + } + + private fun onGlassesSteamVr(now: SteamVr.Status) { + val g = glasses + if (now == SteamVr.Status.RUNNING) { + if (glassesSettings.auto3d && g.can3d && !g.sbs && !glasses3dForVr) { + glasses3dForVr = true + Glasses.set3d(true) + } + } else if (glasses3dForVr && now != SteamVr.Status.UNAVAILABLE) { + glasses3dForVr = false + if (g.sbs) Glasses.set3d(false) } } @@ -2745,6 +2912,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { pendingBackAction = null if (::pip.isInitialized) pip.destroyed() closeSecondScreen(reset = false) + glassesScope.cancel() + glassesScreen?.let { screen -> glassesScreen = null; runCatching { screen.dismiss() } } + headTracker?.stop() if (::hud.isInitialized) hud.stop() padBridge?.stop() steamController?.stop() diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index 2b8ba775b..4c5861a42 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -101,11 +101,58 @@ class AgentBridgeProvider : ContentProvider() { AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override") } "prefs" -> prefs(context, request) + "glasses" -> glasses(context, request) "access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context)) .put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath) else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'") } + private fun glasses(context: Context, request: JSONObject): JSONObject { + val g = com.droiddeck.launcher.glasses.Glasses + when (request.optString("op", "status")) { + "status" -> {} + "allow" -> g.requestControl() + "set" -> { + val key = request.optString("key") + val value = request.optString("value") + fun on() = value == "1" || value.equals("true", true) || value.equals("on", true) + val number = value.toIntOrNull() + val numeric = setOf("refreshHz", "sbsHz", "brightness", "volume", "picture3d", "screenWidthDm", "screenDistanceDm", "sbsSupport") + if (key in numeric && number == null) return error("INVALID_VALUE", "'$key' takes a number") + when (key) { + "refreshHz" -> g.setRefreshHz(number!!) + "sbsHz" -> g.setSbsHz(number!!) + "brightness" -> g.setBrightness(number!!) + "volume" -> g.setVolume(number!!) + "sunblock" -> g.setSunblock(on()) + "3d" -> g.set3d(on()) + "gameOnGlasses" -> g.setGameOnGlasses(on()) + "headTracking" -> g.setHeadTracking(on()) + "auto3d" -> g.setAuto3d(on()) + "picture3d" -> g.setPicture3d(number!!) + "screenWidthDm" -> g.setScreenWidthDm(number!!) + "screenDistanceDm" -> g.setScreenDistanceDm(number!!) + "sbsSupport" -> g.setSbsSupport(number!!) + else -> return error("INVALID_KEY", "Unknown glasses setting '$key'") + } + } + "simulate" -> if (!g.simulate(request.optInt("seconds", 20).coerceIn(1, 600))) return error("BUSY", "Already simulating") + "stop-simulate" -> g.stopSimulation() + "query" -> { g.query(); Thread.sleep(600) } + "mode" -> { g.sendMode(request.optString("mode").removePrefix("0x").toInt(16)); Thread.sleep(600) } + "imu" -> return g.sampleImu(request.optLong("ms", 2000).coerceIn(200, 20_000)).put("ok", true).put("command", "glasses") + "raw" -> { + AgentAccess.requireCommands(context) + val payload = request.optString("payload").chunked(2).filter { it.length == 2 }.map { it.toInt(16).toByte() }.toByteArray() + if (!g.sendRaw(request.optString("msgId").removePrefix("0x").toInt(16), payload)) return error("NOT_CONNECTED", "The glasses' control link is not open") + Thread.sleep(300) + } + else -> return error("INVALID_OP", "Unknown glasses op") + } + if (request.optString("op") in setOf("set", "allow")) Thread.sleep(400) + return g.status().put("ok", true).put("command", "glasses").put("frames", g.recentFrames()) + } + private fun state(context: Context): JSONObject { val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context) val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull() diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt new file mode 100644 index 000000000..b8d90626f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt @@ -0,0 +1,562 @@ +package com.droiddeck.launcher.glasses + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.display.DisplayManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.SystemClock +import android.util.Log +import android.view.Display +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.sin + +data class GlassesState( + val present: Boolean = false, + val model: String = "VITURE", + val controllable: Boolean = false, + val permission: Glasses.Permission = Glasses.Permission.NONE, + val connected: Boolean = false, + val displayId: Int = Display.INVALID_DISPLAY, + val width: Int = 0, + val height: Int = 0, + val hz: Float = 0f, + val sbs: Boolean = false, + val brightness: Int = -1, + val volume: Int = -1, + val brightnessMax: Int = 8, + val volumeMax: Int = 8, + val sunblock: Boolean = false, + val tracking: Boolean = false, + val diagonalFovDeg: Float = 46f, + val noSignal: Boolean = false, + val sbsSupport: Int = SBS_UNKNOWN, + val sbs90: Boolean = false, +) { + val hasDisplay: Boolean get() = displayId != Display.INVALID_DISPLAY + val usable: Boolean get() = hasDisplay && !noSignal + val canControl: Boolean get() = connected && controllable + val can3d: Boolean get() = canControl && !noSignal && sbsSupport != SBS_FAILS + val verticalFovDeg: Float get() { + val half = Math.toRadians(diagonalFovDeg / 2.0) + val aspect = 16.0 / 9.0 + val v = kotlin.math.atan(kotlin.math.tan(half) / kotlin.math.sqrt(1 + aspect * aspect)) + return Math.toDegrees(2 * v).toFloat() + } + + companion object { + const val SBS_UNKNOWN = 0 + const val SBS_WORKS = 1 + const val SBS_FAILS = -1 + } +} + +data class GlassesSettings( + val refreshHz: Int = 120, + val sbsHz: Int = 60, + val sunblock: Boolean? = null, + val gameOnGlasses: Boolean = true, + val headTracking: Boolean = true, + val auto3d: Boolean = true, + val picture3d: Int = PICTURE_AUTO, + val screenWidthDm: Int = 20, + val screenDistanceDm: Int = 30, +) { + companion object { + const val PICTURE_AUTO = 0 + const val PICTURE_SCREEN = 1 + const val PICTURE_SBS = 2 + } +} + +@SuppressLint("StaticFieldLeak") +object Glasses { + enum class Permission { NONE, ASKING, DENIED, GRANTED } + + fun interface PoseSink { + fun onPose(x: Float, y: Float, z: Float, w: Float, timeNs: Long) + } + + private const val TAG = "Glasses" + private const val ACTION_PERMISSION = "com.droiddeck.launcher.glasses.USB_PERMISSION" + private const val PREFS = "glasses" + private const val KEY_SBS_SUPPORT = "sbsSupport" + private const val SBS_CONFIRM_MS = 20_000L + + private val _state = MutableStateFlow(GlassesState()) + val state: StateFlow = _state.asStateFlow() + private val _settings = MutableStateFlow(GlassesSettings()) + val settings: StateFlow = _settings.asStateFlow() + + private lateinit var app: Context + private lateinit var handler: Handler + private lateinit var usb: UsbManager + private lateinit var displays: DisplayManager + @Volatile private var link: VitureLink? = null + private var linkDevice: UsbDevice? = null + private var model: VitureProtocol.Model? = null + private var asked = false + private var lastModeSentAt = 0L + private var sbsAskedAt = 0L + + @Volatile private var sink: PoseSink? = null + @Volatile private var probe: ((ByteArray) -> Unit)? = null + @Volatile private var latest: FloatArray? = null + @Volatile private var imuPackets = 0L + private val simulating = java.util.concurrent.atomic.AtomicReference(null) + private val frames = ArrayDeque() + + fun init(context: Context) { + if (::handler.isInitialized) return + app = context.applicationContext + 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)) + handler = Handler(HandlerThread("droiddeck-glasses").apply { start() }.looper) + handler.post { + val filter = IntentFilter(ACTION_PERMISSION).apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= 33) app.registerReceiver(receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED) + else app.registerReceiver(receiver, filter, null, handler) + displays.registerDisplayListener(displayListener, handler) + refresh() + } + } + + fun rescan() = post { refresh() } + + fun query() = post { + val l = link ?: return@post + listOf(VitureProtocol.MSG_GET_DISPLAY, VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_IMU).forEachIndexed { i, msg -> + handler.postDelayed({ if (link === l) l.send(msg) }, 60L * i) + } + } + + fun sendMode(mode: Int) = post { + if (!_state.value.canControl) return@post + if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) lastModeSentAt = SystemClock.uptimeMillis() + } + + fun requestControl() = post { + asked = false + refresh(ask = true) + } + + fun setRefreshHz(hz: Int) = post { + update { it.copy(refreshHz = hz.coerceIn(60, 120)) } + if (!_state.value.sbs) applyMode(force = true) + } + + fun setSbsHz(hz: Int) = post { + update { it.copy(sbsHz = if (hz >= 90) 90 else 60) } + if (_state.value.sbs && _state.value.can3d) sendMode(true) + } + + fun set3d(on: Boolean) = post { + val s = _state.value + if (if (on) s.can3d else s.canControl) sendMode(on) + } + + fun setBrightness(level: Int) = post { + val s = _state.value + if (!s.canControl) return@post + val v = level.coerceIn(0, s.brightnessMax) + if (link?.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(v.toByte(), 0)) == true) publish { it.copy(brightness = v) } + } + + fun setVolume(level: Int) = post { + val s = _state.value + if (!s.canControl) return@post + val v = level.coerceIn(0, s.volumeMax) + if (link?.send(VitureProtocol.MSG_VOLUME, byteArrayOf(v.toByte(), 0)) == true) publish { it.copy(volume = v) } + } + + fun setSunblock(on: Boolean) = post { + update { it.copy(sunblock = on) } + sendFilm(on) + } + + fun setSbsSupport(support: Int) = post { rememberSbs(support.coerceIn(GlassesState.SBS_FAILS, GlassesState.SBS_WORKS)) } + fun setGameOnGlasses(on: Boolean) = post { update { it.copy(gameOnGlasses = on) } } + fun setHeadTracking(on: Boolean) = post { update { it.copy(headTracking = on) } } + fun setAuto3d(on: Boolean) = post { update { it.copy(auto3d = on) } } + fun setPicture3d(picture: Int) = post { update { it.copy(picture3d = picture.coerceIn(0, 2)) } } + fun setScreenWidthDm(dm: Int) = post { update { it.copy(screenWidthDm = dm.coerceIn(5, 200)) } } + fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } } + + fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) } + + fun setPoseSink(s: PoseSink) { + synchronized(this) { sink = s } + post { updateImu() } + } + + fun clearPoseSink(s: PoseSink) { + synchronized(this) { if (sink === s) sink = null } + post { updateImu() } + } + + fun simulate(seconds: Int): Boolean { + val t = Thread({ + val start = SystemClock.elapsedRealtimeNanos() + val end = start + seconds * 1_000_000_000L + while (SystemClock.elapsedRealtimeNanos() < end && simulating.get() === Thread.currentThread()) { + val now = SystemClock.elapsedRealtimeNanos() + val t = (now - start) / 1e9 + val yaw = 40.0 * sin(t * 2 * Math.PI / 6.0) + val pitch = 15.0 * sin(t * 2 * Math.PI / 4.0) + val roll = 5.0 * sin(t * 2 * Math.PI / 5.0) + val q = VitureProtocol.eulerZxy(roll.toFloat(), pitch.toFloat(), yaw.toFloat()) + deliver(q, now) + SystemClock.sleep(4) + } + simulating.compareAndSet(Thread.currentThread(), null) + }, "glasses-simulate") + if (!simulating.compareAndSet(null, t)) return false + t.start() + return true + } + + fun stopSimulation() { + simulating.set(null) + } + + fun status(): JSONObject { + val s = _state.value + val g = _settings.value + val q = latest + return JSONObject() + .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("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) + .put("fovDeg", s.diagonalFovDeg.toDouble()).put("noSignal", s.noSignal).put("sbsSupport", s.sbsSupport) + .put("settings", JSONObject().put("refreshHz", g.refreshHz).put("sbsHz", g.sbsHz).put("sunblock", g.sunblock ?: JSONObject.NULL) + .put("gameOnGlasses", g.gameOnGlasses).put("headTracking", g.headTracking).put("auto3d", g.auto3d) + .put("picture3d", g.picture3d).put("screenWidthDm", g.screenWidthDm).put("screenDistanceDm", g.screenDistanceDm)) + } + + fun recentFrames(): JSONArray = synchronized(frames) { JSONArray(frames.toList()) } + + fun sendRaw(msgId: Int, payload: ByteArray): Boolean { + val l = link ?: return false + return l.send(msgId, payload) + } + + private fun post(block: () -> Unit) { + if (!::handler.isInitialized) return + handler.post(block) + } + + private fun publish(transform: (GlassesState) -> GlassesState) { + _state.value = transform(_state.value) + } + + private fun update(transform: (GlassesSettings) -> GlassesSettings) { + val next = transform(_settings.value) + _settings.value = next + save(next) + } + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_PERMISSION -> { + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + Log.i(TAG, "USB permission ${if (granted) "granted" else "denied"}") + if (!granted) publish { it.copy(permission = Permission.DENIED) } + refresh() + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + if (intent.device()?.vendorId == VitureProtocol.VENDOR_ID) asked = false + refresh(ask = true) + } + UsbManager.ACTION_USB_DEVICE_DETACHED -> if (intent.device()?.vendorId == VitureProtocol.VENDOR_ID) { + closeLink() + refresh() + } + } + } + } + + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = refresh() + override fun onDisplayRemoved(displayId: Int) = refresh() + override fun onDisplayChanged(displayId: Int) { + if (displayId == _state.value.displayId || displayId != Display.DEFAULT_DISPLAY) refresh() + } + } + + private fun refresh(ask: Boolean = false) { + val device = usb.deviceList.values.firstOrNull { it.vendorId == VitureProtocol.VENDOR_ID } + val m = device?.let { VitureProtocol.model(it.productId, runCatching { it.productName }.getOrNull()) } + if (device == null || (linkDevice != null && linkDevice?.deviceName != device.deviceName)) closeLink() + model = m + val display = findDisplay(device != null) + val mode = display?.mode + val width = mode?.physicalWidth ?: 0 + 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) + } + } + } + val permitted = device != null && usb.hasPermission(device) + 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 { + it.copy( + present = device != null || display != null, + model = m?.name ?: display?.let(::displayName) ?: "VITURE", + controllable = m?.controllable == true, + permission = when { + device == null -> Permission.NONE + permitted -> Permission.GRANTED + it.permission == Permission.ASKING || it.permission == Permission.DENIED -> it.permission + else -> Permission.NONE + }, + 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, + brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8, + sunblock = _settings.value.sunblock ?: it.sunblock, + diagonalFovDeg = m?.diagonalFovDeg ?: 46f, + tracking = it.tracking && link != null, + noSignal = noSignal, + sbs90 = m?.sbs90 == true, + ) + } + if (link != null && display != null && !noSignal) applyMode(force = false) + } + + private fun findDisplay(usbPresent: Boolean): Display? { + val candidates = displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) + .filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 } + return candidates.firstOrNull { displayName(it)?.contains("VITURE", ignoreCase = true) == true } + ?: candidates.singleOrNull()?.takeIf { usbPresent } + } + + private fun displayName(d: Display): String? { + if (Build.VERSION.SDK_INT >= 31) d.deviceProductInfo?.name?.takeIf { it.isNotBlank() }?.let { return it } + return d.name + } + + private fun askPermission(device: UsbDevice) { + asked = true + publish { it.copy(permission = Permission.ASKING) } + val flags = if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0 + val pi = PendingIntent.getBroadcast(app, 0, Intent(ACTION_PERMISSION).setPackage(app.packageName), flags) + runCatching { usb.requestPermission(device, pi) }.onFailure { Log.w(TAG, "requestPermission", it) } + } + + private fun openLink(device: UsbDevice) { + val l = VitureLink.open(usb, device, linkListener) ?: run { + Log.w(TAG, "could not open ${device.deviceName}") + return + } + link = l + linkDevice = device + Log.i(TAG, "opened ${model?.name} pid=0x%04X imu=%s".format(device.productId, l.hasImu)) + publish { it.copy(connected = true) } + listOf(VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_DISPLAY).forEachIndexed { i, msg -> + handler.postDelayed({ if (link === l) l.send(msg) }, 60L * (i + 1)) + } + handler.postDelayed({ + if (link !== l) return@postDelayed + _settings.value.sunblock?.let { sendFilm(it) } + updateImu() + applyMode(force = false) + }, 300) + } + + private fun closeLink() { + val l = link ?: return + link = null + linkDevice = null + l.close() + publish { it.copy(connected = false, tracking = false) } + Log.i(TAG, "link closed") + } + + 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 + if (!s.canControl || !s.usable || s.sbs) return + val want = _settings.value.refreshHz + if (!force && abs(s.hz - want) < 1.5f) return + if (!force && SystemClock.uptimeMillis() - lastModeSentAt < 8_000) return + sendMode(false) + } + + private fun sendMode(sbs: Boolean) { + val m = model ?: return + if (sbs && _state.value.sbsSupport == GlassesState.SBS_FAILS) return + 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 + } + } + + private fun sendFilm(on: Boolean) { + if (!_state.value.canControl) return + if (link?.send(VitureProtocol.MSG_FILM, byteArrayOf(if (on) 1 else 0)) == true) publish { it.copy(sunblock = on) } + } + + private fun updateImu() { + val l = link ?: return + val want = (sink != null || probe != null) && l.hasImu + if (want == _state.value.tracking) return + l.send(VitureProtocol.MSG_IMU, byteArrayOf(if (want) 1 else 0)) + if (want) l.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(VitureProtocol.IMU_RATE_240.toByte())) + publish { it.copy(tracking = want) } + } + + private val linkListener = object : VitureLink.Listener { + override fun onFrame(frame: VitureProtocol.Frame) { + 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) }, + ) + synchronized(frames) { + frames.addLast(line) + while (frames.size > 64) frames.removeFirst() + } + Log.i(TAG, "frame $line") + post { onReport(frame) } + } + + override fun onImu(payload: ByteArray, timeNs: Long) { + imuPackets++ + probe?.invoke(payload) + if (simulating.get() != null) return + VitureProtocol.imuQuaternion(payload)?.let { deliver(it, timeNs) } + } + + override fun onLost() = post { + closeLink() + refresh() + } + } + + private fun deliver(q: FloatArray, timeNs: Long) { + latest = q + sink?.onPose(q[0], q[1], q[2], q[3], timeNs) + } + + private fun onReport(frame: VitureProtocol.Frame) { + val p = frame.payload + val event = p.firstOrNull()?.toInt()?.and(0xFF) ?: return + val answer = if (p.size >= 2 && event == 0) p[1].toInt() and 0xFF else null + when (frame.msgId) { + VitureProtocol.EVENT_DISPLAY -> { + Log.i(TAG, "glasses switched to mode 0x%02X".format(event)) + publish { it.copy(sbs = VitureProtocol.modeIsSbs(event)) } + } + VitureProtocol.EVENT_BRIGHTNESS -> publish { it.copy(brightness = event) } + VitureProtocol.EVENT_VOLUME -> publish { it.copy(volume = event) } + VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_BRIGHTNESS -> answer?.let { v -> publish { it.copy(brightness = v) } } + 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 + } + VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v -> if (VitureProtocol.modeIsSbs(v) != _state.value.sbs && !_state.value.hasDisplay) publish { it.copy(sbs = VitureProtocol.modeIsSbs(v)) } } + } + } + + fun sampleImu(ms: Long): JSONObject { + val raw = java.util.Collections.synchronizedList(mutableListOf()) + val counter = java.util.concurrent.atomic.AtomicInteger() + val began = SystemClock.elapsedRealtime() + probe = { payload -> val n = counter.incrementAndGet(); if (raw.size < 4 || n % 60 == 0) raw.add(payload.copyOf()) } + post { updateImu() } + SystemClock.sleep(ms) + probe = null + post { updateImu() } + val seconds = (SystemClock.elapsedRealtime() - began) / 1000.0 + val count = counter.get() + val samples = JSONArray() + synchronized(raw) { + raw.forEach { b -> + val q = VitureProtocol.imuQuaternion(b) + samples.put(JSONObject() + .put("hex", b.joinToString("") { "%02X".format(it) }) + .put("euler", JSONArray().put(VitureProtocol.beFloat(b, 0).toDouble()).put(VitureProtocol.beFloat(b, 4).toDouble()).put(VitureProtocol.beFloat(b, 8).toDouble())) + .put("quat", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL)) + } + } + return JSONObject().put("packets", count).put("rateHz", count / seconds).put("samples", samples) + } + + private fun Intent.device(): UsbDevice? = + if (Build.VERSION.SDK_INT >= 33) getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + else @Suppress("DEPRECATION") getParcelableExtra(UsbManager.EXTRA_DEVICE) + + private fun load(): GlassesSettings { + val p = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return GlassesSettings( + refreshHz = p.getInt("refreshHz", 120), + sbsHz = p.getInt("sbsHz", 60), + sunblock = if (p.contains("sunblock")) p.getBoolean("sunblock", false) else null, + gameOnGlasses = p.getBoolean("gameOnGlasses", true), + headTracking = p.getBoolean("headTracking", true), + auto3d = p.getBoolean("auto3d", true), + picture3d = p.getInt("picture3d", GlassesSettings.PICTURE_AUTO), + screenWidthDm = p.getInt("screenWidthDm", 20), + screenDistanceDm = p.getInt("screenDistanceDm", 30), + ) + } + + private fun save(s: GlassesSettings) { + app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply { + putInt("refreshHz", s.refreshHz) + putInt("sbsHz", s.sbsHz) + s.sunblock?.let { putBoolean("sunblock", it) } ?: remove("sunblock") + putBoolean("gameOnGlasses", s.gameOnGlasses) + putBoolean("headTracking", s.headTracking) + putBoolean("auto3d", s.auto3d) + putInt("picture3d", s.picture3d) + putInt("screenWidthDm", s.screenWidthDm) + putInt("screenDistanceDm", s.screenDistanceDm) + }.apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt new file mode 100644 index 000000000..60045d32b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.glasses + +import android.app.Presentation +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.view.Display +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout + +class GlassesScreen(context: Context, display: Display, private val callback: SurfaceHolder.Callback) : Presentation(context, display) { + val surfaceView = SurfaceView(getContext()) + val holder: SurfaceHolder get() = surfaceView.holder + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = FrameLayout(context).apply { setBackgroundColor(Color.BLACK) } + root.addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + setContentView(root) + window?.apply { + addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + attributes = attributes.apply { + display.supportedModes.maxByOrNull { it.refreshRate * 10_000 + it.physicalWidth }?.let { best -> + if (display.mode.physicalWidth == best.physicalWidth) preferredDisplayModeId = best.modeId + } + } + } + surfaceView.holder.addCallback(callback) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt new file mode 100644 index 000000000..45fdf2385 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt @@ -0,0 +1,120 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.SystemClock +import android.util.Log + +internal class VitureLink private constructor( + private val connection: UsbDeviceConnection, + private val mcu: UsbInterface, + private val mcuOut: UsbEndpoint, + private val mcuIn: UsbEndpoint?, + private val imu: UsbInterface?, + private val imuIn: UsbEndpoint?, + private val listener: Listener, +) { + interface Listener { + fun onFrame(frame: VitureProtocol.Frame) + fun onImu(payload: ByteArray, timeNs: Long) + fun onLost() + } + + @Volatile private var running = true + private val writeLock = Any() + private val readers = mutableListOf() + + val hasImu: Boolean get() = imuIn != null + + private fun startReaders() { + mcuIn?.let { ep -> readers += reader("viture-mcu", ep) { buf, n -> VitureProtocol.parse(buf, n)?.let(listener::onFrame) } } + imuIn?.let { ep -> + readers += reader("viture-imu", ep) { buf, n -> + val frame = VitureProtocol.parse(buf, n) ?: return@reader + if (frame.magic == VitureProtocol.MAGIC_IMU) listener.onImu(frame.payload, SystemClock.elapsedRealtimeNanos()) + else listener.onFrame(frame) + } + } + } + + private fun reader(name: String, ep: UsbEndpoint, onPacket: (ByteArray, Int) -> Unit): Thread = + Thread({ + val buf = ByteArray(VitureProtocol.PACKET_SIZE) + var failures = 0 + while (running) { + val started = SystemClock.uptimeMillis() + val n = connection.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS) + if (!running) break + if (n > 0) { + failures = 0 + runCatching { onPacket(buf, n) }.onFailure { Log.w(TAG, "$name packet", it) } + } else if (n < 0 && SystemClock.uptimeMillis() - started < READ_TIMEOUT_MS / 2) { + failures++ + SystemClock.sleep(2) + if (failures >= LOST_AFTER) { + Log.w(TAG, "$name: reads keep failing, link lost") + if (running) listener.onLost() + break + } + } + } + }, name).apply { isDaemon = true; priority = Thread.MAX_PRIORITY; start() } + + fun send(msgId: Int, payload: ByteArray = ByteArray(0)): Boolean { + val pkt = VitureProtocol.packet(msgId, payload) + val n = synchronized(writeLock) { + if (!running) return false + connection.bulkTransfer(mcuOut, pkt, pkt.size, WRITE_TIMEOUT_MS) + } + Log.i(TAG, "send 0x%04X [%s] -> %d".format(msgId, payload.joinToString("") { "%02X".format(it) }, n)) + return n == pkt.size + } + + fun close() { + synchronized(writeLock) { running = false } + val self = Thread.currentThread() + readers.forEach { if (it !== self) runCatching { it.join(READ_TIMEOUT_MS * 4L) } } + runCatching { connection.releaseInterface(mcu) } + imu?.let { runCatching { connection.releaseInterface(it) } } + runCatching { connection.close() } + } + + companion object { + private const val TAG = "VitureLink" + private const val READ_TIMEOUT_MS = 100 + private const val WRITE_TIMEOUT_MS = 250 + private const val LOST_AFTER = 50 + + fun open(usb: UsbManager, device: UsbDevice, listener: Listener): VitureLink? { + val hid = (0 until device.interfaceCount).map(device::getInterface) + .filter { it.interfaceClass == UsbConstants.USB_CLASS_HID } + .sortedBy { it.id } + for (intf in hid) Log.i(TAG, "iface ${intf.id} eps " + (0 until intf.endpointCount).joinToString { + val ep = intf.getEndpoint(it) + "0x%02X".format(ep.address) + }) + val mcu = hid.lastOrNull { it.outEndpoint() != null } ?: return null + val imu = hid.firstOrNull { it.id != mcu.id && it.inEndpoint() != null } + val conn = usb.openDevice(device) ?: return null + if (!conn.claimInterface(mcu, true)) { + conn.close() + return null + } + val imuClaimed = imu != null && conn.claimInterface(imu, true) + val link = VitureLink(conn, mcu, mcu.outEndpoint()!!, mcu.inEndpoint(), + imu.takeIf { imuClaimed }, imu.takeIf { imuClaimed }?.inEndpoint(), listener) + link.startReaders() + return link + } + + private fun UsbInterface.outEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint) + .firstOrNull { it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_INT } + + private fun UsbInterface.inEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint) + .firstOrNull { it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_INT } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt new file mode 100644 index 000000000..b9d0fc16f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt @@ -0,0 +1,169 @@ +package com.droiddeck.launcher.glasses + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +object VitureProtocol { + const val VENDOR_ID = 0x35CA + const val PACKET_SIZE = 64 + private const val HEADER = 0x12 + + const val MAGIC_COMMAND = 0xFE + const val MAGIC_REPORT = 0xFD + const val MAGIC_IMU = 0xFC + + const val MSG_GET_BRIGHTNESS = 0x0005 + const val MSG_BRIGHTNESS = 0x0006 + const val MSG_GET_DISPLAY = 0x0007 + const val MSG_DISPLAY = 0x0008 + const val MSG_FILM = 0x000E + const val MSG_IMU = 0x0015 + const val MSG_GET_IMU = 0x0017 + const val MSG_IMU_RATE = 0x0018 + const val MSG_GET_VOLUME = 0x0032 + const val MSG_VOLUME = 0x0033 + + const val EVENT_BRIGHTNESS = 0x0301 + const val EVENT_DISPLAY = 0x0302 + const val EVENT_VOLUME = 0x0304 + + const val IMU_RATE_240 = 3 + + enum class Family { ONE, PRO, LUMA, ULTRA, BEAST } + + 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 brightnessMax: Int get() = 8 + val volumeMax: Int get() = 8 + val diagonalFovDeg: Float get() = when (family) { + Family.ONE -> 43f + Family.PRO -> 46f + Family.LUMA, Family.ULTRA -> 50f + Family.BEAST -> 58f + } + + fun mode(hz: Int, sbs: Boolean): Int = when { + tall && sbs -> if (hz >= 90) 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 } + } + } + + fun model(productId: Int, productName: String?): Model? { + val family = when (productId) { + 0x1011, 0x1013, 0x1015, 0x1017, 0x101b -> Family.ONE + 0x1019, 0x101d -> Family.PRO + 0x1121, 0x1131, 0x1141, 0x1151 -> Family.LUMA + 0x1101, 0x1104 -> Family.ULTRA + 0x1201, 0x1211 -> Family.BEAST + else -> return null + } + val fallback = when (productId) { + 0x1015, 0x101b -> "VITURE One Lite" + 0x1011, 0x1013, 0x1017 -> "VITURE One" + 0x1019 -> "VITURE Pro" + 0x101d -> "VITURE Pro XR" + 0x1131 -> "VITURE Luma" + 0x1121, 0x1141, 0x1151 -> "VITURE Luma Pro" + 0x1101, 0x1104 -> "VITURE Luma Ultra" + else -> "VITURE Beast" + } + val name = productName?.trim()?.removeSuffix(" Glasses")?.takeIf { it.startsWith("VITURE", ignoreCase = true) } ?: fallback + return Model(name, family) + } + + fun modeIsSbs(mode: Int): Boolean = mode == 0x32 || mode == 0x35 || mode == 0x42 || mode == 0x45 + + fun modeHz(mode: Int): Int = when (mode) { + 0x34, 0x44 -> 120 + 0x33, 0x35, 0x43, 0x45 -> 90 + else -> 60 + } + + private val CRC_TABLE = IntArray(256) { i -> + var c = i shl 8 + repeat(8) { c = if (c and 0x8000 != 0) (c shl 1) xor 0x1021 else c shl 1 } + c and 0xFFFF + } + + fun crc16(data: ByteArray, offset: Int, length: Int): Int { + var crc = 0 + for (i in offset until offset + length) { + crc = (CRC_TABLE[((data[i].toInt() and 0xFF) xor (crc ushr 8)) and 0xFF] xor (crc shl 8)) and 0xFFFF + } + return crc + } + + fun packet(msgId: Int, payload: ByteArray = ByteArray(0)): ByteArray { + require(payload.size <= PACKET_SIZE - HEADER) + val buf = ByteArray(PACKET_SIZE) + buf[0] = 0xFF.toByte() + buf[1] = MAGIC_COMMAND.toByte() + val len = payload.size + 0x0C + buf[4] = len.toByte() + buf[5] = (len shr 8).toByte() + buf[14] = msgId.toByte() + buf[15] = (msgId shr 8).toByte() + payload.copyInto(buf, HEADER) + val crc = crc16(buf, 4, len + 2) + buf[2] = crc.toByte() + buf[3] = (crc shr 8).toByte() + return buf + } + + class Frame(val magic: Int, val msgId: Int, val payload: ByteArray, val crcOk: Boolean) + + fun parse(buf: ByteArray, size: Int): Frame? { + if (size < HEADER || (buf[0].toInt() and 0xFF) != 0xFF) return null + val magic = buf[1].toInt() and 0xFF + if (magic != MAGIC_COMMAND && magic != MAGIC_REPORT && magic != MAGIC_IMU) return null + val len = u16(buf, 4) + if (len < 0x0C) return null + val dataLen = (len - 0x0C).coerceAtMost(size - HEADER) + val crcOk = len + 6 <= size && crc16(buf, 4, len + 2) == u16(buf, 2) + return Frame(magic, u16(buf, 14), buf.copyOfRange(HEADER, HEADER + dataLen), crcOk) + } + + private fun u16(buf: ByteArray, at: Int) = (buf[at].toInt() and 0xFF) or ((buf[at + 1].toInt() and 0xFF) shl 8) + + fun beFloat(b: ByteArray, at: Int): Float = java.lang.Float.intBitsToFloat( + ((b[at].toInt() and 0xFF) shl 24) or ((b[at + 1].toInt() and 0xFF) shl 16) or + ((b[at + 2].toInt() and 0xFF) shl 8) or (b[at + 3].toInt() and 0xFF), + ) + + fun imuQuaternion(payload: ByteArray): FloatArray? { + if (payload.size >= 36) { + val w = beFloat(payload, 20) + val x = beFloat(payload, 24) + val y = beFloat(payload, 28) + val z = beFloat(payload, 32) + val n = sqrt(w * w + x * x + y * y + z * z) + if (n.isFinite() && n > 0.9f && n < 1.1f) return floatArrayOf(x / n, y / n, z / n, w / n) + } + if (payload.size < 12) return null + val roll = beFloat(payload, 0) + val pitch = beFloat(payload, 4) + val yaw = beFloat(payload, 8) + if (!roll.isFinite() || !pitch.isFinite() || !yaw.isFinite()) return null + return eulerZxy(roll, pitch, yaw) + } + + fun eulerZxy(rollDeg: Float, pitchDeg: Float, yawDeg: Float): FloatArray { + val r = Math.toRadians(rollDeg.toDouble()) / 2 + val p = Math.toRadians(pitchDeg.toDouble()) / 2 + val y = Math.toRadians(yawDeg.toDouble()) / 2 + val cx = cos(r); val sx = sin(r) + val cy = cos(p); val sy = sin(p) + val cz = cos(y); val sz = sin(y) + return floatArrayOf( + (sx * cy * cz - cx * sy * sz).toFloat(), + (cx * sy * cz + sx * cy * sz).toFloat(), + (cx * cy * sz + sx * sy * cz).toFloat(), + (cx * cy * cz - sx * sy * sz).toFloat(), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt index b5ec1b544..14f85cbee 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt @@ -26,6 +26,7 @@ data class Cardboard( val screenWidthDm: Int = 40, val screenDistanceDm: Int = 30, val headTracking: Boolean = true, + val mono: Boolean = false, ) { val lens: Viewer get() = Viewer.byId(viewer) @@ -35,10 +36,11 @@ data class Cardboard( * from its viewport's centre, so the two pictures line up with the viewer's lenses; the height, * seen through the lens from its focal distance, is the field of view the distortion works in. */ - fun push(panelWidthMm: Float, panelHeightMm: Float) { + fun push(panelWidthMm: Float, panelHeightMm: Float, fovOverrideDeg: Float = 0f, displayHz: Float = 0f) { val l = lens WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f, - screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma) + screenWidthDm / 10f, l.k1, l.k2, if (fovOverrideDeg > 0f) fovOverrideDeg else fovDeg(l, panelHeightMm), + if (mono) 0f else lensInset(panelWidthMm, l.lensSeparationMm), l.chroma, mono, displayHz) } fun encode(): JSONObject = JSONObject() @@ -57,7 +59,8 @@ data class Cardboard( /** The original 2014 Cardboard: narrower, gentler lenses. */ CARDBOARD_V1("cardboard1", 0.441f, 0.156f, 42f, 60f, 0.004f, 80f), /** No lens: side-by-side 3D glasses, a 3D TV, or just looking at the stereo pair. */ - NONE("none", 0f, 0f, 0f, 0f, 0f, 60f); + NONE("none", 0f, 0f, 0f, 0f, 0f, 60f), + VITURE("viture", 0f, 0f, 0f, 0f, 0f, 23.3f); companion object { fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2 @@ -191,6 +194,10 @@ object HeadPose { a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c] } + private val GLASSES_AXES = floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f) + + fun fromGlasses(x: Float, y: Float, z: Float, w: Float): FloatArray = multiply(fromQuaternion(x, y, z, w), GLASSES_AXES) + fun transpose(m: FloatArray): FloatArray = floatArrayOf(m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]) /** m * v for a 3-vector. */ diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt b/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt index 3841854e2..082f5d2b1 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt @@ -19,20 +19,43 @@ import com.droiddeck.launcher.wayland.WaylandCompositor * * [rotation] is the Display rotation (Surface.ROTATION_*) the session is drawn in. */ -class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener { +class HeadTracker(context: Context, private val glasses: Boolean = false, private val rotation: () -> Int) : SensorEventListener { private val sensors = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val sensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) ?: sensors.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) private var thread: HandlerThread? = null + private var started = false + private val glassesLock = Any() @Volatile private var reference: HeadPose.Reference? = null @Volatile private var recenterPending = true @Volatile private var latest: FloatArray? = null + private val glassesSink = com.droiddeck.launcher.glasses.Glasses.PoseSink { x, y, z, w, _ -> + val m = HeadPose.fromGlasses(x, y, z, w) + synchronized(glassesLock) { + if (!started) return@PoseSink + latest = m + apply(m) + } + } + + val usesGlasses: Boolean get() = glasses + /** Whether this device has a rotation sensor at all (no sensor = a fixed, head-locked screen). */ val available: Boolean get() = sensor != null val sensorName: String get() = sensor?.let { "${it.name} (${if (it.type == Sensor.TYPE_GAME_ROTATION_VECTOR) "game rotation vector" else "rotation vector"})" } ?: "none" fun start() { + if (glasses) { + synchronized(glassesLock) { + if (started) return + started = true + recenterPending = true + } + com.droiddeck.launcher.glasses.Glasses.setPoseSink(glassesSink) + WaylandCompositor.nativeLog("cardboard", "head tracking on: VITURE glasses IMU") + return + } val s = sensor ?: run { WaylandCompositor.nativeLog("cardboard", "no rotation sensor: the virtual screen stays head-locked") WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false) @@ -47,6 +70,15 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve } fun stop() { + if (glasses) { + synchronized(glassesLock) { + if (!started) return + started = false + WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false) + } + com.droiddeck.launcher.glasses.Glasses.clearPoseSink(glassesSink) + return + } val t = thread ?: return sensors.unregisterListener(this) t.quitSafely() @@ -57,7 +89,8 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve /** Make where the viewer looks now straight ahead (see HeadPose.recenter). */ fun recenter() { recenterPending = true - latest?.let { apply(it) } + if (glasses) synchronized(glassesLock) { if (started) latest?.let { apply(it) } } + else latest?.let { apply(it) } } override fun onSensorChanged(event: SensorEvent) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index 8c4de8858..f0dc72158 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -92,6 +92,7 @@ class OnScreenControls( private val groups = controls.map { it.group }.distinct() private var buttonsOnly = false + val isButtonsOnly: Boolean get() = buttonsOnly private var quickHidden = false private var quickPressedBy = -1 private var keyboardPressedBy = -1 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt new file mode 100644 index 000000000..8df726607 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt @@ -0,0 +1,353 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import com.droiddeck.launcher.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private const val DROP_PIECES = 10 + +internal class DropSheetState(private val scope: CoroutineScope) { + var open by mutableStateOf(false) + var shown by mutableStateOf(false) + var settledByDrag = false + var engaged = false + private set + val lift = Animatable(0f) + val ride = Animatable(0f) + val sink = Animatable(0f) + val hot = Animatable(1f) + val pieces = List(DROP_PIECES) { Animatable(0f) } + var travelPx = 1f + private var from = 0f + private var progress = 0f + private var dragJob: Job? = null + + fun cancelDrag() { + dragJob?.cancel() + dragJob = null + } + + fun reset() { + cancelDrag() + engaged = false + settledByDrag = false + open = false + shown = false + scope.launch { + listOf(lift, ride, sink).forEach { it.snapTo(0f) } + pieces.forEach { it.snapTo(0f) } + hot.snapTo(1f) + } + } + + fun beginDrag() { + cancelDrag() + settledByDrag = false + engaged = true + from = if (open) 1f else 0f + progress = from + if (!open) shown = true + } + + fun drag(dy: Float) { + progress = (progress + dy / travelPx).coerceIn(0f, 1.04f) + val p = progress + scope.launch { + lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f) + pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) } + } + } + + fun settle(v: Float, flick: Float) { + engaged = false + val opens = when { + v > flick -> true + v < -flick -> false + from > 0.5f -> progress > 0.7f + else -> progress > 0.3f + } + settledByDrag = true + cancelDrag() + dragJob = scope.launch { + val to = if (opens) 1f else 0f + coroutineScope { + launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) } + launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) } + launch { sink.animateTo(to, Motion.tw(220)) } + pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } } + } + if (!opens) { shown = false; hot.snapTo(1f) } + } + if (open != opens) open = opens else settledByDrag = false + } +} + +@Composable +internal fun rememberDropSheetState(): DropSheetState { + val scope = rememberCoroutineScope() + return remember { DropSheetState(scope) } +} + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +@Composable +internal fun BoxWithConstraintsScope.DropSheet( + state: DropSheetState, + title: String, + icon: ImageVector, + label: String, + covered: () -> Float = { 0f }, + rows: @Composable () -> Unit, +) { + val density = LocalDensity.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val inputMode = LocalInputModeManager.current + val firstRow = remember { FocusRequester() } + val buttonFocus = remember { FocusRequester() } + var rest by remember { mutableStateOf(null) } + fun focusButton() { if (inputMode.inputMode == InputMode.Keyboard) runCatching { buttonFocus.requestFocus() } } + fun close() { + if (!state.open) return + state.open = false + focusButton() + } + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val obs = androidx.lifecycle.LifecycleEventObserver { _, e -> + if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (state.open || state.shown || state.engaged)) state.reset() + } + lifecycle.addObserver(obs) + onDispose { lifecycle.removeObserver(obs) } + } + LaunchedEffect(state.open) { + if (state.settledByDrag) { + state.settledByDrag = false + if (state.open) { + if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { firstRow.requestFocus() } } + } else focusButton() + return@LaunchedEffect + } + if (state.engaged) return@LaunchedEffect + state.cancelDrag() + if (state.open) { + state.shown = true + coroutineScope { + launch { state.sink.animateTo(1f, Motion.tw(260)) } + launch { + delay(Motion.ms(120).toLong()) + launch { state.lift.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { state.ride.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { state.hot.snapTo(1f); delay(Motion.ms(100).toLong()); state.hot.animateTo(0f, Motion.tw(200)) } + } + state.pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } } + if (inputMode.inputMode == InputMode.Keyboard) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { firstRow.requestFocus() } + } + } + } else if (state.shown) { + coroutineScope { + state.pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); state.lift.animateTo(0f, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(100).toLong()); state.ride.animateTo(0f, Motion.sp(0.55f, 300f)) } + launch { state.sink.animateTo(0f, Motion.tw(300)) } + } + state.shown = false + state.hot.snapTo(1f) + } else { + listOf(state.sink, state.lift, state.ride).forEach { it.snapTo(0f) } + state.pieces.forEach { it.snapTo(0f) } + state.hot.snapTo(1f) + } + } + BackHandler(enabled = state.open) { close() } + + val w = constraints.maxWidth.toFloat() + val h = constraints.maxHeight.toFloat() + val inset = with(density) { 12.dp.toPx() } + val edge = with(density) { 3.dp.toPx() } + val buttonH = rest?.height ?: with(density) { 48.dp.toPx() } + val openBottom = h - sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, edge) + val lower = rest?.let { (openBottom - edge - buttonH / 2f) - it.top } ?: 0f + state.travelPx = openBottom.coerceAtLeast(1f) + + if (state.shown || state.open) Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = state.sink.value }.background(Color.Black.copy(alpha = 0.42f)) + .focusProperties { canFocus = false } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = state.open) { close() }, + ) + + if (state.shown) { + val bottom = lerp(0f, openBottom, state.lift.value) + val corner = with(density) { 18.dp.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val path = remember { Path() } + val rimPath = remember { Path() } + Canvas(Modifier.fillMaxSize()) { + val l = inset + val r = w - inset + val t = -corner + rimPath.reset() + rimPath.moveTo(l, t) + rimPath.lineTo(l, bottom - corner) + rimPath.arcTo(Rect(l, bottom - 2 * corner, l + 2 * corner, bottom), 180f, -90f, false) + rimPath.lineTo(r - corner, bottom) + rimPath.arcTo(Rect(r - 2 * corner, bottom - 2 * corner, r, bottom), 90f, -90f, false) + rimPath.lineTo(r, t) + path.reset() + path.addPath(rimPath) + path.close() + drawPath(path, lerp(colors.surface, pal.signal, 0.22f * state.hot.value)) + drawPath(rimPath, pal.signal.copy(alpha = lerp(0.55f, 0.9f, state.hot.value)), style = Stroke(rim)) + } + val sheetW = w - 2 * inset + val rowsTop = with(density) { 62.dp.toPx() } + val rowsBottom = openBottom - edge - buttonH / 2f - with(density) { 8.dp.toPx() } + Box( + Modifier + .offset { IntOffset(inset.roundToInt(), (bottom - openBottom).roundToInt()) } + .size(with(density) { sheetW.toDp() }, with(density) { openBottom.toDp() }) + .pointerInput(Unit) { detectTapGestures { } } + .controllerBack { close() } + .focusProperties { exit = { if (state.open) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .offset { IntOffset(0, 18.dp.roundToPx()) } + .fillMaxWidth() + .padding(start = 20.dp, end = 64.dp) + .graphicsLayer { val v = state.pieces[0].value; alpha = v; translationY = (1f - v) * -6.dp.toPx() }, + ) { + Icon(icon, null, tint = pal.signal, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(10.dp)) + Text(title, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Column( + Modifier + .offset { IntOffset(0, rowsTop.roundToInt()) } + .fillMaxWidth() + .height(with(density) { (rowsBottom - rowsTop).coerceAtLeast(0f).toDp() }) + .focusRequester(firstRow) + .focusGroup() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) { + CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(state.pieces) }) { rows() } + } + val closeSource = remember { MutableInteractionSource() } + val closeHot = rememberHot(closeSource) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { IntOffset((sheetW - 52.dp.toPx()).roundToInt(), 8.dp.roundToPx()) } + .zIndex(1f).size(44.dp).testTag("drop-close") + .clip(CircleShape).glideBorder(closeHot, CircleShape, pal.signal) + .hoverable(closeSource) + .clickable(interactionSource = closeSource, indication = LocalIndication.current, enabled = state.open, role = Role.Button) { close() } + .controllerConfirm(enabled = state.open) { close() }, + ) { Icon(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } + } + } + + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val toggle = { if (state.open) close() else state.open = true } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 20.dp) + .onGloballyPositioned { rest = it.boundsInParent() } + .graphicsLayer { translationY = lower * state.ride.value; alpha = 1f - covered() } + .zIndex(2f) + .size(48.dp) + .testTag("glasses-open") + .focusRequester(buttonFocus) + .clip(CircleShape) + .background(lerp(colors.surface.copy(alpha = 0.82f), pal.signal, 0.22f * state.ride.value)) + .glideBorder(hot, CircleShape, pal.signal, pal.line) + .hoverable(src) + .focusProperties { canFocus = covered() < 0.5f } + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = covered() < 0.5f, role = Role.Button, onClick = toggle) + .controllerConfirm(enabled = covered() < 0.5f, onClick = toggle), + ) { + Icon(icon, label, tint = if (hot || state.open) colors.onBackground else colors.onSurfaceVariant, modifier = Modifier.size(26.dp)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt new file mode 100644 index 000000000..c81b682fb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt @@ -0,0 +1,175 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.collectAsState +import com.droiddeck.launcher.R +import com.droiddeck.launcher.glasses.Glasses +import com.droiddeck.launcher.glasses.GlassesSettings +import com.droiddeck.launcher.glasses.GlassesState +import java.util.Locale + +internal val GlassesIcon: ImageVector by lazy { + ImageVector.Builder("Glasses", 24.dp, 24.dp, 24f, 24f).apply { + path(fill = null, stroke = SolidColor(Color.Black), strokeLineWidth = 1.8f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round) { + moveTo(2.5f, 10f); lineTo(21.5f, 10f) + moveTo(3.2f, 10f); lineTo(3.8f, 15.2f); quadTo(4f, 17f, 6f, 17f); lineTo(8.6f, 17f); quadTo(10.2f, 17f, 10.6f, 15.4f); lineTo(11.2f, 12.6f) + quadTo(12f, 11.6f, 12.8f, 12.6f); lineTo(13.4f, 15.4f); quadTo(13.8f, 17f, 15.4f, 17f); lineTo(18f, 17f); quadTo(20f, 17f, 20.2f, 15.2f); lineTo(20.8f, 10f) + moveTo(2.5f, 10f); lineTo(1.8f, 7.6f) + moveTo(21.5f, 10f); lineTo(22.2f, 7.6f) + } + }.build() +} + +@Composable +internal fun rememberGlasses(): Pair { + val g by Glasses.state.collectAsState() + val s by Glasses.settings.collectAsState() + return g to s +} + +@Composable +internal fun GlassesGroups( + host: MenuHost, + g: GlassesState, + s: GlassesSettings, + track: (String) -> Modifier = { Modifier }, + onRecenter: (() -> Unit)? = null, + inSession: Boolean = true, + steamVr: @Composable () -> Unit = {}, +) { + SettingsGroup(g.model) { + val status = if (g.hasDisplay) stringResource(R.string.glasses_status, g.width, g.height, kotlin.math.round(g.hz).toInt(), + stringResource(if (g.sbs) R.string.glasses_mode_3d else R.string.glasses_mode_2d)) else null + when { + g.noSignal -> SettingsRow(stringResource(R.string.glasses_output), stringResource(R.string.glasses_no_signal)) {} + !g.controllable && g.present && g.permission == Glasses.Permission.NONE && !g.connected -> + SettingsRow(stringResource(R.string.glasses_control), status ?: stringResource(R.string.glasses_no_control)) {} + g.permission == Glasses.Permission.ASKING -> + SettingsRow(stringResource(R.string.glasses_control), stringResource(R.string.glasses_asking)) {} + !g.connected && g.controllable -> ActionRow(stringResource(R.string.glasses_control), + stringResource(if (g.permission == Glasses.Permission.DENIED) R.string.glasses_denied else R.string.glasses_allow_hint), + stringResource(R.string.glasses_allow), { Glasses.requestControl() }) + status != null -> SettingsRow(stringResource(R.string.glasses_output), status) {} + } + ToggleRow(host, "glasses-game", stringResource(R.string.glasses_game_on), stringResource(R.string.glasses_game_on_hint), + s.gameOnGlasses, chipModifier = track("glasses-game")) { Glasses.setGameOnGlasses(it) } + } + if (g.canControl) SettingsGroup(stringResource(R.string.glasses_display)) { + 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)) {} + } 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) } + if (g.sbs90) ChoiceRow(host, "glasses-3d-hz", stringResource(R.string.glasses_3d_refresh), null, + listOf(60, 90).map { it to "$it Hz" }, s.sbsHz, chipModifier = track("glasses-3d-hz")) { Glasses.setSbsHz(it) } + ToggleRow(host, "glasses-auto3d", stringResource(R.string.glasses_auto3d), stringResource(R.string.glasses_auto3d_hint), + s.auto3d, chipModifier = track("glasses-auto3d")) { Glasses.setAuto3d(it) } + } + ChoiceRow(host, "glasses-picture", stringResource(R.string.glasses_picture), stringResource(R.string.glasses_picture_hint), + listOf( + GlassesSettings.PICTURE_AUTO to stringResource(R.string.glasses_picture_auto), + GlassesSettings.PICTURE_SCREEN to stringResource(R.string.glasses_picture_screen), + GlassesSettings.PICTURE_SBS to stringResource(R.string.glasses_picture_sbs), + ), s.picture3d, chipModifier = track("glasses-picture"), hintLines = 2) { Glasses.setPicture3d(it) } + if (s.picture3d != GlassesSettings.PICTURE_SBS) { + SliderRow(stringResource(R.string.glasses_screen_size), null, s.screenWidthDm, 5..60, step = 1, + format = { String.format(Locale.US, "%.1f m", it / 10f) }, modifier = track("glasses-width")) { Glasses.setScreenWidthDm(it) } + SliderRow(stringResource(R.string.glasses_screen_distance), null, s.screenDistanceDm, 5..100, step = 1, + format = { String.format(Locale.US, "%.1f m", it / 10f) }, modifier = track("glasses-distance")) { Glasses.setScreenDistanceDm(it) } + } + steamVr() + } + if (g.canControl) SettingsGroup(stringResource(R.string.glasses_hardware)) { + SliderRow(stringResource(R.string.glasses_brightness), null, g.brightness.coerceAtLeast(0), 0..g.brightnessMax, step = 1, + modifier = track("glasses-brightness")) { Glasses.setBrightness(it) } + SliderRow(stringResource(R.string.glasses_volume), null, g.volume.coerceAtLeast(0), 0..g.volumeMax, step = 1, + modifier = track("glasses-volume")) { Glasses.setVolume(it) } + ToggleRow(host, "glasses-sunblock", stringResource(R.string.glasses_sunblock), stringResource(R.string.glasses_sunblock_hint), + g.sunblock, chipModifier = track("glasses-sunblock")) { Glasses.setSunblock(it) } + } + if (g.canControl) SettingsGroup(stringResource(R.string.glasses_tracking)) { + ToggleRow(host, "glasses-tracking", stringResource(R.string.glasses_head_tracking), stringResource(R.string.glasses_head_tracking_hint), + s.headTracking, chipModifier = track("glasses-tracking")) { Glasses.setHeadTracking(it) } + if (onRecenter != null && s.headTracking) ActionRow(stringResource(R.string.glasses_recenter), null, + stringResource(R.string.cardboard_recenter_button), onRecenter) + } +} + +@Composable +internal fun GlassesCornerButton(slot: Int, tint: Int, opacity: Int, onClick: () -> Unit) { + val density = androidx.compose.ui.platform.LocalDensity.current + androidx.compose.foundation.layout.BoxWithConstraints( + Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout), + ) { + val x = if (slot < 0) maxWidth / 2 + 56.dp else 34.dp + 56.dp * slot + val bottom = if (slot < 0) 44.dp else 34.dp + val source = remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val base = Color(tint) + val alpha = opacity.coerceIn(0, 100) / 100f + val fill = Color(base.red * 0.12f, base.green * 0.12f, base.blue * 0.12f, 190 / 255f) + fun light(a: Int, f: Float) = Color(base.red + (1 - base.red) * f, base.green + (1 - base.green) * f, base.blue + (1 - base.blue) * f, a * alpha / 255f) + val line = if (pressed) light(230, 0.7f) else light(150, 0.25f) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { with(density) { IntOffset((x - 18.dp).roundToPx(), (maxHeight - bottom - 18.dp).roundToPx()) } } + .size(36.dp) + .clip(CircleShape) + .background(fill) + .border(1.5.dp, line, CircleShape) + .clickable(interactionSource = source, indication = null, role = Role.Button, onClick = onClick) + .testTag("glasses-button"), + ) { + Icon(GlassesIcon, stringResource(R.string.glasses_open), tint = line, modifier = Modifier.size(22.dp)) + } + } +} + +@Composable +internal fun GlassesPlayingHint(model: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(10.dp)) + Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.45f), fontSize = 15.sp) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index bf64c327b..07ac02176 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -210,6 +211,8 @@ internal fun LiftHero( onOpenChange: (Boolean) -> Unit = {}, start: Dp = 44.dp, bottom: Dp = 44.dp, + drop: DropSheetState? = null, + overlay: @Composable BoxWithConstraintsScope.(lifted: () -> Float) -> Unit = {}, ) { val density = LocalDensity.current val inputMode = LocalInputModeManager.current @@ -455,19 +458,36 @@ internal fun LiftHero( // A swipe anywhere on the hero moves the sheet under the finger - up from rest, down // when open - and lets go open or shut by how far it went and how fast. A swipe that // starts the wrong way (down at rest) is left alone, for the page under it. - .pointerInput(Unit) { + .pointerInput(drop) { val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker() var decided = false + val dropFlick = 600.dp.toPx() detectVerticalDragGestures( onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false }, - onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) }, - onDragCancel = { if (drag.engaged) settleDrag(0f) }, + onDragEnd = { + if (drag.engaged) settleDrag(tracker.calculateVelocity().y) + else if (drop?.engaged == true) drop.settle(tracker.calculateVelocity().y, dropFlick) + }, + onDragCancel = { + if (drag.engaged) settleDrag(0f) + else if (drop?.engaged == true) drop.settle(0f, dropFlick) + }, ) { change, amount -> if (launching) return@detectVerticalDragGestures if (!decided) { decided = true // Up at rest opens; down when open closes. Anything else is not for the sheet. - if (if (open) amount > 0f else amount < 0f) beginDrag() + when { + drop != null && (drop.open || drop.shown) -> if (drop.open && amount < 0f) drop.beginDrag() + open || shown || amount < 0f -> if (if (open) amount > 0f else amount < 0f) beginDrag() + drop != null && amount > 0f -> drop.beginDrag() + } + } + if (drop?.engaged == true) { + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + drop.drag(amount) + return@detectVerticalDragGestures } if (!drag.engaged) return@detectVerticalDragGestures change.consume() @@ -488,7 +508,8 @@ internal fun LiftHero( // The wall behind sinks back about the pane's centre. val foot = h - Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() } + val wallSink = { maxOf(sink.value, drop?.sink?.value ?: 0f) } + Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * wallSink(); scaleX = k; scaleY = k }) { background() } // The fades stay put: scaled with the wall they stopped short of the pane's edges and left // a bright band of unfaded wall down the side and along the top. scrim { sink.value } @@ -627,7 +648,7 @@ internal fun LiftHero( // The words and the button, where they always were; the button rides the sheet's edge. Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) { - Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() } + Box(Modifier.graphicsLayer { alpha = 1f - wallSink() }) { words() } Spacer(Modifier.height(18.dp)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Box( @@ -662,7 +683,7 @@ internal fun LiftHero( val besideShown = !(open || shown) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.graphicsLayer { alpha = 1f - sink.value } + modifier = Modifier.graphicsLayer { alpha = 1f - wallSink() } .focusProperties { canFocus = besideShown }, ) { if (besideShown || sink.value < 0.99f) beside() } } @@ -694,6 +715,7 @@ internal fun LiftHero( } } } + overlay { sink.value } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index 0955651cc..c043076c8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.ui /** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */ internal enum class SessionDrawerPage(val firstControl: String) { CONTROLLER("touch"), + GLASSES("glasses-game"), SECOND_SCREEN("second-screen:none"), DISPLAY("hud"), EFFECTS("look"), @@ -10,8 +11,10 @@ internal enum class SessionDrawerPage(val firstControl: String) { SESSION("suspend"), } -internal fun sessionDrawerPages(hasSecondScreen: Boolean): List = - SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN } +internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List = + SessionDrawerPage.entries.filter { + (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) + } internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage { val index = indexOf(current).coerceAtLeast(0) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 18cd36810..80e95093b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -149,6 +149,7 @@ import java.util.Locale private val SessionDrawerPage.title: Int get() = when (this) { SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller + SessionDrawerPage.GLASSES -> R.string.drawer_page_glasses SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen SessionDrawerPage.DISPLAY -> R.string.drawer_page_display SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects @@ -158,6 +159,7 @@ private val SessionDrawerPage.title: Int get() = when (this) { private val SessionDrawerPage.icon get() = when (this) { SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports + SessionDrawerPage.GLASSES -> GlassesIcon SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh @@ -217,6 +219,8 @@ class DrawerActions( val cardboardSensor: Boolean = false, /** SteamVR in the guest for Cardboard mode (gpu/SteamVr); null while unknown. */ val steamVr: SteamVr.Status? = null, + val glassesPresent: Boolean = false, + val glassesOutput: Boolean = false, /** Texture filtering for DirectX 9-11 games (core/TextureFiltering); lands on their next launch. */ val textureAnisotropy: Int? = 0, val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF, @@ -254,6 +258,7 @@ class DrawerActions( val onEffects: (ScreenEffects) -> Unit = {}, val onCardboard: (Cardboard) -> Unit = {}, val onCardboardRecenter: () -> Unit = {}, + val onGlassesRecenter: () -> Unit = {}, /** install, start or stop SteamVR. */ val onSteamVr: (String) -> Unit = {}, val onTextureAnisotropy: (Int?) -> Unit = {}, @@ -309,7 +314,7 @@ class DrawerActions( @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) { - val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty()) + val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty(), a.glassesPresent) val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) } val colors = MaterialTheme.colorScheme @@ -477,7 +482,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness) } - CardboardGroup(host, a) { key -> focus.track(page, key) } + if (!a.glassesOutput) CardboardGroup(host, a) { key -> focus.track(page, key) } } SessionDrawerPage.EFFECTS -> { ScreenEffectsGroup(host, a, advancedEffectsExpanded, @@ -541,6 +546,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont SessionDrawerPage.SECOND_SCREEN -> { SecondScreenGroup(host, a) { key -> focus.track(page, key) } } + SessionDrawerPage.GLASSES -> { + val (g, s) = rememberGlasses() + GlassesGroups(host, g, s, { key -> focus.track(page, key) }, onRecenter = a.onGlassesRecenter, + steamVr = { SteamVrRow(a) }) + } SessionDrawerPage.GAMES -> { if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) } Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp, @@ -948,18 +958,23 @@ private fun CardboardGroup(host: MenuHost, a: DrawerActions, track: (String) -> } if (c.headTracking && a.cardboardSensor) ActionRow(stringResource(R.string.cardboard_recenter), null, stringResource(R.string.cardboard_recenter_button), a.onCardboardRecenter) - val vrLabel = stringResource(R.string.cardboard_steamvr) - when (a.steamVr) { - SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint), - stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") }) - SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {} - SteamVr.Status.READY -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_ready_hint), - stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") }) - SteamVr.Status.RUNNING -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint), - stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") }) - SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {} - null -> {} - } + SteamVrRow(a) + } +} + +@Composable +private fun SteamVrRow(a: DrawerActions) { + val vrLabel = stringResource(R.string.cardboard_steamvr) + when (a.steamVr) { + SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint), + stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") }) + SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {} + SteamVr.Status.READY -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_ready_hint), + stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") }) + SteamVr.Status.RUNNING -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint), + stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") }) + SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {} + null -> {} } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index f6952fc65..b1adb095f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -103,8 +103,18 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) } }, ) + val (glasses, glassesSettings) = rememberGlasses() + val drop = rememberDropSheetState() + LaunchedEffect(glasses.present) { if (!glasses.present && (drop.open || drop.shown)) drop.reset() } + val glassesLabel = stringResource(R.string.glasses_open) LiftHero( key = "steam", tabs = tabs, + drop = drop.takeIf { glasses.present }, + overlay = { lifted -> + if (glasses.present) DropSheet(drop, glasses.model, GlassesIcon, glassesLabel, covered = lifted) { + GlassesGroups(rememberMenuHost(), glasses, glassesSettings, inSession = false) + } + }, onOpen = a.onSteamSettings, onPrimary = a.onPlay, modifier = modifier.clipToBounds(), start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 14016b29f..ce05db939 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -455,7 +455,7 @@ public final class WaylandCompositor { * stereo pair. Distances in metres, field of view in degrees. */ public static native void nativeSetCardboard(boolean enabled, int source, float ipdM, float screenDistM, float screenWidthM, float k1, float k2, float fovDeg, - float lensInset, float chroma); + float lensInset, float chroma, boolean mono, float displayHz); /** The head pose for Cardboard mode: head -> world rotation, row-major 3x3 (any thread). {@code live} * = a sensor produced it. */ diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7fd550064..4c1eac069 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2147,4 +2147,41 @@ %1$s no indica logros para este juego. Ver los %1$d Logros · %1$s + Gafas + Gafas VITURE + Control de las gafas + Permitir + Permite que DroidDeck ajuste la frecuencia de actualización, el modo 3D, el brillo, el volumen y el bloqueo solar de las gafas, y lea su seguimiento de cabeza. + Esperando a que lo permitas en el aviso de Android… + Se rechazó el control. Permítelo para cambiar los ajustes de las gafas. + Estas gafas muestran el juego, pero sus ajustes no se pueden cambiar desde aquí. + Salida + Juego en las gafas + Muestra el juego solo en las gafas. Desactivado: el teléfono lo muestra y Android duplica la pantalla del teléfono. + Pantalla + Frecuencia de actualización + 3D (lado a lado) + Cada ojo recibe su propia mitad de una imagen de 3840×1080. Si la imagen se pierde, mantén pulsado el botón 2D/3D de las gafas. + Este teléfono no puede enviar la señal de 3840×1080 que necesita el 3D. Los juegos de RV muestran la vista de un ojo, con seguimiento de cabeza. + Sin imagen: las gafas están en un modo que este teléfono no puede enviar. Mantén pulsado su botón 2D/3D o desconéctalas y vuelve a conectarlas. + Frecuencia en 3D + 3D para juegos de RV + Cambia a 3D mientras SteamVR está en marcha, y vuelve después. + Imagen 3D + Automático: SteamVR llena la vista; los demás juegos flotan como una pantalla en 3D y se ven planos en 2D. + Automático + Pantalla virtual + Juego lado a lado + Ancho de la pantalla + Distancia de la pantalla + Gafas + Brillo + Volumen + Bloqueo solar + Oscurece las lentes frontales. + Seguimiento de cabeza + Seguimiento de cabeza + Los sensores de las gafas giran la pantalla virtual y SteamVR con tu cabeza. + Recentrar la vista + Jugando en %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d7bb3e583..e3a4f3b2a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -977,4 +977,41 @@ %1$s n\'indique aucun succès pour ce jeu. Tout afficher (%1$d) Succès · %1$s + Lunettes + Lunettes VITURE + Contrôle des lunettes + Autoriser + Permet à DroidDeck de régler la fréquence d\'affichage, le mode 3D, la luminosité, le volume et le pare-soleil des lunettes, et de lire leur suivi de la tête. + En attente de votre autorisation dans la fenêtre d\'Android… + Le contrôle a été refusé. Autorisez-le pour modifier les réglages des lunettes. + Ces lunettes affichent le jeu, mais leurs réglages ne peuvent pas être modifiés d\'ici. + Sortie + Jeu sur les lunettes + Affiche le jeu uniquement sur les lunettes. Désactivé : le téléphone l\'affiche et Android duplique l\'écran du téléphone. + Affichage + Fréquence d\'affichage + 3D (côte à côte) + Chaque œil reçoit sa propre moitié d\'une image de 3840×1080. Si l\'image disparaît, maintenez le bouton 2D/3D des lunettes. + Ce téléphone ne peut pas envoyer le signal 3840×1080 nécessaire à la 3D. Les jeux VR affichent la vue d\'un seul œil, avec le suivi de la tête. + Pas d\'image : les lunettes sont dans un mode que ce téléphone ne peut pas envoyer. Maintenez leur bouton 2D/3D, ou débranchez-les puis rebranchez-les. + Fréquence en 3D + 3D pour les jeux VR + Passe en 3D pendant que SteamVR tourne, puis revient ensuite. + Image 3D + Automatique : SteamVR remplit la vue ; les autres jeux flottent comme un écran en 3D et restent plats en 2D. + Automatique + Écran virtuel + Jeu côte à côte + Largeur de l\'écran + Distance de l\'écran + Lunettes + Luminosité + Volume + Pare-soleil + Assombrit les verres avant. + Suivi de la tête + Suivi de la tête + Les capteurs des lunettes font tourner l\'écran virtuel et SteamVR avec votre tête. + Recentrer la vue + En jeu sur %1$s \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 44da13653..de1b100a0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2103,4 +2103,41 @@ %1$s にはこのゲームの実績がありません。 すべて表示(%1$d) 実績 · %1$s + グラス + VITURE グラス + グラスの制御 + 許可 + DroidDeck がグラスのリフレッシュレート、3D モード、明るさ、音量、サンブロックを設定し、ヘッドトラッキングを読み取れるようにします。 + Android のダイアログで許可されるのを待っています… + 制御が拒否されました。グラスの設定を変更するには許可してください。 + このグラスにはゲームが表示されますが、ここから設定は変更できません。 + 出力 + グラスにゲームを表示 + ゲームをグラスだけに表示します。オフ: スマートフォンに表示し、Android がスマートフォンの画面をミラーリングします。 + 表示 + リフレッシュレート + 3D(サイドバイサイド) + 左右の目に 3840×1080 の画像の半分ずつを表示します。映像が消えたら、グラスの 2D/3D ボタンを長押ししてください。 + このスマートフォンは 3D に必要な 3840×1080 の信号を出力できません。VR ゲームはヘッドトラッキング付きで片目の映像を表示します。 + 映像なし: グラスがこのスマートフォンで出力できないモードになっています。2D/3D ボタンを長押しするか、抜き差ししてください。 + 3D のリフレッシュレート + VR ゲームで 3D + SteamVR の実行中は 3D に切り替え、終了後に戻します。 + 3D の映像 + 自動: SteamVR は視界全体に表示し、ほかのゲームは 3D ではスクリーンとして浮かび、2D では平面のままです。 + 自動 + 仮想スクリーン + サイドバイサイドのゲーム + スクリーンの幅 + スクリーンまでの距離 + グラス + 明るさ + 音量 + サンブロック + 前面のレンズを暗くします。 + ヘッドトラッキング + ヘッドトラッキング + グラスのセンサーで、頭の動きに合わせて仮想スクリーンと SteamVR を動かします。 + 視点をリセット + %1$s でプレイ中 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 944c8a327..358e5befa 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2103,4 +2103,41 @@ %1$s에 이 게임의 도전 과제가 없습니다. 모두 보기(%1$d) 도전 과제 · %1$s + 안경 + VITURE 안경 + 안경 제어 + 허용 + DroidDeck이 안경의 주사율, 3D 모드, 밝기, 볼륨, 선블록을 설정하고 헤드 트래킹을 읽도록 허용합니다. + Android 안내 창에서 허용하기를 기다리는 중… + 제어가 거부되었습니다. 안경 설정을 바꾸려면 허용하세요. + 이 안경은 게임을 표시하지만 여기에서 설정을 바꿀 수는 없습니다. + 출력 + 안경에 게임 표시 + 게임을 안경에만 표시합니다. 끄면 휴대폰에 표시되고 Android가 휴대폰 화면을 미러링합니다. + 디스플레이 + 주사율 + 3D (사이드 바이 사이드) + 각 눈이 3840×1080 화면의 절반씩을 봅니다. 화면이 끊기면 안경의 2D/3D 버튼을 길게 누르세요. + 이 휴대폰은 3D에 필요한 3840×1080 신호를 보낼 수 없습니다. VR 게임은 헤드 트래킹과 함께 한쪽 눈의 화면을 보여 줍니다. + 화면 없음: 안경이 이 휴대폰에서 보낼 수 없는 모드에 있습니다. 2D/3D 버튼을 길게 누르거나 안경을 뺐다가 다시 연결하세요. + 3D 주사율 + VR 게임에서 3D + SteamVR이 실행되는 동안 3D로 전환하고 끝나면 되돌립니다. + 3D 화면 + 자동: SteamVR은 시야 전체를 채우고, 다른 게임은 3D에서는 화면으로 떠 있고 2D에서는 평면으로 보입니다. + 자동 + 가상 화면 + 사이드 바이 사이드 게임 + 화면 너비 + 화면 거리 + 안경 + 밝기 + 볼륨 + 선블록 + 앞쪽 렌즈를 어둡게 합니다. + 헤드 트래킹 + 헤드 트래킹 + 안경의 센서가 머리 움직임에 맞춰 가상 화면과 SteamVR을 돌립니다. + 시점 재설정 + %1$s에서 플레이 중 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d98e99070..f64d35f89 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1088,4 +1088,41 @@ В %1$s нет достижений для этой игры. Показать все (%1$d) Достижения · %1$s + Очки + Очки VITURE + Управление очками + Разрешить + Разрешите DroidDeck менять частоту обновления, режим 3D, яркость, громкость и затемнение очков и считывать отслеживание головы. + Ожидание разрешения в окне Android… + Управление отклонено. Разрешите его, чтобы менять настройки очков. + Эти очки показывают игру, но их настройки нельзя изменить отсюда. + Вывод + Игра на очках + Показывать игру только на очках. Выкл.: игру показывает телефон, а Android дублирует экран телефона. + Экран + Частота обновления + 3D (стереопара) + Каждый глаз получает свою половину изображения 3840×1080. Если изображение пропадёт, удерживайте кнопку 2D/3D на очках. + Этот телефон не может передать сигнал 3840×1080, нужный для 3D. VR-игры показывают вид одного глаза с отслеживанием головы. + Нет изображения: очки в режиме, который этот телефон не может передать. Удерживайте их кнопку 2D/3D или отключите и снова подключите очки. + Частота в 3D + 3D для VR-игр + Включает 3D, пока работает SteamVR, и возвращает обратно после. + Изображение 3D + Автоматически: SteamVR заполняет весь обзор; другие игры висят экраном в 3D и остаются плоскими в 2D. + Автоматически + Виртуальный экран + Игра в стереопаре + Ширина экрана + Расстояние до экрана + Очки + Яркость + Громкость + Затемнение + Затемняет передние линзы. + Отслеживание головы + Отслеживание головы + Датчики очков поворачивают виртуальный экран и SteamVR вслед за головой. + Сбросить вид + Игра на %1$s \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3f5765ea4..bac9b2f66 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2103,4 +2103,41 @@ %1$s 未列出此游戏的成就。 显示全部 %1$d 项 成就 · %1$s + 眼镜 + VITURE 眼镜 + 眼镜控制 + 允许 + 允许 DroidDeck 设置眼镜的刷新率、3D 模式、亮度、音量和遮光,并读取其头部追踪。 + 正在等待你在 Android 提示中允许… + 控制已被拒绝。允许后才能更改眼镜设置。 + 这副眼镜可以显示游戏,但无法在此更改其设置。 + 输出 + 在眼镜上显示游戏 + 只在眼镜上显示游戏。关闭:由手机显示,Android 会镜像手机画面。 + 显示 + 刷新率 + 3D(左右分屏) + 每只眼睛各看 3840×1080 画面的一半。如果画面消失,请长按眼镜上的 2D/3D 按钮。 + 这部手机无法输出 3D 所需的 3840×1080 信号。VR 游戏会显示单眼画面,并带有头部追踪。 + 无画面:眼镜处于这部手机无法输出的模式。请长按眼镜的 2D/3D 按钮,或拔下后重新连接。 + 3D 刷新率 + VR 游戏使用 3D + SteamVR 运行时切换到 3D,结束后切回。 + 3D 画面 + 自动:SteamVR 填满视野;其他游戏在 3D 下以屏幕形式悬浮,在 2D 下保持平面。 + 自动 + 虚拟屏幕 + 左右分屏游戏 + 屏幕宽度 + 屏幕距离 + 眼镜 + 亮度 + 音量 + 遮光 + 让前镜片变暗。 + 头部追踪 + 头部追踪 + 眼镜自带的传感器会随头部转动虚拟屏幕和 SteamVR。 + 重新居中视角 + 正在 %1$s 上游玩 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 0a2096dab..1956c2227 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2103,4 +2103,41 @@ %1$s 未列出此遊戲的成就。 顯示全部 %1$d 項 成就 · %1$s + 眼鏡 + VITURE 眼鏡 + 眼鏡控制 + 允許 + 允許 DroidDeck 設定眼鏡的更新率、3D 模式、亮度、音量和遮光,並讀取其頭部追蹤。 + 正在等候你在 Android 提示中允許… + 控制已被拒絕。允許後才可更改眼鏡設定。 + 這副眼鏡可以顯示遊戲,但無法在此更改其設定。 + 輸出 + 在眼鏡上顯示遊戲 + 只在眼鏡上顯示遊戲。關閉:由手機顯示,Android 會鏡像手機畫面。 + 顯示 + 更新率 + 3D(左右並排) + 每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。 + 這部手機無法輸出 3D 所需的 3840×1080 訊號。VR 遊戲會顯示單眼畫面,並配合頭部追蹤。 + 沒有畫面:眼鏡處於這部手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。 + 3D 更新率 + VR 遊戲使用 3D + SteamVR 運行時切換到 3D,結束後切回。 + 3D 畫面 + 自動:SteamVR 填滿視野;其他遊戲在 3D 下以螢幕形式懸浮,在 2D 下保持平面。 + 自動 + 虛擬螢幕 + 左右並排遊戲 + 螢幕寬度 + 螢幕距離 + 眼鏡 + 亮度 + 音量 + 遮光 + 令前鏡片變暗。 + 頭部追蹤 + 頭部追蹤 + 眼鏡自帶的感應器會隨頭部轉動虛擬螢幕和 SteamVR。 + 重新置中視角 + 正在 %1$s 上遊玩 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0f38af5c3..c9d5c4134 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2103,4 +2103,41 @@ %1$s 沒有列出這款遊戲的成就。 顯示全部 %1$d 項 成就 · %1$s + 眼鏡 + VITURE 眼鏡 + 眼鏡控制 + 允許 + 允許 DroidDeck 設定眼鏡的更新率、3D 模式、亮度、音量和遮光,並讀取其頭部追蹤。 + 正在等待你在 Android 提示中允許… + 控制已被拒絕。允許後才能變更眼鏡設定。 + 這副眼鏡可以顯示遊戲,但無法在此變更其設定。 + 輸出 + 在眼鏡上顯示遊戲 + 只在眼鏡上顯示遊戲。關閉:由手機顯示,Android 會鏡像手機畫面。 + 顯示 + 更新率 + 3D(左右並排) + 每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。 + 這支手機無法輸出 3D 所需的 3840×1080 訊號。VR 遊戲會顯示單眼畫面,並搭配頭部追蹤。 + 沒有畫面:眼鏡處於這支手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。 + 3D 更新率 + VR 遊戲使用 3D + SteamVR 執行時切換到 3D,結束後切回。 + 3D 畫面 + 自動:SteamVR 填滿視野;其他遊戲在 3D 下以螢幕形式懸浮,在 2D 下保持平面。 + 自動 + 虛擬螢幕 + 左右並排遊戲 + 螢幕寬度 + 螢幕距離 + 眼鏡 + 亮度 + 音量 + 遮光 + 讓前鏡片變暗。 + 頭部追蹤 + 頭部追蹤 + 眼鏡內建的感測器會隨頭部轉動虛擬螢幕和 SteamVR。 + 重新置中視角 + 正在 %1$s 上遊玩 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d86d18244..8b0fe05a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2346,4 +2346,44 @@ Enable GOG achievements in Stores settings Achievements unavailable; check session logs Achievement unlocked + Glasses + VITURE glasses + Glasses control + Allow + Let DroidDeck set the glasses\' refresh rate, 3D mode, brightness, volume and sunblock, and read their head tracking. + Waiting for you to allow it in Android\'s prompt… + Control was declined. Allow it to change the glasses\' settings. + These glasses show the game, but their settings can\'t be changed from here. + Output + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Game on glasses + Show the game only on the glasses. Off: the phone shows it and Android mirrors the phone. + Display + Refresh rate + 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. + 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 + Switches to 3D while SteamVR runs, and back after. + 3D picture + Automatic: SteamVR fills the view; other games hang as a screen in 3D and stay flat in 2D. + Automatic + Virtual screen + Side-by-side game + Screen width + Screen distance + Glasses + Brightness + Volume + Sunblock + Darkens the front lenses. + Head tracking + Head tracking + The glasses\' own sensors turn the virtual screen and SteamVR with your head. + Recenter view + Playing on %1$s diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt new file mode 100644 index 000000000..3aeba76c5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt @@ -0,0 +1,111 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import kotlin.math.cos +import kotlin.math.sin + +class VitureProtocolTest { + private fun u8(b: Byte) = b.toInt() and 0xFF + private fun u16(b: ByteArray, at: Int) = u8(b[at]) or (u8(b[at + 1]) shl 8) + + @Test fun crcIsXmodem() { + val check = "123456789".toByteArray() + assertEquals(0x31C3, VitureProtocol.crc16(check, 0, check.size)) + } + + @Test fun packetLayout() { + val p = VitureProtocol.packet(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(5, 0)) + assertEquals(VitureProtocol.PACKET_SIZE, p.size) + assertEquals(0xFF, u8(p[0])) + assertEquals(VitureProtocol.MAGIC_COMMAND, u8(p[1])) + assertEquals(2 + 0x0C, u16(p, 4)) + assertEquals(VitureProtocol.MSG_BRIGHTNESS, u16(p, 14)) + assertEquals(5, u8(p[18])) + assertEquals(VitureProtocol.crc16(p, 4, u16(p, 4) + 2), u16(p, 2)) + for (i in 6 until 14) assertEquals(0, u8(p[i])) + } + + @Test fun parseRoundTripsAndChecksCrc() { + val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0x34)) + val f = VitureProtocol.parse(p, p.size) + assertNotNull(f) + assertEquals(VitureProtocol.MAGIC_COMMAND, f!!.magic) + assertEquals(VitureProtocol.MSG_DISPLAY, f.msgId) + assertArrayEquals(byteArrayOf(0x34), f.payload) + assertTrue(f.crcOk) + p[18] = 0x31 + assertFalse(VitureProtocol.parse(p, p.size)!!.crcOk) + } + + @Test fun parseReadsRepliesAndRejectsJunk() { + val reply = VitureProtocol.packet(VitureProtocol.MSG_GET_VOLUME, byteArrayOf(0, 5)) + reply[1] = VitureProtocol.MAGIC_REPORT.toByte() + val f = VitureProtocol.parse(reply, reply.size)!! + assertEquals(VitureProtocol.MAGIC_REPORT, f.magic) + assertArrayEquals(byteArrayOf(0, 5), f.payload) + assertNull(VitureProtocol.parse(ByteArray(64), 64)) + assertNull(VitureProtocol.parse(reply, 8)) + val wrongMagic = reply.copyOf().also { it[1] = 0x10 } + assertNull(VitureProtocol.parse(wrongMagic, wrongMagic.size)) + } + + @Test fun modelsAndModes() { + val pro = VitureProtocol.model(0x101d, "VITURE Pro XR Glasses")!! + assertEquals("VITURE Pro XR", pro.name) + assertEquals(VitureProtocol.Family.PRO, pro.family) + assertTrue(pro.controllable) + assertFalse(pro.sbs90) + assertEquals(0x34, pro.mode(120, false)) + assertEquals(0x33, pro.mode(90, false)) + assertEquals(0x31, pro.mode(60, false)) + assertEquals(0x32, pro.mode(90, true)) + assertEquals(0x32, pro.mode(60, true)) + + val luma = VitureProtocol.model(0x1131, null)!! + assertEquals("VITURE Luma", luma.name) + assertTrue(luma.sbs90) + assertEquals(0x44, luma.mode(120, false)) + assertEquals(0x45, luma.mode(90, true)) + assertEquals(0x42, luma.mode(60, true)) + + assertFalse(VitureProtocol.model(0x1201, null)!!.controllable) + assertNull(VitureProtocol.model(0x9999, "VITURE")) + assertEquals("VITURE One", VitureProtocol.model(0x1011, "Something else")!!.name) + } + + @Test fun modeHelpers() { + listOf(0x32, 0x35, 0x42, 0x45).forEach { assertTrue(VitureProtocol.modeIsSbs(it)) } + listOf(0x31, 0x33, 0x34, 0x41, 0x44).forEach { assertFalse(VitureProtocol.modeIsSbs(it)) } + assertEquals(120, VitureProtocol.modeHz(0x34)) + assertEquals(90, VitureProtocol.modeHz(0x35)) + assertEquals(60, VitureProtocol.modeHz(0x32)) + } + + @Test fun imuPrefersTheQuaternion() { + val half = Math.toRadians(30.0) / 2 + val payload = ByteBuffer.allocate(40) + .putFloat(1f).putFloat(2f).putFloat(3f) + .putFloat(0f).putFloat(0f) + .putFloat(cos(half).toFloat()).putFloat(0f).putFloat(0f).putFloat(sin(half).toFloat()) + .array() + val q = VitureProtocol.imuQuaternion(payload)!! + assertArrayEquals(floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()), q, 1e-5f) + } + + @Test fun imuFallsBackToEuler() { + val payload = ByteBuffer.allocate(12).putFloat(0f).putFloat(0f).putFloat(30f).array() + val q = VitureProtocol.imuQuaternion(payload)!! + val half = Math.toRadians(30.0) / 2 + assertArrayEquals(floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()), q, 1e-5f) + assertNull(VitureProtocol.imuQuaternion(ByteArray(8))) + val nan = ByteBuffer.allocate(12).putFloat(Float.NaN).putFloat(0f).putFloat(0f).array() + assertNull(VitureProtocol.imuQuaternion(nan)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt index c388569cb..287a1ce71 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt @@ -174,4 +174,37 @@ class CardboardTest { assertVec(HeadPose.apply(r, floatArrayOf(0f, 1f, 0f)), HeadPose.apply(s270, floatArrayOf(1f, 0f, 0f))) assertVec(r, HeadPose.toScreen(HeadPose.toScreen(HeadPose.toScreen(HeadPose.toScreen(r, 1), 1), 1), 1)) } + + @Test fun glassesLevelAndAheadIsStraightAhead() { + val s2w = HeadPose.fromGlasses(0f, 0f, 0f, 1f) + assertVec(floatArrayOf(1f, 0f, 0f), HeadPose.apply(s2w, floatArrayOf(0f, 0f, -1f))) + assertVec(floatArrayOf(0f, 0f, 1f), HeadPose.apply(s2w, floatArrayOf(0f, 1f, 0f))) + assertVec(HeadPose.IDENTITY, HeadPose.relative(HeadPose.recenter(s2w), s2w)) + } + + @Test fun glassesTurnedLeftLookLeft() { + val ref = HeadPose.recenter(HeadPose.fromGlasses(0f, 0f, 0f, 1f)) + val q = quat(0f, 0f, 1f, 30.0) + val view = HeadPose.relative(ref, HeadPose.fromGlasses(q[0], q[1], q[2], q[3])) + val s = sin(PI / 6).toFloat(); val c = cos(PI / 6).toFloat() + assertVec(floatArrayOf(-s, 0f, -c), HeadPose.apply(view, floatArrayOf(0f, 0f, -1f))) + assertVec(floatArrayOf(0f, 1f, 0f), HeadPose.apply(view, floatArrayOf(0f, 1f, 0f))) + } + + @Test fun glassesRecenterKeepsTheHorizon() { + val q = quat(0f, 0f, 1f, 75.0) + val ref = HeadPose.recenter(HeadPose.fromGlasses(q[0], q[1], q[2], q[3])) + assertVec(HeadPose.IDENTITY, HeadPose.relative(ref, HeadPose.fromGlasses(q[0], q[1], q[2], q[3]))) + val tilt = quat(0f, 1f, 0f, 20.0) + val both = HeadPose.multiply(HeadPose.fromQuaternion(q[0], q[1], q[2], q[3]), HeadPose.fromQuaternion(tilt[0], tilt[1], tilt[2], tilt[3])) + val view = HeadPose.multiply(HeadPose.transpose(ref.basis), HeadPose.multiply(both, floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f))) + val ahead = HeadPose.apply(view, floatArrayOf(0f, 0f, -1f)) + assertEquals(0f, ahead[0], eps) + assertEquals(-sin(PI / 9).toFloat(), ahead[1], eps) + } + + @Test fun monoIsNotPartOfTheSavedSettings() { + val c = Cardboard(enabled = true, mono = true) + assertEquals(false, Cardboard.decode(c.encode()).mono) + } } diff --git a/tools/cardboard/test_cardboard_params.c b/tools/cardboard/test_cardboard_params.c index 360f08857..ed39908e3 100644 --- a/tools/cardboard/test_cardboard_params.c +++ b/tools/cardboard/test_cardboard_params.c @@ -41,6 +41,12 @@ int main(void) { q.source = CARDBOARD_SOURCE_SBS; cardboard_push_constants(&q, pose, 3840, 1080, 2400, 1080, pc); CHECK(near(pc[17], 1920.0f / 1080.0f) && near(pc[18], 1.0f)); + q.mono = 1; + cardboard_push_constants(&q, pose, 3840, 1080, 1920, 1080, pc); + CHECK(near(pc[3], 0.0f) && near(pc[19], 1920.0f / 1080.0f) && near(pc[20], 1.0f)); + q.mono = 0; + cardboard_push_constants(&q, pose, 3840, 1080, 1920, 1080, pc); + CHECK(near(pc[3], 0.032f) && near(pc[19], 960.0f / 1080.0f) && near(pc[20], 0.0f)); /* Unknown sizes fall back instead of dividing by zero. */ cardboard_push_constants(&q, pose, 0, 0, 0, 0, pc); CHECK(isfinite(pc[17]) && isfinite(pc[19])); diff --git a/tools/droiddeckctl b/tools/droiddeckctl index db21fa31a..b1cca562a 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -485,6 +485,20 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: if command == "access": return add_serial(checked(provider_call(adb, serial, "access"), "ACCESS_FAILED"), serial) + if command == "glasses": + request = {"op": args.glasses_op or "status"} + if args.glasses_op == "set": + request.update(key=args.key, value=args.value) + elif args.glasses_op == "simulate": + request["seconds"] = args.seconds + elif args.glasses_op == "mode": + request["mode"] = args.mode + elif args.glasses_op == "imu": + request["ms"] = args.ms + elif args.glasses_op == "raw": + request.update(msgId=args.msg_id, payload=args.payload or "") + return add_serial(checked(provider_call(adb, serial, "glasses", request=request), "GLASSES_FAILED"), serial) + if command == "logs": state = get_state(adb, serial) session = state.get("session", {}) @@ -652,6 +666,25 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser("access", help="whether debugging tools are enabled on the device") + glasses = commands.add_parser("glasses", help="VITURE glasses: status, settings, head tracking test") + glasses_ops = glasses.add_subparsers(dest="glasses_op") + glasses_ops.add_parser("status") + glasses_ops.add_parser("allow", help="ask for USB control of the glasses again") + glasses_set = glasses_ops.add_parser("set", help="refreshHz, sbsHz, brightness, volume, sunblock, 3d, gameOnGlasses, headTracking, auto3d") + glasses_set.add_argument("key") + glasses_set.add_argument("value") + glasses_sim = glasses_ops.add_parser("simulate", help="feed a moving head pose (yaw, pitch and roll sweeps)") + glasses_sim.add_argument("--seconds", type=int, default=20) + glasses_ops.add_parser("stop-simulate") + glasses_ops.add_parser("query", help="ask the glasses for their display mode, brightness, volume and IMU state") + glasses_mode = glasses_ops.add_parser("mode", help="send a display mode byte, e.g. 0x31 (1080p60), 0x34 (1080p120), 0x32 (3D)") + glasses_mode.add_argument("mode") + glasses_imu = glasses_ops.add_parser("imu", help="stream the glasses' IMU for a moment and show what it reports") + glasses_imu.add_argument("--ms", type=int, default=2000) + glasses_raw = glasses_ops.add_parser("raw", help="send one MCU command (debugging tools)") + glasses_raw.add_argument("msg_id", help="hex message id, e.g. 0x0007") + glasses_raw.add_argument("payload", nargs="?", help="hex bytes") + logs = commands.add_parser("logs", help="pull the latest session artifact directory") logs.add_argument("which", choices=["latest"]) logs.add_argument("destination", nargs="?") diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index eaded9ebb..0ed96beee 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -101,6 +101,8 @@ HmdQuaternion_t QuatFromMatrix(const float m[9]) { return q; } +double VsyncPeriodSeconds(); + // IVRVirtualDisplay: the compositor renders the final frame into its own backbuffer and hands it // here instead of scanning it out. What the handle is on Linux is what this logs (experiment). class VirtualDisplay : public IVRVirtualDisplay { @@ -115,10 +117,10 @@ public: } void WaitForPresent() override {} bool GetTimeSinceLastVsync(float *seconds, uint64_t *counter) override { - // A free-running 60 Hz vsync. + // A free-running vsync at the panel's rate. using namespace std::chrono; double t = duration(steady_clock::now().time_since_epoch()).count(); - double period = 1.0 / 60.0; + double period = VsyncPeriodSeconds(); *seconds = (float)fmod(t, period); *counter = (uint64_t)(t / period); return true; @@ -130,16 +132,20 @@ private: }; // The vsync grid SteamVR paces against in driver-direct mode (Prop_DriverDirectModeSendsVsyncEvents): -// a free-running 60 Hz tick, declared a little ahead of time (ALVR's announcer, alvr/server_openvr/src/vsync.rs). +// a free-running tick at the panel's rate, declared a little ahead of time (ALVR's announcer, alvr/server_openvr/src/vsync.rs). class Vsync { public: - static constexpr std::chrono::nanoseconds kInterval{16666667}; static constexpr std::chrono::microseconds kLead{2000}; + static std::chrono::nanoseconds Interval() { return std::chrono::nanoseconds(intervalNs_.load()); } + static void SetRate(float hz) { + if (hz >= 24.0f && hz <= 240.0f) intervalNs_ = (int64_t)(1e9 / hz); + } + static void Start() { if (running_.exchange(true)) return; thread_ = std::thread([] { - auto next = std::chrono::steady_clock::now() + kInterval; + auto next = std::chrono::steady_clock::now() + Interval(); while (running_) { auto announce = next - kLead; std::this_thread::sleep_until(announce); @@ -150,9 +156,10 @@ public: std::lock_guard lock(mutex_); last_ = next; } - next += kInterval; + const auto interval = Interval(); + next += interval; now = std::chrono::steady_clock::now(); - while (next <= now) next += kInterval; + while (next <= now) next += interval; } }); } @@ -167,11 +174,14 @@ public: private: static inline std::atomic running_{false}; + static inline std::atomic intervalNs_{16666667}; static inline std::thread thread_; static inline std::mutex mutex_; static inline std::chrono::steady_clock::time_point last_{}; }; +double VsyncPeriodSeconds() { return Vsync::Interval().count() / 1e9; } + // IVRDriverDirectModeComponent: SteamVR renders each eye into textures this driver allocates // (shared Vulkan images from SteamVR's IPC resource manager, dmabufs on Linux) and hands them // over per frame. This is how ALVR gets frames on Linux; it avoids the compositor's own direct mode, @@ -276,7 +286,7 @@ public: void PostPresent(const Throttling_t *t) override { // Hold until the tick after the last one declared (ALVR's pacing): a slow frame costs one frame. auto last = Vsync::LastTick(); - auto until = last + Vsync::kInterval * (1 + (t ? t->nFramesToThrottle : 0)); + auto until = last + Vsync::Interval() * (1 + (t ? t->nFramesToThrottle : 0)); if (until > std::chrono::steady_clock::now()) std::this_thread::sleep_until(until); } @@ -468,6 +478,10 @@ public: tanHalf_ = hp.tan_half_fov; if (hp.eye_aspect > 0.0f) eyeAspect_ = hp.eye_aspect; } + if (pose_.Read(&hp) && hp.display_hz >= 24.0f) hz = hp.display_hz; + Vsync::SetRate(hz); + sentTan_ = tanHalf_; + sentAspect_ = eyeAspect_; VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "DroidDeck Cardboard"); VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck"); VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck"); @@ -551,7 +565,7 @@ public: bool IsDisplayRealDisplay() override { return false; } void GetRecommendedRenderTargetSize(uint32_t *w, uint32_t *h) override { // Rendered a little larger than the panel: the lens pass magnifies the middle. - *w = (uint32_t)(width_ / 2 * 1.4f); + *w = (uint32_t)(height_ * 1.4f * eyeAspect_); *h = (uint32_t)(height_ * 1.4f); } void GetEyeOutputViewport(EVREye eye, uint32_t *x, uint32_t *y, uint32_t *w, uint32_t *h) override { @@ -575,6 +589,22 @@ public: } private: + void UpdateEye(const droiddeck_headpose &hp) { + sentTan_ = tanHalf_ = hp.tan_half_fov; + sentAspect_ = eyeAspect_ = hp.eye_aspect; + HmdRect2_t r; + r.vTopLeft.v[0] = -tanHalf_ * eyeAspect_; + r.vTopLeft.v[1] = -tanHalf_; + r.vBottomRight.v[0] = tanHalf_ * eyeAspect_; + r.vBottomRight.v[1] = tanHalf_; + VRServerDriverHost()->SetDisplayProjectionRaw(id_, r, r); + uint32_t w = 0, h = 0; + GetRecommendedRenderTargetSize(&w, &h); + VRServerDriverHost()->SetRecommendedRenderTargetSize(id_, w, h); + Log("eye now tan(half fov) %.3f, aspect %.3f (%ux%u eye%s), render %ux%u", tanHalf_, eyeAspect_, hp.eye_w, hp.eye_h, + (hp.flags & DROIDDECK_HEADPOSE_MONO) ? ", one eye shown" : "", w, h); + } + void PoseLoop() { uint32_t lastSeq = 0; auto lastSent = std::chrono::steady_clock::now(); @@ -589,6 +619,10 @@ private: if ((fresh || padChanged || now - lastSent > std::chrono::milliseconds(100)) && id_ != k_unTrackedDeviceIndexInvalid) { if (fresh) { lastSeq = hp.seq; + if (hp.eye_w > 0 && hp.tan_half_fov > 0.0f && hp.eye_aspect > 0.0f && + (fabsf(hp.tan_half_fov - sentTan_) > 1e-3f || fabsf(hp.eye_aspect - sentAspect_) > 1e-3f)) + UpdateEye(hp); + if (hp.display_hz >= 24.0f) Vsync::SetRate(hp.display_hz); const bool worn = (hp.flags & DROIDDECK_HEADPOSE_ENABLED) != 0; if (worn != worn_) { worn_ = worn; @@ -609,6 +643,7 @@ private: uint32_t id_ = k_unTrackedDeviceIndexInvalid; int32_t width_ = 1920, height_ = 1080; float tanHalf_ = 1.0f, eyeAspect_ = 0.889f; + float sentTan_ = 0.0f, sentAspect_ = 0.0f; public: void SetHands(CardboardController *left, CardboardController *right) { hands_[0] = left; hands_[1] = right; }