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; }