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()