From a692436f67863a014bbc295ab6ac44ea1ed71c5b Mon Sep 17 00:00:00 2001 From: Jagger15 Date: Thu, 15 Oct 2026 09:51:02 -0400 Subject: [PATCH] fix(quest): correct desktop and Steam display corruption across sessions (#733) fix(quest): stabilize desktop and Steam layer presentation Keep Quest SDR scenes and the black backdrop off the Android Vulkan swapchain regardless of session order or HDR preferences. Only report the first frame after client content is submitted. Add backdrop lifecycle and first-frame regression coverage. Record the user's successful Quest 3 Test 2 result. Co-authored-by: xXJsonDeruloXx --- app/src/main/cpp/waylandcomp/src/compositor.c | 10 +- .../main/cpp/waylandcomp/src/quest_device.h | 13 ++ app/src/main/cpp/waylandcomp/src/sc_layer.c | 55 +++++++- app/src/main/cpp/waylandcomp/src/sc_layer.h | 2 + app/src/main/cpp/waylandcomp/src/vk_present.c | 57 +++++++- app/src/main/cpp/waylandcomp/src/vk_present.h | 2 + docs/quest-presentation-test.md | 126 ++++++++++++++++++ docs/quest.md | 3 + tools/quest/test_first_frame.c | 74 ++++++++++ tools/quest/test_presentation.c | 103 ++++++++++++++ tools/tests/test_quest_presentation.py | 75 +++++++++++ 11 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 app/src/main/cpp/waylandcomp/src/quest_device.h create mode 100644 docs/quest-presentation-test.md create mode 100644 tools/quest/test_first_frame.c create mode 100644 tools/quest/test_presentation.c create mode 100644 tools/tests/test_quest_presentation.py diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index bff3f3959..2b3b66d09 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -1871,7 +1871,12 @@ static void render_scene(void) { else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0); else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver"; } - if (hdr_route == 0 || hdr_route == 3) { + if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && + !ahb_layer_only_candidate(w, h)) { + /* The complete SDR scene stays on one display layer. Do not hide/re-show GAME + * on each desktop frame just because it is not a single fullscreen window. */ + rendered = vkp_render(w, h, dl.d, dl.n) == 0; + } else if (hdr_route == 0 || hdr_route == 3) { const int pass_on = fx_on || framegen; int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1; int over = li >= 0 ? dl.n - 1 - li : 0; @@ -3156,6 +3161,9 @@ int droiddeck_wayland_run(void) { open_session_log(); if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn"); + /* Presentation compatibility is not HDR capability. Set the initial layer mode before + * clients bind, including when Desktop is the first session in this app process. */ + if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1; struct wl_display *display = wl_display_create(); if (!display) { diff --git a/app/src/main/cpp/waylandcomp/src/quest_device.h b/app/src/main/cpp/waylandcomp/src/quest_device.h new file mode 100644 index 000000000..ee9c37b10 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/quest_device.h @@ -0,0 +1,13 @@ +#ifndef DROIDDECK_QUEST_DEVICE_H +#define DROIDDECK_QUEST_DEVICE_H +#include +#include + +/* Presentation only; this does not select a GPU driver or expand driver support. */ +static inline int quest_device_matches(const char *model, const char *device, const char *maker) { + return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") || + !strcasecmp(model, "Quest 3S") || + ((!strcasecmp(maker, "Oculus") || !strcasecmp(maker, "Meta")) && + (!strcmp(device, "hollywood") || !strcmp(device, "eureka") || !strcmp(device, "panther"))); +} +#endif diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.c b/app/src/main/cpp/waylandcomp/src/sc_layer.c index c7a00eac9..f9d203ba1 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.c +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.c @@ -185,6 +185,8 @@ static int g_pending_cb; static unsigned g_stat_layer_frames; static int g_two_logged; static AHardwareBuffer *g_blank; +static ASurfaceControl *g_base_sc; +static int g_base_w, g_base_h, g_base_shown; static void layers_init(void) { if (g_layers_ready) return; @@ -262,14 +264,58 @@ static AHardwareBuffer *blank_buffer(void) { AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY}; if (AHardwareBuffer_allocate(&d, &g_blank) != 0 || !g_blank) { g_blank = NULL; return NULL; } void *p = NULL; - if (AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p) == 0 && p) { - AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got); - memset(p, 0, (size_t)got.stride * 4 * 16); - AHardwareBuffer_unlock(g_blank, NULL); + int locked = AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p); + if (locked != 0 || !p) { + if (locked == 0) AHardwareBuffer_unlock(g_blank, NULL); + AHardwareBuffer_release(g_blank); g_blank = NULL; + return NULL; + } + AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got); + memset(p, 0, (size_t)got.stride * 4 * got.height); + if (AHardwareBuffer_unlock(g_blank, NULL) != 0) { + AHardwareBuffer_release(g_blank); g_blank = NULL; + return NULL; } return g_blank; } +int sc_layer_base_black(int width, int height) { + if (width <= 0 || height <= 0 || load_api() != 0 || !vkp_window()) return -1; + if (g_base_sc && g_base_shown && width == g_base_w && height == g_base_h) return 0; + AHardwareBuffer *black = blank_buffer(); + if (!black) return -1; + if (!g_base_sc) g_base_sc = api.createFromWindow(vkp_window(), "droiddeck_wayland_black"); + if (!g_base_sc) return -1; + ASurfaceTransaction *tx = api.txCreate(); + if (!tx) return -1; + ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height}; + api.setBuffer(tx, g_base_sc, black, -1); + api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE); + api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */ + api.setGeometry(tx, g_base_sc, &src, &dst, 0); + api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW); + api.txApply(tx); api.txDelete(tx); + g_base_w = width; g_base_h = height; g_base_shown = 1; + return 0; +} + +static void base_hide(int retire) { + if (!g_base_sc) return; + ASurfaceTransaction *tx = api.txCreate(); + if (tx) { + api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_HIDE); + if (retire) api.reparent(tx, g_base_sc, NULL); + api.txApply(tx); api.txDelete(tx); + g_base_shown = 0; + } + if (retire) { + api.release(g_base_sc); g_base_sc = NULL; + g_base_w = g_base_h = g_base_shown = 0; + } +} + +void sc_layer_hide_base(void) { base_hide(0); } + static void replace_with_blank(ASurfaceTransaction *tx, struct layer *l) { AHardwareBuffer *b = blank_buffer(); if (b) api.setBuffer(tx, l->sc, b, -1); @@ -927,6 +973,7 @@ void sc_layer_hide_overlay(void) { } void sc_layer_window_gone(void) { + base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */ if (api.state != 1) return; layers_init(); for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]); diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.h b/app/src/main/cpp/waylandcomp/src/sc_layer.h index 9df4cd80e..58fe5f6d6 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.h +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.h @@ -36,6 +36,8 @@ int sc_layer_overlay_affordable(void); int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent); void sc_layer_hide(void); +int sc_layer_base_black(int width, int height); +void sc_layer_hide_base(void); void sc_layer_hide_overlay(void); void sc_layer_window_gone(void); diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 832521468..9405f00ed 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -8,9 +8,11 @@ #include "hdr_compose.h" #include "blend_pass.h" #include "droiddeck_color.h" +#include "quest_device.h" #include #include #include +#include #include #include #include @@ -64,6 +66,21 @@ static VkExtent2D g_extent; static int g_first_frame_done; static int g_base_black; + +int vkp_quest_layer_presentation(void) { + /* Compositor thread only; this is a process-wide device property, not a session setting. + * Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */ + static int enabled = -1; + if (enabled < 0) { + char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0}; + __system_property_get("ro.product.model", model); + __system_property_get("ro.product.device", device); + __system_property_get("ro.product.manufacturer", maker); + enabled = quest_device_matches(model, device, maker); + if (enabled) droiddeck_log("gpu", "Quest presentation: display layers for Steam and desktop, CPU-initialized black backdrop; independent of HDR"); + } + return enabled; +} static VkExtent2D g_caps_extent; static int64_t g_surface_checked_ns; @@ -1335,6 +1352,10 @@ static void check_surface_changed(void) { int vkp_base_black(int scene_w, int scene_h) { vkp_apply_window_request(); + if (vkp_quest_layer_presentation()) { + if (vkp_update_map(scene_w, scene_h) != 0) return -1; + return sc_layer_base_black((int)g_extent.width, (int)g_extent.height); + } check_surface_changed(); if (g_base_black && g_swapchain && g_window && g_dev_state == 1) { g_perf.base_kept++; @@ -1413,6 +1434,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i const struct vkp_hdr_frame *hf, int *how) { if (how) *how = 0; vkp_apply_window_request(); + sc_layer_hide_base(); check_surface_changed(); if (g_dev_state == -2) return -1; if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1; @@ -1670,6 +1692,26 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i } int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) { + if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) { + /* The desktop (multiple windows), overlays and an empty scene must also avoid WSI. + * Merely enabling zero-copy handles only a single fullscreen window and leaves both + * the letterbox and every fallback frame going through the corrupted swapchain. */ + vkp_apply_window_request(); + sc_layer_hide_overlay(); + if (n > 0) { + if (sc_layer_present_pass(draws, n, scene_w, scene_h) != 0) { + static int said; + if (!said) { said = 1; droiddeck_log("error", "Quest scene layer unavailable; retaining the previous frame rather than using the corrupt Android swapchain"); } + return -1; + } + } else { + sc_layer_hide(); + } + /* sc_layer_present_pass signals only after submitting client content. + * A backdrop alone, or a successful no-op while a layer slot is unavailable, + * must not dismiss the app's loading UI or mark Steam's window as shown. */ + return vkp_base_black(scene_w, scene_h); + } return render_impl(scene_w, scene_h, draws, n, NULL, NULL); } @@ -1703,6 +1745,15 @@ void vkp_signal_first_frame(void) { int vkp_update_map(int scene_w, int scene_h) { if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1; + if (vkp_quest_layer_presentation()) { + /* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do + * not create/present a swapchain just to paint black under an otherwise clean layer. */ + int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window); + if (w <= 0 || h <= 0) return -1; + g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h}; + update_map(scene_w, scene_h); + return g_map.valid ? 0 : -1; + } check_surface_changed(); if (!g_swapchain && swap_init() != 0) return -1; update_map(scene_w, scene_h); @@ -1911,11 +1962,7 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf, int *rw, int *rh) { if (g_pass.active) vkp_pass_abort(); - if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1; - check_surface_changed(); - if (!g_swapchain && swap_init() != 0) return -1; - update_map(scene_w, scene_h); - if (!g_map.valid) return -1; + if (vkp_update_map(scene_w, scene_h) != 0) return -1; if (hf) { if (ensure_img(&g_hdrscene, scene_w, scene_h, HDR_FMT, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 18a976e21..a965807e7 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -8,6 +8,8 @@ void vk_present_set_driver(const char *driver_path, const char *library_name, void vk_present_set_window(ANativeWindow *window); int vkp_apply_window_request(void); +int vkp_quest_layer_presentation(void); + enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3, VKP_MODE_INTEGER = 4 }; enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 }; diff --git a/docs/quest-presentation-test.md b/docs/quest-presentation-test.md new file mode 100644 index 000000000..3338d96a1 --- /dev/null +++ b/docs/quest-presentation-test.md @@ -0,0 +1,126 @@ +# Quest presentation candidate Test 2 — 2026-10-13 + +Base: `main` at `a394dfd31b2102e20dd99c6f51b4873bbdedab05`. +Reference APK: `DroidDeck-PR669-A6xx-Test2(1).apk`, SHA-256 +`95ffb5ea6ae61d6a92c4ce9ad46f329d77a0f113ebbaabc6da30d6c8bd951154`. +Its embedded source revision is `d3f3439bbc45ca67de86d0093356a449c2c74600`. + +## Evidence and scope + +Reported symptoms: Desktop shows vertical corruption; Steam has green marks in +its black borders; starting Desktop first also corrupts subsequent Steam, and +closing Steam can expose corruption when returning to the menu. The supplied +modified PR669 APK reportedly avoids the transition problem. + +Source inspection confirms that the compositor outlives sessions. The old +Quest workaround enables only `hdr.steam`, and the initial HDR-gated layer +selection happens only at compositor startup. Whole desktops and black borders +can still use the Android Vulkan swapchain even when a fullscreen game uses a +SurfaceControl layer. This is consistent with the reported symptoms, but is not +an on-device reproduction or proof of the exact driver defect. + +The supplied APK has the same Steam-only preference initialization and the +same bundled Quest 3 Turnip binary as main (SHA-256 +`af0d6bb38756eace17b43311c5c61a7ef3dc6e83d5cb8a63577d6a1d10c0aed0`). +It additionally supports a Quest 2 driver. Therefore the APK comparison does +not establish a new Quest 3 driver regression. Device, installed driver, +persistent preferences and session logs still matter. + +## Test 1 startup regression and Test 2 correction + +The Test 1 user confirmed a Quest 3 and a black window after runtime setup. +The supplied state report identifies `0.4.0-questdisplay-test1`, runtime `r9` +installed, an active guest, `installing=null`, no recorded failure, and saved +session artifacts. The session was suspended at capture. Suspension alone does +not establish the cause of the original black window. The retained main log +starts after the test window was closed, so the startup diagnostics are absent. + +Source review found a Test 1 bug: `vkp_render` unconditionally signaled the first +frame after successfully showing the black backdrop, even with no client draws. +The Android listener then hid the loading UI and marked Steam's window as shown. +This also happened when the content-layer operation returned a successful no-op +(no visible geometry or no available pool slot). Consequently `firstFrame=true` +in Test 1 is not reliable evidence that Steam had displayed a frame. + +Test 2 removes the backdrop notification. The existing content-layer presenter +owns the notification after submitting client content. A host regression test +compiles the production `vkp_render` entrypoint with controlled layer outcomes; +it failed on Test 1's empty scene and passes with this change. It also covers +successful no-ops, content presentation, errors and the existing non-Quest, +frame-generation and Cardboard routes. All seven focused host tests pass. + +On 2026-10-13, the user tested Test 2 on Quest 3 and confirmed that it was +functioning as intended. This is a user-reported hardware result; a separate +pass/fail record for each checklist item below was not supplied. Broader headset, +game, colour and performance coverage remains outstanding. The user's separate +Quest 2 success report concerns the Adreno 6xx compatibility PR, not this build. + +## Candidate change + +- Recognize Quest 2/3/3S for presentation independently of HDR and session order. + This does not install an A6xx driver or expand main's Quest 2 driver support. +- Enable the layer protocol before the first client connects. +- Compose complete SDR scenes into the existing GPU-rendered gralloc layer, + including Desktop's multiple windows. Keep the existing path for a fullscreen + AHB that cannot be imported into the compositor. +- Present the black backdrop as an opaque SurfaceControl layer, initialized + once through a CPU-mapped 16-by-16 buffer. Game/desktop rendering stays on + the GPU. The backdrop does not use a Turnip Android swapchain. +- Use actual native-window dimensions for layer geometry without creating a + Vulkan swapchain solely for sizing. +- Retire the backdrop on detach/rebind and hide it before a real swapchain + frame. Reject a black buffer whose CPU lock/unlock failed. + +Other devices retain their normal presentation paths. Frame generation and +Cardboard still use the original swapchain path and are outside this test's +coverage; leave them off for the first test. HDR negotiation itself is unchanged. + +## Validation + +The full ARM64 native compositor, including the existing frame-generation +engines, is compiled from this checkout with Android NDK r27d, API 26 and 16 KB +ELF alignment. Host tests exercise the actual backdrop implementation with +mock Android APIs: initialized pixels and padded rows, immutable reuse, +resize, hide/show, retirement/recreation, absent windows, allocation/lock/ +unlock/transaction failure recovery, and the Quest device gate. These checks +cannot validate Horizon's compositor, colours, latency or graphics output. + +Run the focused host tests (LeakSanitizer may need `ASAN_OPTIONS=detect_leaks=0` +when the test runner itself operates under ptrace): + +```sh +python3 -m unittest tools.tests.test_quest_presentation tools.tests.test_quest_kgsl -v +``` + +The test APK uses the verified official CI APK for this exact base commit, +with only `lib/arm64-v8a/libdroiddeckwayland.so` rebuilt and replaced, an added +`assets/quest-presentation-test.json` provenance record, and a new APK signature. +DEX, other native libraries and runtime assets stay unchanged. The manifest is +repackaged with the repository's variant helper as `com.droiddeck.questdisplaytest` +so it can install beside the normal app and the supplied APK, whose signing key +is different. Provider authorities and the signature permission are renamed too. +It uses separate app data and needs its own initial setup. It is not a complete +Gradle rebuild. The displayed version is `0.4.0-questdisplay-test2`. It is signed +using the repository's public AOSP test key. The launcher label remains DroidDeck; +check the version in app info to distinguish the test installation. + +Test 2 retains Test 1's package, version code and signing certificate. Install it +over Test 1 without uninstalling or clearing data; its installed runtime and +session logs are retained. The original main application remains separate. + +## Headset test + +Fully close DroidDeck before starting. Keep the same driver and resolution +settings that produced the problem; turn off frame generation and Cardboard. + +1. Cold launch Desktop. Check the desktop, windows and black borders. +2. Switch Desktop to Steam without restarting the app. +3. Close Steam and return to the DroidDeck menu. +4. Fully close the app, then launch Steam first; inspect the top/bottom borders. +5. Switch to Desktop and repeat both transitions several times. +6. Resize the Quest panel and suspend/resume the app once. + +Record the headset model and whether each step is fixed. If it fails, export +that session's logs, including `wayland.log` and device information. Look for +`Quest presentation: display layers for Steam and desktop` to confirm this +renderer was loaded. Test 2's reported Quest 3 result is recorded above. diff --git a/docs/quest.md b/docs/quest.md index 8a05fd19d..597fb5ac5 100644 --- a/docs/quest.md +++ b/docs/quest.md @@ -24,6 +24,9 @@ first launch selects manual drivers, the bundled `turnip25.1.0-quest1` Android driver and the default Linux driver. It enables `hdr.steam` to use the existing SurfaceControl presentation workaround from [the Quest report](https://github.com/Droid-Deck/DroidDeck/issues/245). +Presentation also has a Quest-specific layer path for complete SDR scenes and +the black backdrop, independent of this HDR preference; see +[the presentation candidate and headset test](quest-presentation-test.md). These defaults apply once; subsequent user changes are retained. Colour accuracy and headset performance still need testing. diff --git a/tools/quest/test_first_frame.c b/tools/quest/test_first_frame.c new file mode 100644 index 000000000..f58da48bb --- /dev/null +++ b/tools/quest/test_first_frame.c @@ -0,0 +1,74 @@ +/* Exercise the production vkp_render entrypoint without Android/Vulkan hardware. + * The Python runner extracts that function verbatim into the included file. */ +#include +#include +#include + +struct vkp_draw { int unused; }; +static int quest, framegen, cardboard, pass_result, base_result, pass_shown; +static int first_frames, pass_calls, base_calls, fallback_calls; +static int vkp_quest_layer_presentation(void) { return quest; } +static int vkp_framegen_active(void) { return framegen; } +static int vkp_cardboard_active(void) { return cardboard; } +static void vkp_apply_window_request(void) {} +static void sc_layer_hide_overlay(void) {} +static void sc_layer_hide(void) {} +static void droiddeck_log(const char *tag, const char *message, ...) {} +static void vkp_signal_first_frame(void) { first_frames++; } +static int vkp_base_black(int w, int h) { base_calls++; return base_result; } +static int sc_layer_present_pass(const struct vkp_draw *draws, int n, int w, int h) { + pass_calls++; + /* Production sc_layer_present_pass signals only after a content submission. + * Its no-op results (off-screen geometry or pool exhaustion) also return 0. */ + if (pass_result == 0 && pass_shown) vkp_signal_first_frame(); + return pass_result; +} +static int render_impl(int w, int h, const struct vkp_draw *draws, int n, + const void *hdr, int *how) { fallback_calls++; return 17; } + +#include "vkp_render_under_test.inc" + +static void reset(void) { + quest = 1; + framegen = cardboard = pass_result = base_result = pass_shown = 0; + first_frames = pass_calls = base_calls = fallback_calls = 0; +} + +int main(void) { + const struct vkp_draw draw = {0}; + reset(); + for (int i = 0; i < 3; i++) assert(vkp_render(1280, 720, NULL, 0) == 0); + assert(base_calls == 3 && pass_calls == 0 && first_frames == 0); + + /* An accepted layer operation is not necessarily a displayed client frame. */ + reset(); + assert(vkp_render(1280, 720, &draw, 1) == 0); + assert(pass_calls == 1 && base_calls == 1 && first_frames == 0); + + /* Content is the sole owner of the notification, with no second backdrop event. */ + reset(); pass_shown = 1; + assert(vkp_render(1280, 720, &draw, 1) == 0); + assert(first_frames == 1); + assert(vkp_render(1280, 720, NULL, 0) == 0); + assert(first_frames == 1); + + reset(); pass_result = -1; + assert(vkp_render(1280, 720, &draw, 1) == -1); + assert(first_frames == 0 && base_calls == 0); + reset(); base_result = -1; + assert(vkp_render(1280, 720, NULL, 0) == -1); + assert(first_frames == 0); + + /* Existing routes remain responsible for their own notifications. */ + for (int mode = 0; mode < 3; mode++) { + reset(); + if (mode == 0) quest = 0; + if (mode == 1) framegen = 1; + if (mode == 2) cardboard = 1; + assert(vkp_render(1280, 720, &draw, 1) == 17); + assert(fallback_calls == 1 && pass_calls == 0 && base_calls == 0); + assert(first_frames == 0); + } + puts("PASS: Quest first-frame notification waits for client content"); + return 0; +} diff --git a/tools/quest/test_presentation.c b/tools/quest/test_presentation.c new file mode 100644 index 000000000..dcbad6224 --- /dev/null +++ b/tools/quest/test_presentation.c @@ -0,0 +1,103 @@ +/* Exercise the production backdrop with mock Android buffers/transactions. */ +#include "../../app/src/main/cpp/waylandcomp/src/sc_layer.c" +#include "../../app/src/main/cpp/waylandcomp/src/quest_device.h" +#include + +struct AHardwareBuffer { unsigned char pixels[20 * 16 * 4]; }; +static struct AHardwareBuffer buffer; +static int fail_alloc, fail_lock, fail_unlock, fail_tx; +static int allocs, locks, unlocks, buffers, applies, creates, releases, retirements; +static int visible, zorder, opacity; +static ARect destination; +static ANativeWindow *window = (ANativeWindow *)1; +ANativeWindow *vkp_window(void) { return window; } +void droiddeck_log(const char *kind, const char *fmt, ...) { (void)kind; (void)fmt; } +int AHardwareBuffer_allocate(const AHardwareBuffer_Desc *d, AHardwareBuffer **b) { + assert(d->width == 16 && d->height == 16); + assert(d->usage & AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY); + allocs++; + if (fail_alloc) return -1; + memset(&buffer, 0xa5, sizeof(buffer)); *b = &buffer; return 0; +} +void AHardwareBuffer_describe(const AHardwareBuffer *b, AHardwareBuffer_Desc *d) { + assert(b == &buffer); *d = (AHardwareBuffer_Desc){.width=16,.height=16,.stride=20}; +} +int AHardwareBuffer_lock(AHardwareBuffer *b, uint64_t usage, int32_t fd, const ARect *r, void **p) { + (void)usage; (void)fd; (void)r; assert(b == &buffer); locks++; + if (fail_lock) return -1; + *p = buffer.pixels; return 0; +} +int AHardwareBuffer_unlock(AHardwareBuffer *b, int32_t *f) { + assert(b == &buffer && f == NULL); unlocks++; return fail_unlock ? -1 : 0; +} +void AHardwareBuffer_release(AHardwareBuffer *b) { assert(b == &buffer); releases++; } +static ASurfaceControl *create(ANativeWindow *w, const char *n) { + assert(w == window && strcmp(n, "droiddeck_wayland_black") == 0); creates++; + return (ASurfaceControl *)(uintptr_t)(100 + creates); +} +static ASurfaceTransaction *tx_create(void) { return fail_tx ? NULL : (ASurfaceTransaction *)2; } +static void tx_delete(ASurfaceTransaction *t) { assert(t == (ASurfaceTransaction *)2); } +static void tx_apply(ASurfaceTransaction *t) { (void)t; applies++; } +static void set_buffer(ASurfaceTransaction *t, ASurfaceControl *s, AHardwareBuffer *b, int fd) { + (void)t; (void)s; assert(b == &buffer && fd == -1); + /* No garbage, including padded row bytes, reaches the display. */ + for (unsigned i=0; i 0); buffers++; +} +static void visibility(ASurfaceTransaction *t, ASurfaceControl *s, int8_t v) { (void)t; (void)s; visible=v; } +static void z(ASurfaceTransaction *t, ASurfaceControl *s, int32_t v) { (void)t; (void)s; zorder=v; } +static void transparency(ASurfaceTransaction *t, ASurfaceControl *s, int8_t v) { (void)t; (void)s; opacity=v; } +static void geometry(ASurfaceTransaction *t, ASurfaceControl *s, const ARect *src, const ARect *dst, int32_t rotation) { + (void)t; (void)s; assert(src->right==16 && src->bottom==16 && rotation==0); destination=*dst; +} +static void reparent(ASurfaceTransaction *t, ASurfaceControl *s, ASurfaceControl *parent) { + (void)t; (void)s; assert(parent==NULL); retirements++; +} +static void release(ASurfaceControl *s) { assert(s); } +static void reset(void) { + base_hide(1); g_blank=NULL; + fail_alloc=fail_lock=fail_unlock=fail_tx=0; + allocs=locks=unlocks=buffers=applies=creates=releases=retirements=0; + window=(ANativeWindow *)1; +} +int main(void) { + assert(quest_device_matches("Quest 2", "", "")); + assert(quest_device_matches("quest 3", "", "")); + assert(quest_device_matches("Quest 3S", "", "")); + assert(quest_device_matches("unknown", "hollywood", "Oculus")); + assert(quest_device_matches("unknown", "eureka", "Meta")); + assert(quest_device_matches("unknown", "panther", "Oculus")); + assert(!quest_device_matches("Pixel", "panther", "Google")); + assert(!quest_device_matches("Quest Pro", "seacliff", "Meta")); + assert(!quest_device_matches("", "", "")); + api.state=1; api.createFromWindow=create; api.txCreate=tx_create; api.txDelete=tx_delete; + api.txApply=tx_apply; api.setBuffer=set_buffer; api.setVisibility=visibility; + api.setZOrder=z; api.setBufferTransparency=transparency; api.setGeometry=geometry; + api.reparent=reparent; api.release=release; + reset(); + assert(sc_layer_base_black(1920,1080)==0); + assert(visible==ASC_VISIBILITY_SHOW && zorder==0 && opacity==ASC_TRANSPARENCY_OPAQUE); + assert(destination.right==1920 && destination.bottom==1080); + assert(allocs==1 && locks==1 && buffers==1 && creates==1); + assert(sc_layer_base_black(1920,1080)==0 && applies==1); /* retained, not redrawn */ + assert(sc_layer_base_black(1440,1080)==0 && destination.right==1440 && allocs==1); + sc_layer_hide_base(); assert(!visible && !g_base_shown); + assert(sc_layer_base_black(1440,1080)==0 && visible && allocs==1); + base_hide(1); assert(!g_base_sc && !g_base_shown && retirements==1); + window=(ANativeWindow *)3; + assert(sc_layer_base_black(1280,720)==0 && creates==2 && allocs==1 && locks==1); + reset(); fail_alloc=1; + assert(sc_layer_base_black(1920,1080)==-1 && buffers==0 && locks==0); + reset(); fail_lock=1; + assert(sc_layer_base_black(1920,1080)==-1 && buffers==0 && releases==1 && g_blank==NULL); + reset(); fail_unlock=1; + assert(sc_layer_base_black(1920,1080)==-1 && buffers==0 && releases==1 && g_blank==NULL); + reset(); fail_tx=1; + assert(sc_layer_base_black(1920,1080)==-1 && buffers==0 && !g_base_shown); + fail_tx=0; assert(sc_layer_base_black(1920,1080)==0 && creates==1 && locks==1); + reset(); window=NULL; + assert(sc_layer_base_black(1920,1080)==-1 && allocs==0); + window=(ANativeWindow *)1; + assert(sc_layer_base_black(0,1080)==-1 && allocs==0); + puts("PASS: Quest backdrop initialization, padding, reuse, resize, retire and failure recovery"); +} diff --git a/tools/tests/test_quest_presentation.py b/tools/tests/test_quest_presentation.py new file mode 100644 index 000000000..9c87926e4 --- /dev/null +++ b/tools/tests/test_quest_presentation.py @@ -0,0 +1,75 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(shutil.which('cc'), 'needs a C compiler') +class QuestBackdropTest(unittest.TestCase): + def test_first_frame_requires_client_content(self): + # Compile the real entrypoint with controlled layer outcomes, including the + # successful no-op that previously dismissed the loading UI on a black frame. + source = (ROOT / 'app/src/main/cpp/waylandcomp/src/vk_present.c').read_text() + start = source.index('\nint vkp_render(') + end = source.index('\nint vkp_render_hdr(', start) + with tempfile.TemporaryDirectory() as folder: + work = Path(folder) + (work / 'vkp_render_under_test.inc').write_text(source[start:end]) + binary = work / 'test-first-frame' + subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', + '-Wno-unused-parameter', '-Wno-unused-function', f'-I{work}', + str(ROOT / 'tools/quest/test_first_frame.c'), '-o', str(binary)], + check=True, capture_output=True, text=True) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS: Quest first-frame', result.stdout) + + def test_backdrop_lifecycle_and_failed_buffer_initialization(self): + with tempfile.TemporaryDirectory() as folder: + work = Path(folder) + android = work / 'android' + android.mkdir() + (android / 'rect.h').write_text('''#pragma once +#include +typedef struct ARect { int32_t left, top, right, bottom; } ARect; +''') + (android / 'native_window.h').write_text('''#pragma once +typedef struct ANativeWindow ANativeWindow; +''') + (android / 'hardware_buffer.h').write_text('''#pragma once +#include +#include "rect.h" +typedef struct AHardwareBuffer AHardwareBuffer; +typedef struct { uint32_t width,height,layers,format; uint64_t usage; + uint32_t stride,rfu0; uint64_t rfu1; } AHardwareBuffer_Desc; +#define AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM 1 +#define AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM 0x2b +#define AHARDWAREBUFFER_USAGE_CPU_READ_RARELY 2ULL +#define AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY 32ULL +#define AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE 256ULL +#define AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT 512ULL +#define AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY 2048ULL +int AHardwareBuffer_allocate(const AHardwareBuffer_Desc *, AHardwareBuffer **); +void AHardwareBuffer_describe(const AHardwareBuffer *, AHardwareBuffer_Desc *); +int AHardwareBuffer_lock(AHardwareBuffer *, uint64_t, int32_t, const ARect *, void **); +int AHardwareBuffer_unlock(AHardwareBuffer *, int32_t *); +void AHardwareBuffer_release(AHardwareBuffer *); +''') + binary = work / 'test-presentation' + subprocess.run(['cc', '-std=c11', '-O1', '-g', '-Wall', '-Wextra', + '-Wno-unused-parameter', '-Wno-unused-function', + '-ffunction-sections', '-fdata-sections', '-Wl,--gc-sections', + '-fsanitize=address,undefined', f'-I{work}', + f'-I{ROOT}/app/src/main/cpp/waylandcomp/prebuilt/include', + str(ROOT / 'tools/quest/test_presentation.c'), '-ldl', '-pthread', + '-o', str(binary)], check=True, capture_output=True, text=True) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS: Quest backdrop', result.stdout) + + +if __name__ == '__main__': + unittest.main()