From a62e96a059ff7d5cac3cdb01b7a934e41d0e29f0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 11:50:10 -0700 Subject: [PATCH 1/9] feat(linux): windowless weaver, SR weaver routing + display binding, SR refresh rate (multi-screen M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SR side of multi-screen M4, all behind the leia_sr_linux.h seam: - The Vulkan weaver is created with window = 0 on every path. The runtime supplies the phase origin per frame (set_present_origin -> srWeaverSetPresentOrigin, ADR-033); the Linux SDK never used the window for anything else, and an X11 id made it refuse a resampled panel, forcing 2D under a fractionally scaled XWayland desktop. - On SDK headers that carry them (LeiaSR Linux 876620d62, CMake DXR_LEIA_LNX_HAVE_SR_ROUTING, a type-only check), each weaver create chains SrWeaverRoutingInfo{EXTERNAL} and, for a known SR displayId, SrDisplayBindingInfo — gated at run time on SrWeaverRoutingCapabilities / SrDisplayBindingCapabilities (queried once per context). A refused create drops the binding, then the routing, one INFO line each: the end state is the pre-M4 weaver. The installed 1.38 runtime reports neither, so nothing is chained there. DXR_LEIA_SR_EXTERNAL_ROUTING=0 opts out. - An EXTERNAL weaver never votes the lens, so the lens owner now turns the lens on when one is created (unless the last request was 2D), sends every 3D request while one is alive, and releases the lens when the last one is destroyed — recorded as "nothing to re-apply", not as a 2D wish. - srDisplayGetRefreshRate (DXR_LEIA_LNX_HAVE_SR_REFRESH, compile + link probe) replaces the 60 Hz constant when present and plausible (#184). - The decision (leia_sr_routing_linux.h) is pure C and tested everywhere; the SR-typed pNext builder (leia_sr_chain_sdk_linux.h) is tested against SDK headers that carry the structs. New lens-owner cases cover EXTERNAL. The SR instance, tracker, lens and display handle stay process-wide and unbound: correct for one Leia panel (maxBoundDisplays = 1); two panels wait on LeiaSR phase D. Co-Authored-By: Claude Opus 5.5 --- src/drv_leia_linux/CMakeLists.txt | 57 ++++ src/drv_leia_linux/leia_lens_owner_linux.h | 69 ++++- src/drv_leia_linux/leia_sr_chain_sdk_linux.h | 109 ++++++++ src/drv_leia_linux/leia_sr_linux.h | 41 ++- src/drv_leia_linux/leia_sr_linux_sdk.c | 249 ++++++++++++++++-- src/drv_leia_linux/leia_sr_routing_linux.h | 117 ++++++++ src/drv_leia_linux/leia_sr_stub.c | 6 + .../tests/test_lens_owner_linux.c | 94 +++++++ .../tests/test_sr_routing_linux.c | 189 +++++++++++++ 9 files changed, 907 insertions(+), 24 deletions(-) create mode 100644 src/drv_leia_linux/leia_sr_chain_sdk_linux.h create mode 100644 src/drv_leia_linux/leia_sr_routing_linux.h create mode 100644 src/drv_leia_linux/tests/test_sr_routing_linux.c diff --git a/src/drv_leia_linux/CMakeLists.txt b/src/drv_leia_linux/CMakeLists.txt index af1607d..9572bc1 100644 --- a/src/drv_leia_linux/CMakeLists.txt +++ b/src/drv_leia_linux/CMakeLists.txt @@ -120,6 +120,49 @@ elseif(DXR_LEIA_LINUX_WEAVER STREQUAL "sdk") message(STATUS "DisplayXR-LeiaSR: SR display enumeration COMPILED OUT — no srEnumerateDisplays in " "${_srsdk_snap_inc} (or this loader archive); probe_displays uses EDID + lens serial") endif() + # Multi-screen M4 (SR-P1/P2 of the plan; LeiaSR Linux line 876620d62 + # phases A-C): weaver routing (SrWeaverRoutingInfo, EXTERNAL) and display + # binding (SrDisplayBindingInfo), both chained on create-infos and both + # gated at RUN time on the capability structs. Types only — no new call, + # so a compile check is the whole question (nothing to link). A 1.38 root + # lacks them: everything compiles out and every weaver is the plain one. + include(CheckCSourceCompiles) + set(CMAKE_REQUIRED_INCLUDES "${_srsdk_snap_inc}") + check_c_source_compiles(" +#include +#include +int main(void) +{ + SrWeaverRoutingInfo r = SrWeaverRoutingInfo(.mode = SR_WEAVER_ROUTING_EXTERNAL); + SrWeaverRoutingCapabilities rc = SrWeaverRoutingCapabilities(); + SrDisplayBindingInfo b = SrDisplayBindingInfo(.displayId = 1); + SrDisplayBindingCapabilities bc = SrDisplayBindingCapabilities(); + SrWeaverCreateInfoVulkan w = SrWeaverCreateInfoVulkan(.pNext = &r); + (void)rc; (void)b; (void)bc; (void)w; + return 0; +}" DXR_LEIA_LNX_HAVE_SR_ROUTING) + # leia-plugin #184: srDisplayGetRefreshRate (slot 98) — a call, so the + # snap's compile-AND-link probe (it must be in THIS loader archive). + set(CMAKE_REQUIRED_LIBRARIES srSDK::loader) + check_symbol_exists(srDisplayGetRefreshRate "sr/sr.h" DXR_LEIA_LNX_HAVE_SR_REFRESH) + unset(CMAKE_REQUIRED_INCLUDES) + unset(CMAKE_REQUIRED_LIBRARIES) + if(DXR_LEIA_LNX_HAVE_SR_ROUTING) + target_compile_definitions(leia_weaver_backend INTERFACE DXR_LEIA_LNX_HAVE_SR_ROUTING=1) + message(STATUS "DisplayXR-LeiaSR: SR weaver routing + display binding ENABLED (SrWeaverRoutingInfo / " + "SrDisplayBindingInfo declared) — used when the installed SR runtime reports them") + else() + message(STATUS "DisplayXR-LeiaSR: SR weaver routing + display binding COMPILED OUT — the SDK at " + "${_srsdk_snap_inc} predates SrWeaverRoutingInfo; weavers are SDK-routed and unbound") + endif() + if(DXR_LEIA_LNX_HAVE_SR_REFRESH) + target_compile_definitions(leia_weaver_backend INTERFACE DXR_LEIA_LNX_HAVE_SR_REFRESH=1) + message(STATUS "DisplayXR-LeiaSR: SR refresh rate ENABLED (srDisplayGetRefreshRate)") + else() + message(STATUS "DisplayXR-LeiaSR: SR refresh rate COMPILED OUT — no srDisplayGetRefreshRate in this SDK; " + "display info reports 60 Hz") + endif() + if(DXR_LEIA_LNX_HAVE_SR_SNAP) target_compile_definitions(leia_weaver_backend INTERFACE DXR_LEIA_LNX_HAVE_SR_SNAP=1) message(STATUS "DisplayXR-LeiaSR: drag phase-snap ENABLED " @@ -217,6 +260,7 @@ add_library(DisplayXR-LeiaSR SHARED leia_edid_probe_linux.h leia_display_claims_linux.c leia_display_claims_linux.h + leia_sr_routing_linux.h leia_bg_capture_linux.c leia_bg_capture_linux.h leia_bg_capture_worker_linux.c @@ -332,3 +376,16 @@ target_include_directories(test_display_claims_linux PRIVATE ) target_link_libraries(test_display_claims_linux PRIVATE xrt-interfaces aux_util PkgConfig::PLUGIN_XCB) add_test(NAME test_display_claims_linux COMMAND test_display_claims_linux) + +# --- Host unit test: SR weaver chain plan (multi-screen M4) --------------- +# leia_sr_routing_linux.h decides routing + binding from the SR caps; pure +# C, so it runs everywhere. Where the SDK headers carry the structs, the same +# test also builds the REAL pNext chains (leia_sr_chain_sdk_linux.h: SR types +# and macros only, no SR call, no loader) and walks them. +add_executable(test_sr_routing_linux tests/test_sr_routing_linux.c) +target_include_directories(test_sr_routing_linux PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +if(DXR_LEIA_LINUX_WEAVER STREQUAL "sdk" AND DXR_LEIA_LNX_HAVE_SR_ROUTING) + target_include_directories(test_sr_routing_linux PRIVATE ${_srsdk_snap_inc}) + target_compile_definitions(test_sr_routing_linux PRIVATE DXR_LEIA_LNX_HAVE_SR_ROUTING=1) +endif() +add_test(NAME test_sr_routing_linux COMMAND test_sr_routing_linux) diff --git a/src/drv_leia_linux/leia_lens_owner_linux.h b/src/drv_leia_linux/leia_lens_owner_linux.h index ff391f7..3e398cd 100644 --- a/src/drv_leia_linux/leia_lens_owner_linux.h +++ b/src/drv_leia_linux/leia_lens_owner_linux.h @@ -51,6 +51,7 @@ #pragma once #include +#include #ifdef __cplusplus extern "C" { @@ -81,6 +82,14 @@ struct leia_lens_owner //! marked this context's preference application-owned. Cleared by //! leia_lens_owner_on_new_context(). bool ctx_app_owned; + /*! + * Weavers alive with SR_WEAVER_ROUTING_EXTERNAL (multi-screen M4). Such a + * weaver NEVER votes the lens (sr_weaver.h: "the controller owns the lens + * through srLensEnable / srLensDisable"), so while one is alive "3D before + * any 2D is delegated, not sent" no longer holds: nobody would turn the + * lens on. See leia_lens_owner_on_external_weaver_created(). + */ + uint32_t external_weavers; }; /*! @@ -91,10 +100,12 @@ struct leia_lens_owner static inline enum leia_lens_action leia_lens_owner_on_request(const struct leia_lens_owner *o, bool want_3d) { - if (want_3d && o->last_sent == LEIA_LENS_REQ_NONE) { + if (want_3d && o->last_sent == LEIA_LENS_REQ_NONE && !o->ctx_app_owned && o->external_weavers == 0) { // Never turned the lens off: the weaver still owns it and brings it // up when it weaves. Sending srLensEnable here would take ownership - // for nothing and lose the weaver's off-panel release. + // for nothing and lose the weaver's off-panel release. (Not so with + // an EXTERNAL-routed weaver alive, which never votes, nor once this + // context is ours: then a 3D wish must be sent.) return LEIA_LENS_ACTION_NONE; } return want_3d ? LEIA_LENS_ACTION_ENABLE : LEIA_LENS_ACTION_DISABLE; @@ -136,6 +147,60 @@ leia_lens_owner_on_new_context(struct leia_lens_owner *o) } } +/*! + * An SR_WEAVER_ROUTING_EXTERNAL weaver was just created (multi-screen M4). + * It never votes the lens, so the lens is ours to turn on: returns ENABLE + * unless the last request sent was 2D (respect it — the runtime asks for 3D + * again at xrBeginSession or when the reason for 2D clears) or this context + * already has 3D from us. Commit the result with leia_lens_owner_commit() + * once the call succeeded. Always counts the weaver. + */ +static inline enum leia_lens_action +leia_lens_owner_on_external_weaver_created(struct leia_lens_owner *o) +{ + o->external_weavers++; + if (o->last_sent == LEIA_LENS_REQ_2D) { + return LEIA_LENS_ACTION_NONE; + } + if (o->ctx_app_owned && o->last_sent == LEIA_LENS_REQ_3D) { + return LEIA_LENS_ACTION_NONE; + } + return LEIA_LENS_ACTION_ENABLE; +} + +/*! + * An SR_WEAVER_ROUTING_EXTERNAL weaver was destroyed. When it was the last + * one and the lens is on because of us, returns DISABLE: SRService keeps the + * lens ON while ANY client holds an ENABLE preference, and the plug-in's SR + * context outlives the session (it is process-lifetime, and the service + * process is long-lived), so without this release the panel would stay + * lenticular over a 2D desktop. Commit a DISABLE from here with + * leia_lens_owner_commit_release(), NOT leia_lens_owner_commit(): this is not + * a 2D wish to re-apply, and the next EXTERNAL weaver must turn the lens back + * on. + */ +static inline enum leia_lens_action +leia_lens_owner_on_external_weaver_destroyed(struct leia_lens_owner *o) +{ + if (o->external_weavers > 0) { + o->external_weavers--; + } + if (o->external_weavers == 0 && o->ctx_app_owned && o->last_sent == LEIA_LENS_REQ_3D) { + return LEIA_LENS_ACTION_DISABLE; + } + return LEIA_LENS_ACTION_NONE; +} + +//! Record a successful release from leia_lens_owner_on_external_weaver_destroyed(): +//! the context stays ours (the SDK does not hand it back), but there is no wish +//! left to re-apply to a later context. +static inline void +leia_lens_owner_commit_release(struct leia_lens_owner *o) +{ + o->last_sent = LEIA_LENS_REQ_NONE; + o->ctx_app_owned = true; +} + #ifdef __cplusplus } #endif diff --git a/src/drv_leia_linux/leia_sr_chain_sdk_linux.h b/src/drv_leia_linux/leia_sr_chain_sdk_linux.h new file mode 100644 index 0000000..0184be1 --- /dev/null +++ b/src/drv_leia_linux/leia_sr_chain_sdk_linux.h @@ -0,0 +1,109 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief The SR-typed half of leia_sr_routing_linux.h: build the weaver + * create-info pNext chain from a plan, and the capability query + * chain (multi-screen M4). + * + * Header-only and call-free (SR types and the SDK's compound-literal macros, + * no SR function), so tests/test_sr_chain_linux.c can check the chains with + * nothing but the SDK headers — no loader, no SR runtime, no panel. Only + * usable when CMake found the structs (DXR_LEIA_LNX_HAVE_SR_ROUTING): the + * LeiaSR 1.38 headers predate them. + * + * @author David Fattal + * @ingroup drv_leia_linux + */ + +#pragma once + +#ifndef DXR_LEIA_LNX_HAVE_SR_ROUTING +#error "leia_sr_chain_sdk_linux.h needs SR SDK headers with SrWeaverRoutingInfo (DXR_LEIA_LNX_HAVE_SR_ROUTING)" +#endif + +#include "leia_sr_routing_linux.h" + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/*! Storage for the extension structs one weaver create chains. Must outlive + * the srCreateWeaver* call (it may live on the caller's stack). */ +struct leia_lnx_sr_weaver_chain +{ + SrWeaverRoutingInfo routing; + SrDisplayBindingInfo binding; +}; + +/*! + * Chain what @p plan asks for in front of @p tail (the create-info's existing + * pNext, usually NULL) and return the new head, to be stored in the + * create-info's pNext. Order: routing -> binding -> tail. Neither struct is + * position-sensitive (SR walks the chain by sType), but a fixed order keeps + * the logs and the test deterministic. + */ +static inline const void * +leia_lnx_sr_build_weaver_chain(const struct leia_lnx_sr_weaver_plan *plan, + struct leia_lnx_sr_weaver_chain *storage, + const void *tail) +{ + const void *head = tail; + if (plan->bind_display) { + storage->binding = SrDisplayBindingInfo(.displayId = plan->display_id); + storage->binding.pNext = head; + head = &storage->binding; + } + if (plan->routing_external) { + storage->routing = SrWeaverRoutingInfo(.mode = SR_WEAVER_ROUTING_EXTERNAL); + storage->routing.flags = 0; // no KEEP_DRAG_SNAP: windowless on Linux + storage->routing.pNext = head; + head = &storage->routing; + } + return head; +} + +/*! Storage for the capability query: SrRuntimeCapabilities -> routing caps -> binding caps. */ +struct leia_lnx_sr_caps_chain +{ + SrRuntimeCapabilities caps; + SrWeaverRoutingCapabilities routing; + SrDisplayBindingCapabilities binding; +}; + +/*! + * Initialise @p c for srGetRuntimeCapabilities(instance, &c->caps): every + * extension struct at "not supported", since a runtime that predates one + * leaves it untouched. + */ +static inline void +leia_lnx_sr_caps_chain_init(struct leia_lnx_sr_caps_chain *c) +{ + c->binding = SrDisplayBindingCapabilities(); + c->routing = SrWeaverRoutingCapabilities(); + c->caps = SrRuntimeCapabilities(); + c->routing.pNext = &c->binding; + c->caps.pNext = &c->routing; +} + +//! Reduce a filled query (@p res = the srGetRuntimeCapabilities result) to the plain-C caps. +static inline struct leia_lnx_sr_multi_caps +leia_lnx_sr_caps_chain_reduce(SrResult res, const struct leia_lnx_sr_caps_chain *c) +{ + struct leia_lnx_sr_multi_caps out = {0}; + if (SR_FAILED(res)) { + return out; + } + out.known = true; + out.external_routing = c->routing.externalRouting != SR_FALSE; + out.display_binding = c->binding.displayBinding != SR_FALSE; + out.max_bound_displays = c->binding.maxBoundDisplays; + return out; +} + +#ifdef __cplusplus +} +#endif diff --git a/src/drv_leia_linux/leia_sr_linux.h b/src/drv_leia_linux/leia_sr_linux.h index 999788d..b3129c3 100644 --- a/src/drv_leia_linux/leia_sr_linux.h +++ b/src/drv_leia_linux/leia_sr_linux.h @@ -90,8 +90,15 @@ struct leiasr_lnx_create_info * `Display*` / `xcb_connection_t*` (backend's preference). * * srSDK 1.0.0: `SrWeaverCreateInfoVulkan.window` takes the XID (0 = - * windowless, honored). `x11_connection` has NO srSDK counterpart (the - * SDK opens its own X connection) — the sdk backend ignores it. + * windowless, honored) — but the sdk backend now ALWAYS passes 0 + * (multi-screen M4, ADR-033): the runtime supplies the phase origin per + * frame (set_present_origin -> srWeaverSetPresentOrigin), the Linux SDK + * never tracked the window's position anyway (getScreenRect is (0,0)), + * and with an X11 id the SDK's resampled-panel refusal forces 2D under a + * fractionally scaled XWayland desktop. `x11_window` is kept on the seam + * for the stub and for diagnostics only. `x11_connection` has NO srSDK + * counterpart (the SDK opens its own X connection) — the sdk backend + * ignores it. * `graphics_queue_family` likewise has no counterpart (the command pool * implies the family); kept because the stub and future backends want it. */ @@ -111,6 +118,15 @@ struct leiasr_lnx_create_info * baseline VK_FORMAT_B8G8R8A8_UNORM. The stub (blit path) ignores it. */ VkFormat target_format; + + /*! + * The SR display this weaver is for (multi-screen M4): the opaque + * `displayId` srEnumerateDisplays reported, chained as + * SrDisplayBindingInfo when the installed SR runtime honours binding. + * 0 = none (SR 1.38, a monitor SR does not list, the plain factory): + * the weaver follows the active SR display, as before. Stub: ignored. + */ + uint64_t sr_display_id; }; /*! @@ -167,6 +183,14 @@ struct leiasr_lnx_weave_output uint32_t width; uint32_t height; VkFormat format; + /*! + * Multi-screen M4: begin the backend's render pass with renderArea = + * the weave viewport instead of the whole target. Set for a SEGMENT DP + * (created for one screen) with a sub-rect canvas, which must not touch + * the pixels a sibling DP wove (runtime create_dp_vk_for_screen + * contract). The draw is already viewport+scissor-confined either way. + */ + bool confine_to_viewport; }; /*! @@ -216,7 +240,7 @@ struct leiasr_lnx_display_info int32_t screen_top; uint32_t recommended_view_width; //!< recommended per-view render size uint32_t recommended_view_height; - uint32_t refresh_mhz; //!< refresh rate in milli-Hz (60000 = 60 Hz); srSDK 1.0.0 has NO getter — sdk backend reports 60000 (carried ask) + uint32_t refresh_mhz; //!< milli-Hz (60000 = 60 Hz): srDisplayGetRefreshRate when available (#184), else 60000 float nominal_viewer_x_m; //!< recommended viewing position, display-center meters float nominal_viewer_y_m; float nominal_viewer_z_m; @@ -538,6 +562,17 @@ leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap); int32_t leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap); +/*! + * The displayId of the SR display the process-wide context drives (the one + * the instance, tracker, lens and the display query describe): the first + * FPC-verified entry of the cached srEnumerateDisplays answer. Never + * enumerates and never creates a context. 0 = unknown (API compiled out, no + * enumeration yet, nothing FPC-verified) — callers then fall back to the + * one-panel rule (leia_screen_linux.h). + */ +uint64_t +leia_lnx_sr_active_display_id(void); + #ifdef __cplusplus } #endif diff --git a/src/drv_leia_linux/leia_sr_linux_sdk.c b/src/drv_leia_linux/leia_sr_linux_sdk.c index f20951c..3af53de 100644 --- a/src/drv_leia_linux/leia_sr_linux_sdk.c +++ b/src/drv_leia_linux/leia_sr_linux_sdk.c @@ -78,6 +78,10 @@ #include "leia_edid_probe_linux.h" #include "leia_lens_owner_linux.h" #include "leia_display_claims_linux.h" +#include "leia_sr_routing_linux.h" +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING +#include "leia_sr_chain_sdk_linux.h" +#endif #include "leia_interface.h" @@ -102,6 +106,11 @@ DEBUG_GET_ONCE_BOOL_OPTION(sr_fb_sdk, "DXR_LEIA_SR_FB_SDK", false) * v14 reuse) — the escape hatch should the SDK-owned pass misbehave. */ DEBUG_GET_ONCE_BOOL_OPTION(sr_compose, "DXR_LEIA_SR_COMPOSE", true) DEBUG_GET_ONCE_BOOL_OPTION(sr_compose_inline, "DXR_LEIA_SR_COMPOSE_INLINE", false) +/* Multi-screen M4: chain SrWeaverRoutingInfo{EXTERNAL} on every weaver when the + * SR runtime honours it. DXR_LEIA_SR_EXTERNAL_ROUTING=0 keeps the SDK-routed + * weaver — an on-panel A/B, not a setting (SR's own SR_WEAVER_ROUTING env + * overrides whatever we chain, in either direction). */ +DEBUG_GET_ONCE_BOOL_OPTION(sr_external_routing, "DXR_LEIA_SR_EXTERNAL_ROUTING", true) #define SDK_HALF_IPD_MM 31.5f /* nominal 63 mm IPD — R-T2 fallback pair */ @@ -539,6 +548,56 @@ sr_ctx_shutdown(void) pthread_mutex_unlock(&g_ctx_lock); } +/* + * Multi-screen capabilities of the INSTALLED SR runtime (weaver routing + + * display binding), queried once per context and logged once. Fixed for a + * runtime build (sr_instance.h), so the generation key only matters across an + * SRService restart onto a different runtime. A runtime that predates the + * structs leaves them at "not supported" (the installed 1.38: everything + * false), and an SDK whose headers predate them compiles this to "unknown". + * Caller holds g_ctx_lock and has a READY context. + */ +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING +static struct leia_lnx_sr_multi_caps g_multi_caps; +static uint32_t g_multi_caps_generation = 0; // 0 = never queried + +static struct leia_lnx_sr_multi_caps +sdk_multi_caps_locked(void) +{ + const uint32_t gen = atomic_load(&g_ctx.generation); + if (g_multi_caps_generation != gen) { + g_multi_caps_generation = gen; + struct leia_lnx_sr_caps_chain chain; + leia_lnx_sr_caps_chain_init(&chain); + const SrResult res = srGetRuntimeCapabilities(g_ctx.instance, &chain.caps); + g_multi_caps = leia_lnx_sr_caps_chain_reduce(res, &chain); + if (SR_FAILED(res)) { + U_LOG_W( + "leia_sr_sdk: srGetRuntimeCapabilities failed: %s — no weaver routing / display binding", + srResultToString(res)); + } else { + U_LOG_W( + "leia_sr_sdk: SR multi-display caps: externalRouting=%u displayBinding=%u " + "maxBoundDisplays=%u%s", + (unsigned)g_multi_caps.external_routing, (unsigned)g_multi_caps.display_binding, + (unsigned)g_multi_caps.max_bound_displays, + (!g_multi_caps.external_routing && !g_multi_caps.display_binding) + ? " (runtime predates both: plain SDK-routed, unbound weavers)" + : ""); + } + } + return g_multi_caps; +} +#else +static struct leia_lnx_sr_multi_caps +sdk_multi_caps_locked(void) +{ + // SDK headers predate SrWeaverRoutingInfo / SrDisplayBindingInfo + // (LeiaSR 1.38): nothing to chain, whatever the runtime could do. + return (struct leia_lnx_sr_multi_caps){0}; +} +#endif + /*! Refresh the cached display query from the SDK. Caller holds g_ctx_lock. */ static bool sr_ctx_refresh_display_info_locked(void) @@ -582,8 +641,31 @@ sr_ctx_refresh_display_info_locked(void) * consumes and logs as "per eye" (leia_sr_d3d11.cpp) — settled. */ info.recommended_view_width = (uint32_t)rec_w; info.recommended_view_height = (uint32_t)rec_h; - /* No refresh getter in srSDK 1.0.0 (contract §8 R-D1 carried ask). */ + /* Refresh (leia-plugin #184): srDisplayGetRefreshRate (slot 98) when the + * SDK has it and the installed runtime answers; else the 60 Hz constant + * (SR 1.38 has no getter — contract §8 R-D1). */ info.refresh_mhz = 60000; + const char *refresh_src = "HARDCODED 60 Hz (SDK has no srDisplayGetRefreshRate)"; +#ifdef DXR_LEIA_LNX_HAVE_SR_REFRESH + { + float hz = 0.0f; + const SrResult rr = srDisplayGetRefreshRate(g_ctx.display, &hz); + if (SR_SUCCEEDED(rr) && hz >= 1.0f && hz <= 1000.0f) { + info.refresh_mhz = (uint32_t)(hz * 1000.0f + 0.5f); + refresh_src = "from srDisplayGetRefreshRate"; + } else { + // SR_ERROR_FUNCTION_UNSUPPORTED = a runtime older than the header. + refresh_src = SR_FAILED(rr) ? "60 Hz fallback (srDisplayGetRefreshRate failed)" + : "60 Hz fallback (srDisplayGetRefreshRate out of range)"; + static bool rr_logged; + if (!rr_logged) { + U_LOG_I("leia_sr_sdk: srDisplayGetRefreshRate: %s (%.2f Hz) — keeping 60 Hz", + SR_FAILED(rr) ? srResultToString(rr) : "implausible", hz); + rr_logged = true; + } + } + } +#endif info.nominal_viewer_x_m = nx / 1000.0f; info.nominal_viewer_y_m = ny / 1000.0f; info.nominal_viewer_z_m = nz / 1000.0f; @@ -630,9 +712,11 @@ sr_ctx_refresh_display_info_locked(void) static bool logged; if (!logged) { - U_LOG_I("leia_sr_sdk: display %dx%d px, %.1fx%.1f cm, at (%d,%d), recommended %dx%d " - "per view, nominal viewer Z %.0f mm, refresh HARDCODED 60 Hz", - px_w, px_h, w_cm, h_cm, (int)rect.left, (int)rect.top, rec_w, rec_h, nz); + U_LOG_I( + "leia_sr_sdk: display %dx%d px, %.1fx%.1f cm, at (%d,%d), recommended %dx%d " + "per view, nominal viewer Z %.0f mm, refresh %.3f Hz %s", + px_w, px_h, w_cm, h_cm, (int)rect.left, (int)rect.top, rec_w, rec_h, nz, info.refresh_mhz / 1000.0, + refresh_src); logged = true; } @@ -711,6 +795,12 @@ struct leiasr_lnx struct leiasr_lnx_create_info info; SrWeaver weaver; + /* Multi-screen M4: what the weaver was actually created with (after any + * fallback). An EXTERNAL-routed weaver never votes the lens, so its + * creation and destruction drive the lens owner (leia_lens_owner_linux.h). */ + bool external_routed; + bool display_bound; + VkRenderPass render_pass; //!< backend-owned, single color attachment (loadOp LOAD) VkFormat render_pass_format; @@ -1478,18 +1568,88 @@ leiasr_lnx_create(const struct leiasr_lnx_create_info *info, struct leiasr_lnx * "(the SDK opens its own X connection)"); } - /* srSDK takes no queue-family index — the command pool implies it. */ - SrWeaverCreateInfoVulkan wci = SrWeaverCreateInfoVulkan( - .device = (SrVkDevice)info->device, .physicalDevice = (SrVkPhysicalDevice)info->physical_device, - .graphicsQueue = (SrVkQueue)info->graphics_queue, .commandPool = (SrVkCommandPool)info->command_pool, - .window = (SrNativeWindowHandle)(uintptr_t)info->x11_window); - SrResult sres = srCreateWeaverVulkan(g_ctx.instance, &wci, &lnx->weaver); + /* Multi-screen M4: what to chain — weaver routing (EXTERNAL) and display + * binding — decided against what the INSTALLED SR runtime honours. */ + pthread_mutex_lock(&g_ctx_lock); + const struct leia_lnx_sr_multi_caps mcaps = sdk_multi_caps_locked(); + pthread_mutex_unlock(&g_ctx_lock); + struct leia_lnx_sr_weaver_plan plan = + leia_lnx_sr_plan_weaver(&mcaps, debug_get_bool_option_sr_external_routing(), info->sr_display_id); + + SrResult sres = SR_SUCCESS; + for (;;) { + /* srSDK takes no queue-family index — the command pool implies it. + * + * window = 0, ALWAYS (multi-screen M4, ADR-033). The runtime supplies + * the phase origin every frame (set_present_origin -> + * srWeaverSetPresentOrigin), which is all the Linux SDK ever used a + * window for (its getScreenRect is (0,0)); and an X11 id makes the + * SDK refuse a resampled panel, which forces 2D under a fractionally + * scaled XWayland desktop. A weaver CONSTRUCTED windowless always + * weaves (WeaverBaseImpl.ipp, runbook §3). info->x11_window is + * deliberately not forwarded. */ + SrWeaverCreateInfoVulkan wci = + SrWeaverCreateInfoVulkan(.device = (SrVkDevice)info->device, + .physicalDevice = (SrVkPhysicalDevice)info->physical_device, + .graphicsQueue = (SrVkQueue)info->graphics_queue, + .commandPool = (SrVkCommandPool)info->command_pool, .window = 0); +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING + struct leia_lnx_sr_weaver_chain chain; + wci.pNext = leia_lnx_sr_build_weaver_chain(&plan, &chain, wci.pNext); +#endif + sres = srCreateWeaverVulkan(g_ctx.instance, &wci, &lnx->weaver); + if (SR_SUCCEEDED(sres)) { + break; + } + struct leia_lnx_sr_weaver_plan next; + if (!leia_lnx_sr_plan_weaver_fallback(&plan, &next)) { + break; + } + /* One INFO per fallback step: a refused binding (EDID-only display = + * SR_ERROR_DEVICE_NOT_AVAILABLE, unknown id = SR_ERROR_DISPLAY_NOT_FOUND) + * falls back to the active display, then a refused routing to the + * SDK-routed weaver — both are exactly the pre-M4 weaver. */ + U_LOG_I("leia_sr_sdk: srCreateWeaverVulkan with %s%s refused (%s) — retrying without %s", + plan.routing_external ? "EXTERNAL routing" : "", + plan.bind_display ? (plan.routing_external ? " + display binding" : "display binding") : "", + srResultToString(sres), plan.bind_display ? "the display binding" : "EXTERNAL routing"); + plan = next; + } if (SR_FAILED(sres)) { U_LOG_W("leia_sr_sdk: srCreateWeaverVulkan failed: %s", srResultToString(sres)); free(lnx); sr_ctx_weaver_unpin(); return LEIASR_LNX_ERROR_FAILED; } + lnx->external_routed = plan.routing_external; + lnx->display_bound = plan.bind_display; + if (info->x11_window != NULL) { + static bool window_logged; + if (!window_logged) { + U_LOG_I( + "leia_sr_sdk: X11 window 0x%lx not forwarded — the weaver is windowless (phase from " + "set_present_origin)", + (unsigned long)(uintptr_t)info->x11_window); + window_logged = true; + } + } + if (lnx->external_routed) { + /* An EXTERNAL weaver never votes the lens: it is ours to turn on + * (and to release when the last one goes — leiasr_lnx_destroy). */ + pthread_mutex_lock(&g_ctx_lock); + const enum leia_lens_action la = leia_lens_owner_on_external_weaver_created(&g_lens_owner); + if (la != LEIA_LENS_ACTION_NONE) { + if (g_ctx.lens != NULL) { + (void)sr_ctx_send_lens_locked( + la, "EXTERNAL-routed weaver created — it never votes the lens"); + } else { + U_LOG_W( + "leia_sr_sdk: EXTERNAL-routed weaver but no lens handle — the lens cannot be " + "turned on (panel stays 2D)"); + } + } + pthread_mutex_unlock(&g_ctx_lock); + } VkFormat rp_format = info->target_format != VK_FORMAT_UNDEFINED ? info->target_format : VK_FORMAT_B8G8R8A8_UNORM; @@ -1512,13 +1672,14 @@ leiasr_lnx_create(const struct leiasr_lnx_create_info *info, struct leiasr_lnx * lnx->hw_encodes = sdk_format_is_srgb(rp_format); sdk_apply_srgb_conversion(lnx); - U_LOG_I("leia_sr_sdk: Vulkan weaver created (window=0x%lx%s, target format %d%s, " - "atlas encoding awaiting the runtime's declaration (ADR-021 default ENCODED), " - "weave sRGB read=%u write=%u)", - (unsigned long)(uintptr_t)info->x11_window, - info->x11_window == NULL ? " = windowless/display-scoped" : "", rp_format, - lnx->hw_encodes ? " = *_SRGB, HW encodes on store" : " = *_UNORM, stores verbatim", - (unsigned)lnx->srgb_read, (unsigned)lnx->srgb_write); + U_LOG_I( + "leia_sr_sdk: Vulkan weaver created (window=0x0 = windowless, routing %s, display %s0x%016llx, " + "target format %d%s, atlas encoding awaiting the runtime's declaration (ADR-021 default ENCODED), " + "weave sRGB read=%u write=%u)", + lnx->external_routed ? "EXTERNAL" : "SDK", lnx->display_bound ? "bound to " : "active (unbound), wanted ", + (unsigned long long)info->sr_display_id, rp_format, + lnx->hw_encodes ? " = *_SRGB, HW encodes on store" : " = *_UNORM, stores verbatim", + (unsigned)lnx->srgb_read, (unsigned)lnx->srgb_write); *out_lnx = lnx; return LEIASR_LNX_SUCCESS; } @@ -1534,6 +1695,23 @@ leiasr_lnx_destroy(struct leiasr_lnx *lnx) if (lnx->weaver != NULL) { srDestroyWeaver(lnx->weaver); } + if (lnx->external_routed) { + /* Last EXTERNAL weaver gone with the lens on because of us: release it. + * SRService keeps the lens ON while ANY client holds an ENABLE, and + * this SR context outlives the session (runbook §4, "Co-existing"). */ + pthread_mutex_lock(&g_ctx_lock); + if (leia_lens_owner_on_external_weaver_destroyed(&g_lens_owner) == LEIA_LENS_ACTION_DISABLE && + g_ctx.lens != NULL) { + const SrResult lr = srLensDisable(g_ctx.lens); + if (SR_SUCCEEDED(lr)) { + leia_lens_owner_commit_release(&g_lens_owner); + U_LOG_W("leia_sr_sdk: lens OFF — the last EXTERNAL-routed weaver is gone"); + } else { + LOG_SR_ONCE("srLensDisable (EXTERNAL weaver release)", lr); + } + } + pthread_mutex_unlock(&g_ctx_lock); + } sdk_fb_cache_flush(lnx); sdk_flip_cache_flush(lnx); if (lnx->render_pass != VK_NULL_HANDLE) { @@ -1676,11 +1854,18 @@ leiasr_lnx_weave(struct leiasr_lnx *lnx, U_LOG_W("leia_sr_sdk: no framebuffer for weave target — skipping frame"); return; } + VkRect2D area = {.offset = {0, 0}, .extent = {output->width, output->height}}; + if (output->confine_to_viewport) { + /* Segment DP (multi-screen M4): touch only our canvas — a + * sibling DP may already have woven the rest of the target. + * The DP clamped the viewport to the target. */ + area = viewport; + } VkRenderPassBeginInfo begin = { .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = lnx->render_pass, .framebuffer = fb, - .renderArea = {.offset = {0, 0}, .extent = {output->width, output->height}}, + .renderArea = area, }; vkCmdBeginRenderPass(cmd_buffer, &begin, VK_SUBPASS_CONTENTS_INLINE); @@ -2178,7 +2363,9 @@ sr_log_display_caps_locked(uint32_t gen) return; } g_sr_caps_logged_generation = gen; - +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING + (void)sdk_multi_caps_locked(); // queries + logs once per context (shared with weaver creation) +#else SrDisplayBindingCapabilities bind_caps = SrDisplayBindingCapabilities(); SrWeaverRoutingCapabilities route_caps = SrWeaverRoutingCapabilities(); SrRuntimeCapabilities caps = SrRuntimeCapabilities(); @@ -2192,6 +2379,7 @@ sr_log_display_caps_locked(uint32_t gen) U_LOG_W("leia_sr_sdk: SR multi-display caps: externalRouting=%u displayBinding=%u maxBoundDisplays=%u", (unsigned)route_caps.externalRouting, (unsigned)bind_caps.displayBinding, (unsigned)bind_caps.maxBoundDisplays); +#endif } static void @@ -2288,8 +2476,31 @@ leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap) return ret; } +uint64_t +leia_lnx_sr_active_display_id(void) +{ + uint64_t id = 0; + pthread_mutex_lock(&g_ctx_lock); + if (g_ctx.state == SR_CTX_READY && g_sr_displays_generation == atomic_load(&g_ctx.generation)) { + for (int32_t i = 0; i < g_sr_display_count; i++) { + if (g_sr_displays[i].fpc_verified) { + id = g_sr_displays[i].display_id; + break; + } + } + } + pthread_mutex_unlock(&g_ctx_lock); + return id; +} + #else // !DXR_LEIA_LNX_HAVE_SR_DISPLAY_ENUM +uint64_t +leia_lnx_sr_active_display_id(void) +{ + return 0; // no srEnumerateDisplays in these SDK headers +} + int32_t leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap) { diff --git a/src/drv_leia_linux/leia_sr_routing_linux.h b/src/drv_leia_linux/leia_sr_routing_linux.h new file mode 100644 index 0000000..b761f18 --- /dev/null +++ b/src/drv_leia_linux/leia_sr_routing_linux.h @@ -0,0 +1,117 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief What to chain on an SR weaver's create-info (multi-screen M4): + * weaver routing (SrWeaverRoutingInfo) and display binding + * (SrDisplayBindingInfo) — the decision, kept free of SR types. + * + * The new SR API (LeiaSR Linux line 876620d62, phases A-C) lets a display + * controller (a) turn off the weaver's own window/monitor detection — + * SR_WEAVER_ROUTING_EXTERNAL: weave whatever it is given, phase = present + * origin + viewport only, no lens vote, no polling — and (b) bind a weaver to + * one SR display by the `displayId` srEnumerateDisplays reported. Both are + * gated on what the INSTALLED SR runtime says it honours + * (SrWeaverRoutingCapabilities / SrDisplayBindingCapabilities chained on + * SrRuntimeCapabilities); a runtime that predates them (the 1.38 .deb) leaves + * the caps at "not supported" and gets the plain create-info, exactly as + * before. + * + * Pure, header-only: tests/test_sr_routing_linux.c checks the plan on any box; + * leia_sr_linux_sdk.c turns it into the actual pNext chain + * (leia_sr_chain_sdk_linux.h, tested where the SDK headers exist). + * + * @author David Fattal + * @ingroup drv_leia_linux + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/*! What the installed SR runtime honours (SR types reduced to plain C). */ +struct leia_lnx_sr_multi_caps +{ + //! The capability query ran and succeeded. False = unknown: the SDK this + //! plug-in was built against predates the structs, or the query failed. + bool known; + //! SrWeaverRoutingCapabilities::externalRouting. + bool external_routing; + //! SrDisplayBindingCapabilities::displayBinding. + bool display_binding; + //! SrDisplayBindingCapabilities::maxBoundDisplays (1 today). + uint32_t max_bound_displays; +}; + +/*! The chain one weaver create gets. */ +struct leia_lnx_sr_weaver_plan +{ + //! Chain SrWeaverRoutingInfo{mode = SR_WEAVER_ROUTING_EXTERNAL, flags = 0}. + //! No KEEP_DRAG_SNAP on Linux: the bit only matters with a real window, + //! and the Linux weaver is windowless (drag phase-snap is the runtime's + //! snap_window_rect slot -> srWeaverSnapToPhase, independent of routing). + bool routing_external; + //! Chain SrDisplayBindingInfo{displayId = @ref display_id}. + bool bind_display; + uint64_t display_id; +}; + +/*! + * Decide the weaver chain. + * + * @param caps What the SR runtime honours (NULL = unknown). + * @param want_external The plug-in's wish (DXR_LEIA_SR_EXTERNAL_ROUTING, default on). + * @param display_id The SR displayId this weaver is for (0 = none known: + * SR 1.38, a monitor SR does not list, or a DP created + * through the plain factory). + */ +static inline struct leia_lnx_sr_weaver_plan +leia_lnx_sr_plan_weaver(const struct leia_lnx_sr_multi_caps *caps, bool want_external, uint64_t display_id) +{ + struct leia_lnx_sr_weaver_plan p = {0}; + if (caps == NULL || !caps->known) { + return p; + } + p.routing_external = want_external && caps->external_routing; + // Binding needs the runtime to honour it AND to bind at least one display + // (maxBoundDisplays is 1 today: only the FPC-verified display binds). + if (display_id != 0 && caps->display_binding && caps->max_bound_displays >= 1) { + p.bind_display = true; + p.display_id = display_id; + } + return p; +} + +/*! + * The plan for the retry after a create with @p tried failed: drop the + * display binding first (SR refuses an EDID-only id with + * SR_ERROR_DEVICE_NOT_AVAILABLE and an unknown one with + * SR_ERROR_DISPLAY_NOT_FOUND — the active display is then the right + * fallback), then the routing. @return false when there is nothing left to + * drop (the plain create already failed: give up as before). + */ +static inline bool +leia_lnx_sr_plan_weaver_fallback(const struct leia_lnx_sr_weaver_plan *tried, struct leia_lnx_sr_weaver_plan *out) +{ + *out = *tried; + if (out->bind_display) { + out->bind_display = false; + out->display_id = 0; + return true; + } + if (out->routing_external) { + out->routing_external = false; + return true; + } + return false; +} + +#ifdef __cplusplus +} +#endif diff --git a/src/drv_leia_linux/leia_sr_stub.c b/src/drv_leia_linux/leia_sr_stub.c index c9aac99..6d4d71c 100644 --- a/src/drv_leia_linux/leia_sr_stub.c +++ b/src/drv_leia_linux/leia_sr_stub.c @@ -467,3 +467,9 @@ leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap) (void)cap; return -1; // claims stay at EDID confidence } + +uint64_t +leia_lnx_sr_active_display_id(void) +{ + return 0; // the stub has no SR display list +} diff --git a/src/drv_leia_linux/tests/test_lens_owner_linux.c b/src/drv_leia_linux/tests/test_lens_owner_linux.c index 6da30b5..4fc87db 100644 --- a/src/drv_leia_linux/tests/test_lens_owner_linux.c +++ b/src/drv_leia_linux/tests/test_lens_owner_linux.c @@ -175,6 +175,97 @@ test_uncommitted_call_is_not_recorded(void) CHECK(leia_lens_owner_on_new_context(&o) == LEIA_LENS_ACTION_NONE); } +/* Multi-screen M4: an EXTERNAL-routed weaver never votes, so the plug-in turns + * the lens on at its creation and off after the last one is gone. */ +static void +external_created(struct fake_ctx *c, struct leia_lens_owner *o) +{ + fake_send(c, o, leia_lens_owner_on_external_weaver_created(o)); +} + +static void +external_destroyed(struct fake_ctx *c, struct leia_lens_owner *o) +{ + const enum leia_lens_action a = leia_lens_owner_on_external_weaver_destroyed(o); + if (a == LEIA_LENS_ACTION_NONE) { + return; + } + CHECK(a == LEIA_LENS_ACTION_DISABLE); + c->app_owns = true; + c->disables++; + c->pref_on = false; + c->pref_set = true; + leia_lens_owner_commit_release(o); +} + +static void +test_external_weaver_turns_the_lens_on_and_releases_it(void) +{ + struct leia_lens_owner o = {0}; + struct fake_ctx c; + new_context(&c, &o); + + external_created(&c, &o); + CHECK(c.enables == 1 && c.pref_on && c.app_owns); + CHECK(o.external_weavers == 1); + + // The session-begin 3D request: the context is ours now, so it is sent + // (every request is, once owned — redundant, harmless). + request(&c, &o, true); + CHECK(c.enables == 2 && c.pref_on); + + // A second EXTERNAL weaver (a segment DP next to the window's own DP). + external_created(&c, &o); + CHECK(c.enables == 2 && o.external_weavers == 2); // already on from us: no call + external_destroyed(&c, &o); + CHECK(c.disables == 0 && c.pref_on); // one still weaving + + external_destroyed(&c, &o); + CHECK(c.disables == 1 && !c.pref_on); + CHECK(o.external_weavers == 0); + // A release is not a 2D wish: nothing to re-apply to a later context ... + CHECK(o.last_sent == LEIA_LENS_REQ_NONE); + // ... and the next session's EXTERNAL weaver turns the lens back on. + external_created(&c, &o); + CHECK(c.enables == 3 && c.pref_on); + external_destroyed(&c, &o); + CHECK(c.disables == 2); +} + +/* With an EXTERNAL weaver alive a 3D wish is sent even before any 2D. */ +static void +test_external_weaver_3d_request_is_sent(void) +{ + struct leia_lens_owner o = {0}; + o.external_weavers = 1; // created while the enable failed (not committed) + CHECK(leia_lens_owner_on_request(&o, true) == LEIA_LENS_ACTION_ENABLE); +} + +/* An app 2D wish survives a new EXTERNAL weaver; the 3D request restores it. */ +static void +test_external_weaver_respects_2d(void) +{ + struct leia_lens_owner o = {0}; + struct fake_ctx c; + new_context(&c, &o); + external_created(&c, &o); + request(&c, &o, false); // V toggle + CHECK(c.disables == 1 && !c.pref_on); + + external_created(&c, &o); // a segment DP comes up mid-2D + CHECK(c.enables == 1 && !c.pref_on); + + request(&c, &o, true); + CHECK(c.enables == 2 && c.pref_on); + + // While the app is in 2D at teardown, the release has nothing to do. + request(&c, &o, false); + external_destroyed(&c, &o); + external_destroyed(&c, &o); + CHECK(c.disables == 2); + CHECK(o.last_sent == LEIA_LENS_REQ_2D); +} + int main(void) { @@ -183,6 +274,9 @@ main(void) test_new_context_reapplies_last_request(); test_new_context_without_history_stays_weaver_owned(); test_uncommitted_call_is_not_recorded(); + test_external_weaver_turns_the_lens_on_and_releases_it(); + test_external_weaver_3d_request_is_sent(); + test_external_weaver_respects_2d(); if (g_failures != 0) { fprintf(stderr, "test_lens_owner_linux: %d failure(s)\n", g_failures); return EXIT_FAILURE; diff --git a/src/drv_leia_linux/tests/test_sr_routing_linux.c b/src/drv_leia_linux/tests/test_sr_routing_linux.c new file mode 100644 index 0000000..6f9645b --- /dev/null +++ b/src/drv_leia_linux/tests/test_sr_routing_linux.c @@ -0,0 +1,189 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Unit test for the multi-screen M4 SR weaver chain: the plan + * (leia_sr_routing_linux.h, everywhere) and — when the SDK headers + * carry the structs — the real pNext chains built from it + * (leia_sr_chain_sdk_linux.h). + * + * Hardware-free and call-free: no SR loader, no SR runtime, no panel. The + * chain half uses only SR types and the SDK's compound-literal macros. + */ + +#include "leia_sr_routing_linux.h" +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING +#include "leia_sr_chain_sdk_linux.h" +#endif + +#include +#include + +static int g_failures; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ + g_failures++; \ + } \ + } while (0) + +#define DS1_ID 0x5352000000000001ull + +//! LeiaSR 876620d62 (phases A-C): both honoured, one bindable display. +static const struct leia_lnx_sr_multi_caps k_caps_new = { + .known = true, .external_routing = true, .display_binding = true, .max_bound_displays = 1}; +//! LeiaSR 1.38 runtime under new headers: the query succeeds, structs untouched. +static const struct leia_lnx_sr_multi_caps k_caps_138 = {.known = true}; + +static void +test_plan_new_runtime(void) +{ + struct leia_lnx_sr_weaver_plan p = leia_lnx_sr_plan_weaver(&k_caps_new, true, DS1_ID); + CHECK(p.routing_external); + CHECK(p.bind_display && p.display_id == DS1_ID); + + // No display id known (plain factory / SR does not list the monitor). + p = leia_lnx_sr_plan_weaver(&k_caps_new, true, 0); + CHECK(p.routing_external && !p.bind_display && p.display_id == 0); + + // DXR_LEIA_SR_EXTERNAL_ROUTING=0: SDK routing, binding unaffected. + p = leia_lnx_sr_plan_weaver(&k_caps_new, false, DS1_ID); + CHECK(!p.routing_external && p.bind_display); +} + +static void +test_plan_old_or_unknown_runtime(void) +{ + struct leia_lnx_sr_weaver_plan p = leia_lnx_sr_plan_weaver(&k_caps_138, true, DS1_ID); + CHECK(!p.routing_external && !p.bind_display); + + p = leia_lnx_sr_plan_weaver(NULL, true, DS1_ID); + CHECK(!p.routing_external && !p.bind_display); + + const struct leia_lnx_sr_multi_caps unknown = { + .known = false, .external_routing = true, .display_binding = true, .max_bound_displays = 1}; + p = leia_lnx_sr_plan_weaver(&unknown, true, DS1_ID); + CHECK(!p.routing_external && !p.bind_display); + + // Binding honoured but zero bindable displays: no binding. + const struct leia_lnx_sr_multi_caps none_bindable = {.known = true, .display_binding = true}; + p = leia_lnx_sr_plan_weaver(&none_bindable, true, DS1_ID); + CHECK(!p.bind_display); +} + +static void +test_fallback_order(void) +{ + struct leia_lnx_sr_weaver_plan p = leia_lnx_sr_plan_weaver(&k_caps_new, true, DS1_ID); + struct leia_lnx_sr_weaver_plan n; + // 1. drop the binding (EDID-only / unknown id refused) ... + CHECK(leia_lnx_sr_plan_weaver_fallback(&p, &n)); + CHECK(n.routing_external && !n.bind_display && n.display_id == 0); + // 2. ... then the routing ... + p = n; + CHECK(leia_lnx_sr_plan_weaver_fallback(&p, &n)); + CHECK(!n.routing_external && !n.bind_display); + // 3. ... then nothing left: the plain create failed, give up. + p = n; + CHECK(!leia_lnx_sr_plan_weaver_fallback(&p, &n)); +} + +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING +static void +test_chain_full(void) +{ + const struct leia_lnx_sr_weaver_plan p = leia_lnx_sr_plan_weaver(&k_caps_new, true, DS1_ID); + struct leia_lnx_sr_weaver_chain storage; + SrWeaverCreateInfoVulkan wci = SrWeaverCreateInfoVulkan(.window = 0); + wci.pNext = leia_lnx_sr_build_weaver_chain(&p, &storage, wci.pNext); + + const SrWeaverRoutingInfo *r = (const SrWeaverRoutingInfo *)wci.pNext; + CHECK(r == &storage.routing); + CHECK(r->sType == SR_TYPE_WEAVER_ROUTING_INFO && (int)r->sType == 22); + CHECK(r->mode == SR_WEAVER_ROUTING_EXTERNAL); + CHECK(r->flags == 0); // no KEEP_DRAG_SNAP on Linux + const SrDisplayBindingInfo *b = (const SrDisplayBindingInfo *)r->pNext; + CHECK(b == &storage.binding); + CHECK(b->sType == SR_TYPE_DISPLAY_BINDING_INFO && (int)b->sType == 23); + CHECK(b->displayId == DS1_ID); + CHECK(b->pNext == NULL); + CHECK(wci.window == 0); +} + +static void +test_chain_partial_and_empty(void) +{ + struct leia_lnx_sr_weaver_chain storage; + int tail_marker = 0; + + // Routing only, onto an existing tail. + struct leia_lnx_sr_weaver_plan p = leia_lnx_sr_plan_weaver(&k_caps_new, true, 0); + const void *head = leia_lnx_sr_build_weaver_chain(&p, &storage, &tail_marker); + CHECK(head == &storage.routing); + CHECK(storage.routing.pNext == &tail_marker); + + // Binding only (routing opted out). + p = leia_lnx_sr_plan_weaver(&k_caps_new, false, DS1_ID); + head = leia_lnx_sr_build_weaver_chain(&p, &storage, NULL); + CHECK(head == &storage.binding); + CHECK(storage.binding.pNext == NULL && storage.binding.displayId == DS1_ID); + + // Nothing (1.38 runtime): the create-info is untouched. + p = leia_lnx_sr_plan_weaver(&k_caps_138, true, DS1_ID); + head = leia_lnx_sr_build_weaver_chain(&p, &storage, &tail_marker); + CHECK(head == &tail_marker); + head = leia_lnx_sr_build_weaver_chain(&p, &storage, NULL); + CHECK(head == NULL); +} + +static void +test_caps_chain(void) +{ + struct leia_lnx_sr_caps_chain c; + leia_lnx_sr_caps_chain_init(&c); + CHECK(c.caps.sType == SR_TYPE_RUNTIME_CAPABILITIES); + CHECK(c.caps.pNext == &c.routing); + CHECK(c.routing.sType == SR_TYPE_WEAVER_ROUTING_CAPABILITIES && (int)c.routing.sType == 24); + CHECK(c.routing.pNext == &c.binding); + CHECK(c.binding.sType == SR_TYPE_DISPLAY_BINDING_CAPABILITIES && (int)c.binding.sType == 26); + CHECK(c.binding.pNext == NULL); + // Initialised to "not supported": a runtime that predates them leaves them so. + struct leia_lnx_sr_multi_caps m = leia_lnx_sr_caps_chain_reduce(SR_SUCCESS, &c); + CHECK(m.known && !m.external_routing && !m.display_binding && m.max_bound_displays == 0); + + // What a phase A-C runtime writes. + c.routing.externalRouting = SR_TRUE; + c.binding.displayBinding = SR_TRUE; + c.binding.maxBoundDisplays = 1; + m = leia_lnx_sr_caps_chain_reduce(SR_SUCCESS, &c); + CHECK(m.known && m.external_routing && m.display_binding && m.max_bound_displays == 1); + + // A failed query is "unknown", whatever the structs hold. + m = leia_lnx_sr_caps_chain_reduce(SR_ERROR_VALIDATION_FAILURE, &c); + CHECK(!m.known && !m.external_routing && !m.display_binding); +} +#endif + +int +main(void) +{ + test_plan_new_runtime(); + test_plan_old_or_unknown_runtime(); + test_fallback_order(); +#ifdef DXR_LEIA_LNX_HAVE_SR_ROUTING + test_chain_full(); + test_chain_partial_and_empty(); + test_caps_chain(); + printf("test_sr_routing_linux: plan + SR pNext chains checked\n"); +#else + printf("test_sr_routing_linux: plan checked (SR chain half compiled out: SDK headers lack the structs)\n"); +#endif + if (g_failures != 0) { + fprintf(stderr, "test_sr_routing_linux: %d failure(s)\n", g_failures); + return EXIT_FAILURE; + } + printf("test_sr_routing_linux: all checks passed\n"); + return EXIT_SUCCESS; +} From 0a223cd8964c5472df9514835316517e49246431 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 11:50:10 -0700 Subject: [PATCH 2/9] =?UTF-8?q?feat(linux):=20per-DP=20screen=20binding=20?= =?UTF-8?q?=E2=80=94=20create=5Fdp=5Fvk=5Ffor=5Fscreen=20+=20get=5Fdisplay?= =?UTF-8?q?=5Finfo=5Ffor=5Fmonitor=20(multi-screen=20M4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the runtime's two appended plug-in slots, each compiled only when the runtime headers announce it (XRT_PLUGIN_IFACE_HAS_CREATE_DP_FOR_SCREEN, XRT_PLUGIN_IFACE_HAS_DISPLAY_INFO_FOR_MONITOR), so the arm still builds — slots compiled out — against the pinned runtime. - create_dp_vk_for_screen: the DP resolves its xrt_screen_binding ONCE, at creation, into a struct leia_lnx_screen it owns (leia_screen_linux.c: binding + the #311 claim table + the cached EDID panel list + the SR query), and get_display_dimensions / get_display_pixel_info answer from that alone — no process-wide SR display, no "first panel" statics, so two DPs cannot answer for each other. Origin = the binding's desktop rect; SR's size/viewer/recommended view/refresh only for the SR-driven panel (by displayId, else SR 1.38's first-panel rule). The SR displayId (the binding's, else the claim table's) goes to the weaver's display binding. - A segment DP with a sub-rect canvas confines every write to it: 1x1 blit to the canvas, weave render area = the canvas, whole-target alpha-gate skipped (logged once). The plain create_dp_vk is unchanged. - get_display_info_for_monitor answers for a claimed monitor that is the SR-driven panel (same numbers as get_display_info); false otherwise, so the runtime derives EDID defaults with no eye tracking. - get_scanout_caps stated explicitly: weave scope CANVAS, flags = 0 (no TOLERATES_RESAMPLE — the weave needs 1:1 pixels). - test_screen_linux: binding -> screen rules, and two instances never share state. Co-Authored-By: Claude Opus 5.5 --- src/drv_leia_linux/CMakeLists.txt | 14 + .../leia_display_processor_linux.c | 216 +++++++++++-- .../leia_display_processor_linux.h | 18 ++ src/drv_leia_linux/leia_edid_probe_linux.c | 8 + src/drv_leia_linux/leia_edid_probe_linux.h | 10 + src/drv_leia_linux/leia_plugin_linux.c | 171 +++++++++++ src/drv_leia_linux/leia_screen_linux.c | 151 ++++++++++ src/drv_leia_linux/leia_screen_linux.h | 114 +++++++ src/drv_leia_linux/tests/test_screen_linux.c | 284 ++++++++++++++++++ 9 files changed, 966 insertions(+), 20 deletions(-) create mode 100644 src/drv_leia_linux/leia_screen_linux.c create mode 100644 src/drv_leia_linux/leia_screen_linux.h create mode 100644 src/drv_leia_linux/tests/test_screen_linux.c diff --git a/src/drv_leia_linux/CMakeLists.txt b/src/drv_leia_linux/CMakeLists.txt index 9572bc1..22b8e15 100644 --- a/src/drv_leia_linux/CMakeLists.txt +++ b/src/drv_leia_linux/CMakeLists.txt @@ -260,6 +260,8 @@ add_library(DisplayXR-LeiaSR SHARED leia_edid_probe_linux.h leia_display_claims_linux.c leia_display_claims_linux.h + leia_screen_linux.c + leia_screen_linux.h leia_sr_routing_linux.h leia_bg_capture_linux.c leia_bg_capture_linux.h @@ -377,6 +379,18 @@ target_include_directories(test_display_claims_linux PRIVATE target_link_libraries(test_display_claims_linux PRIVATE xrt-interfaces aux_util PkgConfig::PLUGIN_XCB) add_test(NAME test_display_claims_linux COMMAND test_display_claims_linux) +# --- Host unit test: per-DP screen state (multi-screen M4) ---------------- +# leia_screen_linux.c resolves one DP's screen from a binding + the EDID list + +# the SR query, into state the DP owns. Fixtures only — no SDK, no Vulkan +# device, no panel, no X — so both weaver arms and both runtime pins run it. +add_executable(test_screen_linux + tests/test_screen_linux.c + leia_screen_linux.c +) +target_include_directories(test_screen_linux PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(test_screen_linux PRIVATE xrt-interfaces Vulkan::Vulkan m) +add_test(NAME test_screen_linux COMMAND test_screen_linux) + # --- Host unit test: SR weaver chain plan (multi-screen M4) --------------- # leia_sr_routing_linux.h decides routing + binding from the SR caps; pure # C, so it runs everywhere. Where the SDK headers carry the structs, the same diff --git a/src/drv_leia_linux/leia_display_processor_linux.c b/src/drv_leia_linux/leia_display_processor_linux.c index 960dfa3..9b560b4 100644 --- a/src/drv_leia_linux/leia_display_processor_linux.c +++ b/src/drv_leia_linux/leia_display_processor_linux.c @@ -23,6 +23,8 @@ #include "leia_bg_capture_linux.h" #include "leia_bg_capture_worker_linux.h" #include "leia_compose_push_linux.h" +#include "leia_screen_linux.h" +#include "leia_display_claims_linux.h" #include "xrt/xrt_display_processor_vk.h" #include "xrt/xrt_display_metrics.h" @@ -48,6 +50,16 @@ struct leia_dp_linux struct leiasr_lnx *sr; //!< weaver backend — owned uint32_t view_count; //!< from the last process_atlas grid + //! Multi-screen M4: created for ONE screen (create_dp_vk_for_screen). + //! Then the display getters answer from @ref screen — resolved once at + //! creation, owned by this instance — and never from the process-wide SR + //! display or the "first panel" caches; and the DP is a SEGMENT DP that + //! confines every write to its canvas (a sibling DP may own the rest of + //! the target). False = the plain factory: today's behaviour exactly. + bool screen_bound; + struct leia_lnx_screen screen; + bool segment_alpha_gate_logged; //!< one line when a segment skips the alpha-gate + //! XR_DXR_display_zones (ADR-027). Zones need NO special weaver support on //! Linux: the runtime composites every zone into the ONE content atlas and //! the standard process_atlas weave (0,0 offset) presents it — the same @@ -1501,8 +1513,7 @@ flat_enc_blit_copy(struct leia_dp_linux *ldp, const VkImageBlit *blit, VkImage enc_image, VkImage target_image, - uint32_t w, - uint32_t h) + VkRect2D rect) { struct vk_bundle *vk = ldp->vk; @@ -1537,10 +1548,10 @@ flat_enc_blit_copy(struct leia_dp_linux *ldp, // bytes as they are. VkImageCopy region = { .srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, - .srcOffset = {0, 0, 0}, + .srcOffset = {rect.offset.x, rect.offset.y, 0}, .dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, - .dstOffset = {0, 0, 0}, - .extent = {w, h, 1}, + .dstOffset = {rect.offset.x, rect.offset.y, 0}, + .extent = {rect.extent.width, rect.extent.height, 1}, }; vk->vkCmdCopyImage(cmd, enc_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, target_image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); @@ -1634,6 +1645,36 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, // one that is no longer wanted, age the ones waiting on the GPU. dp_capture_reconcile(ldp, /*age_graveyard=*/true); + // Multi-screen M4: a SEGMENT DP (created for one screen) writes only its + // canvas — a sibling DP may already have woven the rest of the target + // this frame. The canvas is clamped to the target (a rect outside it is + // nothing any weaver can honour). A plain-factory DP keeps today's + // behaviour exactly: segment_confined stays false. + VkRect2D out_rect = {.offset = {0, 0}, .extent = {target_width, target_height}}; + bool segment_confined = false; + if (ldp->screen_bound && canvas_width != 0 && canvas_height != 0) { + int64_t x0 = canvas_offset_x, y0 = canvas_offset_y; + int64_t x1 = x0 + canvas_width, y1 = y0 + canvas_height; + x0 = x0 < 0 ? 0 : x0; + y0 = y0 < 0 ? 0 : y0; + x1 = x1 > (int64_t)target_width ? (int64_t)target_width : x1; + y1 = y1 > (int64_t)target_height ? (int64_t)target_height : y1; + if (x1 <= x0 || y1 <= y0) { + // Nothing of the canvas is on the target: write nothing, but + // leave the target in the layout every DP hands back (#280). + if (target_image != (VkImage_XDP)0) { + dp_target_to_present(ldp, cmd_buffer, (VkImage)target_image); + } + return; + } + out_rect.offset.x = (int32_t)x0; + out_rect.offset.y = (int32_t)y0; + out_rect.extent.width = (uint32_t)(x1 - x0); + out_rect.extent.height = (uint32_t)(y1 - y0); + segment_confined = + !(x0 == 0 && y0 == 0 && (uint32_t)x1 == target_width && (uint32_t)y1 == target_height); + } + // runtime#542: atlas processing follows the CONTENT, not the lens — // the grid the runtime packed decides weave vs flat blit. ldp->view_count = (tile_columns * tile_rows > 1) ? tile_columns * tile_rows : 1; @@ -1700,7 +1741,10 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, .srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, .srcOffsets = {{0, 0, 0}, {(int32_t)view_width, (int32_t)view_height, 1}}, .dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, - .dstOffsets = {{0, 0, 0}, {(int32_t)target_width, (int32_t)target_height, 1}}, + // The whole target, or (segment DP) just its canvas. + .dstOffsets = {{out_rect.offset.x, out_rect.offset.y, 0}, + {out_rect.offset.x + (int32_t)out_rect.extent.width, + out_rect.offset.y + (int32_t)out_rect.extent.height, 1}}, }; // Issue #278: the declared encoding is read per frame (set_atlas_encoding // runs before every process_atlas). A LINEAR atlas into a target that @@ -1717,8 +1761,8 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, } } if (enc_image != VK_NULL_HANDLE) { - flat_enc_blit_copy(ldp, cmd_buffer, (VkImage)atlas_image, &blit, enc_image, (VkImage)target_image, - target_width, target_height); + flat_enc_blit_copy(ldp, cmd_buffer, (VkImage)atlas_image, &blit, enc_image, + (VkImage)target_image, out_rect); } else { vk->vkCmdBlitImage(cmd_buffer, (VkImage)atlas_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, (VkImage)target_image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, @@ -1807,6 +1851,7 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, .width = target_width, .height = target_height, .format = (VkFormat)target_format, + .confine_to_viewport = segment_confined, }; // Viewport and phase origin decoupled (contract R-W7): the woven pixels @@ -1817,6 +1862,10 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, .extent = {canvas_width != 0 ? canvas_width : target_width, canvas_height != 0 ? canvas_height : target_height}, }; + if (segment_confined) { + // The clamped canvas: a render area must lie inside the framebuffer. + viewport = out_rect; + } // Windowed weaving (runtime#757 / LeiaSR#85): the phase origin is the app // WINDOW's panel-relative top-left (pushed by the compositor via // set_present_origin; 0,0 = display-scoped default). The weave adds the @@ -1835,7 +1884,17 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, // view when compose_pre_weave baked a trusted captured desktop into the // de-occlusion band this frame, else in ANY view (silhouette intersection). Pass the ORIGINAL atlas_view (pre-compose) — that carries the app's // per-view alpha the gate keys on. - if (dp_transparency_live(ldp) && target_image != (VkImage_XDP)0 && !dxr_leia_bg_debug()) { + if (segment_confined && dp_transparency_live(ldp)) { + // The alpha-gate re-punches the WHOLE target from the atlas; on a + // segment it would punch the sibling segments too. A transparent + // window spanning screens stays opaque on this one (multi-screen M4). + if (!ldp->segment_alpha_gate_logged) { + ldp->segment_alpha_gate_logged = true; + U_LOG_W( + "leia_lnx_dp: segment DP with transparency — post-weave alpha-gate skipped (it is " + "whole-target); this segment presents opaque (logged once)"); + } + } else if (dp_transparency_live(ldp) && target_image != (VkImage_XDP)0 && !dxr_leia_bg_debug()) { alpha_gate_run(ldp, cmd_buffer, (VkImage)target_image, (VkImageView)atlas_view, (VkFormat)target_format, target_width, target_height, tile_columns, tile_rows); } @@ -1878,7 +1937,16 @@ leia_lnx_dp_get_predicted_eye_positions(struct xrt_display_processor *xdp, struc static bool leia_lnx_dp_get_display_dimensions(struct xrt_display_processor *xdp, float *out_width_m, float *out_height_m) { - (void)xdp; + struct leia_dp_linux *ldp = leia_dp_linux(xdp); + if (ldp->screen_bound) { + // Multi-screen M4: THIS DP's screen, resolved at creation. + if (!ldp->screen.valid) { + return false; + } + *out_width_m = ldp->screen.width_m; + *out_height_m = ldp->screen.height_m; + return true; + } struct leiasr_lnx_display_info info; if (!leiasr_lnx_query_display_info(&info) || !info.valid) { return false; @@ -1895,7 +1963,20 @@ leia_lnx_dp_get_display_pixel_info(struct xrt_display_processor *xdp, int32_t *out_screen_left, int32_t *out_screen_top) { - (void)xdp; + struct leia_dp_linux *ldp = leia_dp_linux(xdp); + if (ldp->screen_bound) { + // Multi-screen M4: the bound screen's device pixels and the + // desktop origin the runtime gave it (out_screen_left/top = the + // binding's origin, per the create_dp_vk_for_screen contract). + if (!ldp->screen.valid) { + return false; + } + *out_pixel_width = ldp->screen.pixel_width; + *out_pixel_height = ldp->screen.pixel_height; + *out_screen_left = ldp->screen.screen_left; + *out_screen_top = ldp->screen.screen_top; + return true; + } struct leiasr_lnx_display_info info; if (!leiasr_lnx_query_display_info(&info) || !info.valid) { return false; @@ -2212,6 +2293,34 @@ leia_lnx_dp_get_background_preview(struct xrt_display_processor_vk *xdp_vk, stru } #endif +#ifdef XRT_DP_VK_HAS_SCANOUT_CAPS +/* + * Scanout caps, stated explicitly rather than left to the absent-slot default + * (which is the same answer): a GPU weaver that writes final pixels into the + * canvas it is handed (weave scope CANVAS), whose output does NOT survive a + * display-server resample — a lenticular weave is a 1-pixel-period pattern + * that a fractional scale turns into a uniform double image. So flags = 0, + * i.e. no XRT_DP_SCANOUT_FLAG_TOLERATES_RESAMPLE (multi-screen M2/M4): the + * runtime's refuse-rather-than-resample gates (#1595, #1831, the per-segment + * gate) keep degrading a resampled Leia surface to flat 2D. Writes only within + * the caller's struct_size and zeroes everything else in it. + */ +static bool +leia_lnx_dp_get_scanout_caps(struct xrt_display_processor_vk *xdp_vk, struct xrt_dp_scanout_caps *out_caps) +{ + (void)xdp_vk; + if (out_caps == NULL || out_caps->struct_size < XRT_DP_SCANOUT_CAPS_SIZE_V1) { + return false; + } + const uint32_t caller_size = out_caps->struct_size; + const size_t n = caller_size < sizeof(*out_caps) ? caller_size : sizeof(*out_caps); + memset((uint8_t *)out_caps + sizeof(out_caps->struct_size), 0, n - sizeof(out_caps->struct_size)); + out_caps->weave_scope = XRT_DP_WEAVE_SCOPE_CANVAS; + // flags (multi-screen M2, carved out of reserved[0]) stay 0: needs 1:1 pixels. + return true; +} +#endif + static void leia_lnx_dp_destroy(struct xrt_display_processor *xdp) { @@ -2332,12 +2441,13 @@ leia_lnx_dp_clear_local_zone_mask(struct xrt_display_processor *xdp) * */ -xrt_result_t -leia_lnx_dp_factory_vk(void *vk_bundle, - void *vk_cmd_pool, - void *window_handle, - int32_t target_format, - struct xrt_display_processor **out_xdp) +static xrt_result_t +leia_lnx_dp_create(void *vk_bundle, + void *vk_cmd_pool, + void *window_handle, + int32_t target_format, + const struct leia_lnx_screen_binding *binding, + struct xrt_display_processor **out_xdp) { struct vk_bundle *vk = (struct vk_bundle *)vk_bundle; if (vk == NULL || out_xdp == NULL) { @@ -2410,6 +2520,10 @@ leia_lnx_dp_factory_vk(void *vk_bundle, #endif #ifdef XRT_DP_VK_HAS_OVERLAY_2D_FILTER_STRENGTH ldp->base.set_overlay_2d_filter_strength = leia_lnx_dp_set_overlay_2d_filter_strength; // XR_DXR_weave v15 +#endif +#ifdef XRT_DP_VK_HAS_SCANOUT_CAPS + // Weave scope CANVAS, flags 0 = needs 1:1 pixels (multi-screen M2/M4). + ldp->base.get_scanout_caps = leia_lnx_dp_get_scanout_caps; #endif // The struct is calloc'd: 0.0 would mean "no lens filtering". Start (and, // against a runtime without the v15 slot, stay) at "SR default". @@ -2419,9 +2533,20 @@ leia_lnx_dp_factory_vk(void *vk_bundle, ldp->vk = vk; ldp->view_count = 2; - // Backend creation on the compositor's Vulkan objects (R-W2). The - // window handle is NULL on Linux today (display-scoped, contract §3.5); - // runtime Phase 3b supplies an X11 Window for per-window phase. + // Multi-screen M4: the SR display this DP's screen is, for the weaver's + // display binding — the runtime's binding (opaque to it), else this + // plug-in's own probe_displays table. 0 for the plain factory. + struct leia_lnx_claim_binding claim = {0}; + const bool have_claim = binding != NULL && leia_lnx_claims_lookup(binding->monitor_id, &claim); + uint64_t sr_display_id = 0; + if (binding != NULL) { + sr_display_id = binding->display_id != 0 ? binding->display_id : claim.sr_display_id; + } + + // Backend creation on the compositor's Vulkan objects (R-W2). The window + // handle is carried on the seam, but the SDK weaver is ALWAYS created + // windowless (multi-screen M4 / ADR-033 — leia_sr_linux.h): the phase + // origin comes from set_present_origin every frame. struct leiasr_lnx_create_info info = { .physical_device = vk->physical_device, .device = vk->device, @@ -2432,6 +2557,7 @@ leia_lnx_dp_factory_vk(void *vk_bundle, .x11_connection = NULL, .retry_budget_s = 5.0, // Windows-parity connect budget (R-W1) .target_format = (VkFormat)target_format, + .sr_display_id = sr_display_id, }; struct leiasr_lnx *sr = NULL; enum leiasr_lnx_result res = leiasr_lnx_create(&info, &sr); @@ -2443,6 +2569,31 @@ leia_lnx_dp_factory_vk(void *vk_bundle, } ldp->sr = sr; + if (binding != NULL) { + // Resolve this DP's screen ONCE, into state it owns: the binding, the + // EDID panel list (cached per process, sysfs + RandR once), and — only + // if this screen is the panel the SR context drives — SR's numbers. + struct leia_lnx_edid_panel panels[LEIA_LNX_EDID_MAX_PANELS]; + const uint32_t panel_count = leia_lnx_edid_panels_cached(panels, LEIA_LNX_EDID_MAX_PANELS); + struct leiasr_lnx_display_info sr_info = {0}; + if (!leiasr_lnx_query_display_info(&sr_info)) { + sr_info.valid = false; + } + leia_lnx_screen_resolve(binding, have_claim ? &claim : NULL, panels, panel_count, &sr_info, + leia_lnx_sr_active_display_id(), &ldp->screen); + ldp->screen_bound = true; + U_LOG_W( + "leia_lnx_dp: segment DP for monitor 0x%016llx ('%s', connector '%s'): %ux%u px at (%d,%d), " + "%.3fx%.3f m, %s, SR display 0x%016llx%s", + (unsigned long long)binding->monitor_id, binding->device_name, ldp->screen.connector, + ldp->screen.pixel_width, ldp->screen.pixel_height, ldp->screen.screen_left, ldp->screen.screen_top, + ldp->screen.width_m, ldp->screen.height_m, + ldp->screen.is_sr_panel ? "the SR-driven panel" + : "NOT the SR-driven panel (no tracking/calibration)", + (unsigned long long)ldp->screen.sr_display_id, + ldp->screen.valid ? "" : " — INCOMPLETE (getters will report false)"); + } + // WARN not INFO: one-off lifecycle line (docs/reference/debug-logging.md) // — aux INFO is dropped from the hot path, which is exactly how this line // went missing in George's on-hardware trace (#81 smoke-test note). @@ -2503,3 +2654,28 @@ leia_lnx_dp_factory_vk(void *vk_bundle, *out_xdp = &ldp->base.base; return XRT_SUCCESS; } + +xrt_result_t +leia_lnx_dp_factory_vk(void *vk_bundle, + void *vk_cmd_pool, + void *window_handle, + int32_t target_format, + struct xrt_display_processor **out_xdp) +{ + // The plain factory: the primary (first) panel, today's behaviour. + return leia_lnx_dp_create(vk_bundle, vk_cmd_pool, window_handle, target_format, NULL, out_xdp); +} + +xrt_result_t +leia_lnx_dp_factory_vk_for_screen(void *vk_bundle, + void *vk_cmd_pool, + void *window_handle, + int32_t target_format, + const struct leia_lnx_screen_binding *binding, + struct xrt_display_processor **out_xdp) +{ + if (binding == NULL) { + return XRT_ERROR_DEVICE_CREATION_FAILED; + } + return leia_lnx_dp_create(vk_bundle, vk_cmd_pool, window_handle, target_format, binding, out_xdp); +} diff --git a/src/drv_leia_linux/leia_display_processor_linux.h b/src/drv_leia_linux/leia_display_processor_linux.h index 9f5c948..167e691 100644 --- a/src/drv_leia_linux/leia_display_processor_linux.h +++ b/src/drv_leia_linux/leia_display_processor_linux.h @@ -32,6 +32,24 @@ leia_lnx_dp_factory_vk(void *vk_bundle, int32_t target_format, struct xrt_display_processor **out_xdp); +struct leia_lnx_screen_binding; // leia_screen_linux.h + +/*! + * Per-screen variant (multi-screen M4; the runtime's + * `xrt_plugin_iface::create_dp_vk_for_screen`, M2): same as + * leia_lnx_dp_factory_vk, but the DP describes the screen @p binding names — + * its own size, pixels and desktop origin, resolved once at creation into + * per-instance state — and, being a segment DP, confines every write to the + * canvas it is handed. @p binding is never NULL. + */ +xrt_result_t +leia_lnx_dp_factory_vk_for_screen(void *vk_bundle, + void *vk_cmd_pool, + void *window_handle, + int32_t target_format, + const struct leia_lnx_screen_binding *binding, + struct xrt_display_processor **out_xdp); + #ifdef __cplusplus } #endif diff --git a/src/drv_leia_linux/leia_edid_probe_linux.c b/src/drv_leia_linux/leia_edid_probe_linux.c index b5c980e..9d5c5ec 100644 --- a/src/drv_leia_linux/leia_edid_probe_linux.c +++ b/src/drv_leia_linux/leia_edid_probe_linux.c @@ -477,6 +477,14 @@ leia_lnx_edid_panels_snapshot(struct leia_lnx_edid_panel *out, uint32_t cap, uin return out != NULL ? n : 0; } +uint32_t +leia_lnx_edid_panels_cached(struct leia_lnx_edid_panel *out, uint32_t cap) +{ + // Per-DP screen resolution: whatever the cache holds now (refreshed by + // probe_displays' TTL and by SR topology events), no rescan of its own. + return leia_lnx_edid_panels_snapshot(out, cap, UINT64_MAX); +} + void leia_lnx_edid_cache_invalidate(void) { diff --git a/src/drv_leia_linux/leia_edid_probe_linux.h b/src/drv_leia_linux/leia_edid_probe_linux.h index c1b52ef..9debe2f 100644 --- a/src/drv_leia_linux/leia_edid_probe_linux.h +++ b/src/drv_leia_linux/leia_edid_probe_linux.h @@ -111,6 +111,16 @@ leia_lnx_edid_cache_invalidate(void); bool leia_lnx_edid_panel_desktop_position_cached(const char *connector, int32_t *out_left, int32_t *out_top); +/*! + * The panel list the position cache resolved (one enumerate-with-positions + * pass per process, shared with leia_lnx_edid_panel_desktop_position_cached). + * Multi-screen M4: what a per-screen DP resolves its screen against, without + * re-scanning sysfs or reconnecting to X per DP. Thread-safe. + * @return entries copied to @p out (<= @p cap). + */ +uint32_t +leia_lnx_edid_panels_cached(struct leia_lnx_edid_panel *out, uint32_t cap); + /*! * Scan /sys/class/drm//edid for a connected monitor whose EDID * manufacturer+product IDs match the known Leia panel table. Single-panel diff --git a/src/drv_leia_linux/leia_plugin_linux.c b/src/drv_leia_linux/leia_plugin_linux.c index 3b49a0a..58dd567 100644 --- a/src/drv_leia_linux/leia_plugin_linux.c +++ b/src/drv_leia_linux/leia_plugin_linux.c @@ -41,9 +41,11 @@ #include "leia_display_processor_linux.h" #include "leia_edid_probe_linux.h" #include "leia_display_claims_linux.h" +#include "leia_screen_linux.h" #include #include +#include #include DEBUG_GET_ONCE_BOOL_OPTION(leia_force_probe, "DXR_LEIA_FORCE_PROBE", false) @@ -325,6 +327,166 @@ leia_lnx_plugin_probe_displays(struct xrt_plugin_instance *inst, } +/* + * + * Multi-screen M1/M4: per-monitor display info and per-screen DPs. Both slots + * are appended to xrt_plugin_iface (ADR-020, struct_size-gated) and compiled + * only when the runtime headers announce them, so this file still builds — + * slots compiled out — against a runtime pinned before multi-screen. + * + */ + +#ifdef XRT_PLUGIN_IFACE_HAS_DISPLAY_INFO_FOR_MONITOR +/*! + * Fill an xrt_plugin_display_info from a resolved screen (the shared tail of + * get_display_info_for_monitor). The SR-driven panel only: its eye tracking, + * and the same recommended view scale get_display_info reports (seeded at + * probe from the same SR numbers — never a second figure). + */ +static void +leia_lnx_fill_info_from_screen(const struct leia_lnx_screen *scr, struct xrt_plugin_display_info *out_info) +{ + out_info->display_width_m = scr->width_m; + out_info->display_height_m = scr->height_m; + out_info->nominal_viewer_x_m = scr->nominal_viewer_x_m; + out_info->nominal_viewer_y_m = scr->nominal_viewer_y_m; + out_info->nominal_viewer_z_m = scr->nominal_viewer_z_m; + out_info->display_pixel_width = scr->pixel_width; + out_info->display_pixel_height = scr->pixel_height; + leia_view_scale_get(&out_info->recommended_view_scale_x, &out_info->recommended_view_scale_y); + out_info->display_screen_left = scr->screen_left; + out_info->display_screen_top = scr->screen_top; + out_info->supported_eye_tracking_modes = 1u; /* MANAGED_BIT */ + out_info->default_eye_tracking_mode = 0u; /* MANAGED */ + if (out_info->struct_size >= offsetof(struct xrt_plugin_display_info, refresh_mhz) + sizeof(uint32_t)) { + out_info->refresh_mhz = scr->refresh_mhz; + } +} + +/*! Resolve @p b against the claim table, the cached EDID list and the SR query. */ +static void +leia_lnx_resolve_screen(const struct leia_lnx_screen_binding *b, struct leia_lnx_screen *out) +{ + struct leia_lnx_claim_binding claim = {0}; + const bool have_claim = leia_lnx_claims_lookup(b->monitor_id, &claim); + struct leia_lnx_edid_panel panels[LEIA_LNX_EDID_MAX_PANELS]; + const uint32_t panel_count = leia_lnx_edid_panels_cached(panels, LEIA_LNX_EDID_MAX_PANELS); + struct leiasr_lnx_display_info sr = {0}; + if (!leiasr_lnx_query_display_info(&sr)) { + sr.valid = false; + } + leia_lnx_screen_resolve(b, have_claim ? &claim : NULL, panels, panel_count, &sr, + leia_lnx_sr_active_display_id(), out); +} + +static bool +leia_lnx_plugin_get_display_info_for_monitor(struct xrt_plugin_instance *inst, + const struct xrt_display_descriptor *display, + const struct xrt_display_physical *physical, + struct xrt_plugin_display_info *out_info) +{ + (void)inst; + if (display == NULL || out_info == NULL || + display->struct_size < offsetof(struct xrt_display_descriptor, screen_top) + sizeof(display->screen_top)) { + return false; + } + // Only monitors this plug-in claimed in probe_displays. + if (!leia_lnx_claims_lookup(display->monitor_id, NULL)) { + return false; + } + + struct leia_lnx_screen_binding b = {0}; + b.monitor_id = display->monitor_id; + b.desktop_left = display->screen_left; + b.desktop_top = display->screen_top; + b.desktop_width = display->pixel_width; + b.desktop_height = display->pixel_height; + if (physical != NULL) { + const uint32_t psz = physical->struct_size; + if (psz >= offsetof(struct xrt_display_physical, physical_height_mm) + sizeof(uint32_t)) { + b.width_mm = physical->physical_width_mm; + b.height_mm = physical->physical_height_mm; + } + if (psz >= offsetof(struct xrt_display_physical, native_pixel_height) + sizeof(uint32_t)) { + b.native_width = physical->native_pixel_width; + b.native_height = physical->native_pixel_height; + } + } + + struct leia_lnx_screen scr; + leia_lnx_resolve_screen(&b, &scr); + // A Leia panel the SR context does not drive (a second panel, until + // LeiaSR phase D) has no tracking or calibration here: let the runtime + // derive its EDID defaults (no eye tracking, view scale 1). + if (!scr.valid || !scr.is_sr_panel) { + return false; + } + leia_lnx_fill_info_from_screen(&scr, out_info); + return true; +} +#endif + +#ifdef XRT_PLUGIN_IFACE_HAS_CREATE_DP_FOR_SCREEN +/*! Mirror the runtime's binding into the plain struct, never reading past its struct_size. */ +static void +leia_lnx_binding_from_xrt(const struct xrt_screen_binding *xb, struct leia_lnx_screen_binding *out) +{ + memset(out, 0, sizeof(*out)); + const uint32_t sz = xb->struct_size; +#define LEIA_HAS(field) (sz >= offsetof(struct xrt_screen_binding, field) + sizeof(xb->field)) + if (LEIA_HAS(monitor_id)) { + out->monitor_id = xb->monitor_id; + } + if (LEIA_HAS(desktop_height)) { + out->desktop_left = xb->desktop_left; + out->desktop_top = xb->desktop_top; + out->desktop_width = xb->desktop_width; + out->desktop_height = xb->desktop_height; + } + if (LEIA_HAS(native_pixel_height)) { + out->native_width = xb->native_pixel_width; + out->native_height = xb->native_pixel_height; + } + if (LEIA_HAS(physical_height_mm)) { + out->width_mm = xb->physical_width_mm; + out->height_mm = xb->physical_height_mm; + } + if (LEIA_HAS(desktop_scale)) { + out->desktop_scale = xb->desktop_scale; + } + if (LEIA_HAS(display_id)) { + out->display_id = xb->display_id; + } + if (LEIA_HAS(serial)) { + snprintf(out->serial, sizeof(out->serial), "%.*s", (int)sizeof(xb->serial), xb->serial); + } + if (LEIA_HAS(device_name)) { + snprintf(out->device_name, sizeof(out->device_name), "%.*s", (int)sizeof(xb->device_name), + xb->device_name); + } +#undef LEIA_HAS +} + +static xrt_result_t +leia_lnx_plugin_create_dp_vk_for_screen(struct xrt_plugin_instance *inst, + void *vk_bundle, + void *vk_cmd_pool, + void *window_handle, + int32_t target_format, + const struct xrt_screen_binding *binding, + struct xrt_display_processor **out_xdp) +{ + (void)inst; + if (binding == NULL || out_xdp == NULL) { + return XRT_ERROR_DEVICE_CREATION_FAILED; + } + struct leia_lnx_screen_binding b; + leia_lnx_binding_from_xrt(binding, &b); + return leia_lnx_dp_factory_vk_for_screen(vk_bundle, vk_cmd_pool, window_handle, target_format, &b, out_xdp); +} +#endif + + /* * * Vtable. @@ -381,6 +543,15 @@ static struct xrt_plugin_iface g_leia_lnx_iface = { /* Per-monitor claims (multi-screen M0; #69 / ADR-015 shape). */ .probe_displays = leia_lnx_plugin_probe_displays, + +#ifdef XRT_PLUGIN_IFACE_HAS_DISPLAY_INFO_FOR_MONITOR + /* Per-monitor display info (multi-screen M1). */ + .get_display_info_for_monitor = leia_lnx_plugin_get_display_info_for_monitor, +#endif +#ifdef XRT_PLUGIN_IFACE_HAS_CREATE_DP_FOR_SCREEN + /* One DP per screen (multi-screen M2 slot; this plug-in's side is M4). */ + .create_dp_vk_for_screen = leia_lnx_plugin_create_dp_vk_for_screen, +#endif }; diff --git a/src/drv_leia_linux/leia_screen_linux.c b/src/drv_leia_linux/leia_screen_linux.c new file mode 100644 index 0000000..5fe2e8b --- /dev/null +++ b/src/drv_leia_linux/leia_screen_linux.c @@ -0,0 +1,151 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Per-DP screen state (multi-screen M4) — see leia_screen_linux.h. + * + * @author David Fattal + * @ingroup drv_leia_linux + */ + +#include "leia_screen_linux.h" +#include "leia_sr_linux.h" // leiasr_lnx_display_info + +#include +#include + +//! Fallback nominal viewing distance when nothing better is known (m). +#define LEIA_LNX_SCREEN_DEFAULT_VIEWER_Z_M 0.65f + +static const struct leia_lnx_edid_panel * +find_panel(const struct leia_lnx_screen_binding *b, + const struct leia_lnx_claim_binding *claim, + const struct leia_lnx_edid_panel *panels, + uint32_t panel_count) +{ + if (panels == NULL || panel_count == 0) { + return NULL; + } + // 1. The claim knows the DRM connector it matched at probe_displays. + if (claim != NULL && claim->connector[0] != '\0') { + for (uint32_t i = 0; i < panel_count; i++) { + if (strcmp(panels[i].connector, claim->connector) == 0) { + return &panels[i]; + } + } + } + // 2. The binding's OS output name: a DRM connector, or (X11) a RandR + // output — matched against both spellings the panel list carries. + if (b->device_name[0] != '\0') { + for (uint32_t i = 0; i < panel_count; i++) { + if (strcmp(panels[i].connector, b->device_name) == 0 || + (panels[i].randr_output[0] != '\0' && + strcmp(panels[i].randr_output, b->device_name) == 0)) { + return &panels[i]; + } + } + } + // 3. The panel sitting at the binding's desktop origin. + for (uint32_t i = 0; i < panel_count; i++) { + if (panels[i].has_position && panels[i].left == b->desktop_left && panels[i].top == b->desktop_top) { + return &panels[i]; + } + } + return NULL; +} + +void +leia_lnx_screen_resolve(const struct leia_lnx_screen_binding *b, + const struct leia_lnx_claim_binding *claim, + const struct leia_lnx_edid_panel *panels, + uint32_t panel_count, + const struct leiasr_lnx_display_info *sr, + uint64_t sr_active_display_id, + struct leia_lnx_screen *out) +{ + memset(out, 0, sizeof(*out)); + if (b == NULL) { + return; + } + const bool have_sr = sr != NULL && sr->valid; + const struct leia_lnx_edid_panel *p = find_panel(b, claim, panels, panel_count); + + out->monitor_id = b->monitor_id; + if (p != NULL) { + snprintf(out->connector, sizeof(out->connector), "%s", p->connector); + } + + // The SR display id: the runtime's binding (opaque, plug-in supplied + // upstream), else the plug-in's own probe_displays table. + out->sr_display_id = b->display_id != 0 ? b->display_id : (claim != NULL ? claim->sr_display_id : 0); + + // Origin: always the binding's — the runtime placed the screen on the + // desktop and every window rect it hands this DP is in that frame. + out->screen_left = b->desktop_left; + out->screen_top = b->desktop_top; + + // Pixels: the device mode (weave pixels are device pixels), else the + // panel's EDID native mode, else the desktop size. + if (b->native_width != 0 && b->native_height != 0) { + out->pixel_width = b->native_width; + out->pixel_height = b->native_height; + } else if (p != NULL && p->native_w != 0 && p->native_h != 0) { + out->pixel_width = p->native_w; + out->pixel_height = p->native_h; + } else { + out->pixel_width = b->desktop_width; + out->pixel_height = b->desktop_height; + } + + // Is this the panel the process-wide SR display describes? By id when + // both sides know one; otherwise SR 1.38's one-panel rule (the first + // panel in connector order, the same panel the pre-M4 "first panel" + // caches resolved); with no panel list at all (forced probe, no EDID + // match) the single-panel assumption stands. + if (have_sr) { + if (out->sr_display_id != 0 && sr_active_display_id != 0) { + out->is_sr_panel = out->sr_display_id == sr_active_display_id; + } else if (p != NULL) { + out->is_sr_panel = p == &panels[0]; + } else { + out->is_sr_panel = panel_count == 0; + } + } + + if (out->is_sr_panel) { + // The same numbers get_display_info reports (SR's calibrated size, + // already EDID-corrected by the backend when SR answers its built-in + // default), so the system answer and this screen's agree. + out->width_m = sr->width_m; + out->height_m = sr->height_m; + out->nominal_viewer_x_m = sr->nominal_viewer_x_m; + out->nominal_viewer_y_m = sr->nominal_viewer_y_m; + out->nominal_viewer_z_m = sr->nominal_viewer_z_m; + out->recommended_view_width = sr->recommended_view_width; + out->recommended_view_height = sr->recommended_view_height; + out->refresh_mhz = sr->refresh_mhz; + if (out->pixel_width == 0 || out->pixel_height == 0) { + out->pixel_width = sr->pixel_width; + out->pixel_height = sr->pixel_height; + } + } else { + // A Leia panel the SR context does not drive (a second panel: LeiaSR + // phase D), or no SR at all: physical facts only. + if (b->width_mm != 0 && b->height_mm != 0) { + out->width_m = (float)b->width_mm / 1000.0f; + out->height_m = (float)b->height_mm / 1000.0f; + } else if (p != NULL && p->width_mm != 0 && p->height_mm != 0) { + out->width_m = (float)p->width_mm / 1000.0f; + out->height_m = (float)p->height_mm / 1000.0f; + } + // Centred viewer at the SR panel's distance-to-height ratio (SR's + // own design ratio), else a desktop default. + float z = LEIA_LNX_SCREEN_DEFAULT_VIEWER_Z_M; + if (have_sr && sr->height_m > 0.0f && sr->nominal_viewer_z_m > 0.0f && out->height_m > 0.0f) { + z = sr->nominal_viewer_z_m * (out->height_m / sr->height_m); + } + out->nominal_viewer_z_m = z; + } + + out->valid = out->width_m > 0.0f && out->height_m > 0.0f && out->pixel_width != 0 && out->pixel_height != 0; +} diff --git a/src/drv_leia_linux/leia_screen_linux.h b/src/drv_leia_linux/leia_screen_linux.h new file mode 100644 index 0000000..18f4dea --- /dev/null +++ b/src/drv_leia_linux/leia_screen_linux.h @@ -0,0 +1,114 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Per-DP screen state for the Linux arm (multi-screen M4): what ONE + * display processor answers for the screen it was created for — + * pure logic, no SDK, no Vulkan, no I/O. + * + * The runtime's `create_dp_vk_for_screen` slot (multi-screen M2, ADR-047 D2) + * hands each DP the screen it serves (`struct xrt_screen_binding`): monitor + * id, desktop rect, native px, EDID mm, serial, vendor display id. Before M4 + * every DP answered `get_display_dimensions` / `get_display_pixel_info` from + * the process-wide SR display and the "first panel" RandR position, which is + * one panel by construction. Here each DP resolves its own screen ONCE, at + * creation, into a @ref leia_lnx_screen it owns, and its getters read only + * that — so two DPs in one process can never answer for each other. + * + * The runtime struct only exists in runtime headers that carry + * XRT_PLUGIN_IFACE_HAS_CREATE_DP_FOR_SCREEN; @ref leia_lnx_screen_binding is + * its plain mirror so this module (and its test) build against any pinned + * runtime. leia_plugin_linux.c converts, honouring the runtime's struct_size. + * + * @author David Fattal + * @ingroup drv_leia_linux + */ + +#pragma once + +#include "leia_display_claims_linux.h" // leia_lnx_claim_binding +#include "leia_edid_probe_linux.h" // leia_lnx_edid_panel + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct leiasr_lnx_display_info; // leia_sr_linux.h + +/*! Plain mirror of the runtime's `struct xrt_screen_binding` (0 / "" = unknown). */ +struct leia_lnx_screen_binding +{ + uint64_t monitor_id; + int32_t desktop_left, desktop_top; + uint32_t desktop_width, desktop_height; + uint32_t native_width, native_height; //!< the connector's device mode + uint32_t width_mm, height_mm; //!< EDID physical size + float desktop_scale; + uint64_t display_id; //!< vendor (SR) display id, 0 = none + char serial[64]; + char device_name[64]; //!< RandR output (X11) or DRM connector +}; + +/*! What one DP answers for its screen. Owned by the DP; never shared. */ +struct leia_lnx_screen +{ + bool valid; + uint64_t monitor_id; + char connector[32]; //!< DRM connector of the matched Leia panel ("" = none matched) + + //! The SR display this screen is (0 = unknown — SR 1.38, or not listed). + uint64_t sr_display_id; + //! This screen IS the panel the process-wide SR display describes (the + //! one panel the SR instance/tracker/lens drive). Only then are the SR + //! nominal viewer, recommended view size and eye tracking this screen's. + bool is_sr_panel; + + float width_m, height_m; + uint32_t pixel_width, pixel_height; + int32_t screen_left, screen_top; //!< desktop origin (the binding's) + + float nominal_viewer_x_m, nominal_viewer_y_m, nominal_viewer_z_m; + uint32_t recommended_view_width, recommended_view_height; //!< 0 = unknown + uint32_t refresh_mhz; //!< 0 = unknown +}; + +/*! + * Resolve one screen. + * + * @param b The screen (never NULL). + * @param claim The plug-in's probe_displays claim for b->monitor_id, or NULL. + * @param panels The EDID panel list (leia_lnx_edid_enumerate_panels, sorted by connector). + * @param panel_count Entries in @p panels. + * @param sr The process-wide SR display query (valid = false / NULL when none). + * @param sr_active_display_id The displayId the process-wide SR display describes + * (the FPC-verified one), 0 when unknown. + * @param[out] out Filled; out->valid says whether a usable size + resolution resulted. + * + * Rules (each documented at its line in leia_screen_linux.c): + * - origin: always the binding's desktop rect (the runtime placed the screen); + * - panel: the claim's connector, else the binding's device name (DRM + * connector or RandR output), else the panel at the binding's origin; + * - pixels: binding native mode, else the panel's EDID native, else the desktop size; + * - SR panel: by displayId when both ids are known, else "the first panel" + * (connector order — the one SR 1.38 drives on a one-panel box), else + * (no panel list at all, a forced probe) assumed; + * - size / viewer / recommended view / refresh: the SR query's for the SR + * panel (the same numbers get_display_info reports, so the per-screen + * and the system answers cannot disagree), else binding mm / EDID mm with + * a centred viewer at the SR panel's distance-to-height ratio. + */ +void +leia_lnx_screen_resolve(const struct leia_lnx_screen_binding *b, + const struct leia_lnx_claim_binding *claim, + const struct leia_lnx_edid_panel *panels, + uint32_t panel_count, + const struct leiasr_lnx_display_info *sr, + uint64_t sr_active_display_id, + struct leia_lnx_screen *out); + +#ifdef __cplusplus +} +#endif diff --git a/src/drv_leia_linux/tests/test_screen_linux.c b/src/drv_leia_linux/tests/test_screen_linux.c new file mode 100644 index 0000000..159cc71 --- /dev/null +++ b/src/drv_leia_linux/tests/test_screen_linux.c @@ -0,0 +1,284 @@ +// Copyright 2026, Leia Inc / DisplayXR +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Unit test for multi-screen M4 per-DP screen state + * (leia_screen_linux.h): binding -> what one DP answers. + * + * Hardware-free: bindings, the EDID panel list, the SR display query and the + * claim table entries are hand-built fixtures — no SDK, no Vulkan device, no + * sysfs, no X server. The box this targets (ds1-linux): the laptop eDP at + * (0,0) owned by sim_display, the Acer DS1 on HDMI-A-1 to its right, owned by + * leia-sr; plus a second Leia panel for the "not the SR panel" rules. + */ + +#include "leia_screen_linux.h" +#include "leia_sr_linux.h" + +#include +#include +#include +#include + +static int g_failures; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ + g_failures++; \ + } \ + } while (0) + +#define NEAR(a, b) (fabsf((a) - (b)) < 1e-4f) + +#define DS1_SR_ID 0x5352000000000001ull +#define OTHER_SR_ID 0x5352000000000002ull + +//! The DS1 on HDMI-A-1, right of a 2880-px-wide eDP. +static struct leia_lnx_edid_panel +ds1_panel(void) +{ + struct leia_lnx_edid_panel p = {0}; + snprintf(p.connector, sizeof(p.connector), "HDMI-A-1"); + snprintf(p.randr_output, sizeof(p.randr_output), "HDMI-1"); + p.manufacturer_id = 29188; + p.product_id = 1; + p.width_mm = 344; + p.height_mm = 194; + p.native_w = 3840; + p.native_h = 2160; + p.has_position = true; + p.left = 2880; + p.top = 0; + return p; +} + +//! A second Leia panel (LeiaSR phase D territory) on DP-2, below the DS1. +static struct leia_lnx_edid_panel +second_panel(void) +{ + struct leia_lnx_edid_panel p = {0}; + snprintf(p.connector, sizeof(p.connector), "DP-2"); + p.manufacturer_id = 29188; + p.product_id = 1; + p.width_mm = 597; + p.height_mm = 336; + p.native_w = 3840; + p.native_h = 2160; + p.has_position = true; + p.left = 2880; + p.top = 2160; + return p; +} + +//! What the SR context reports for the DS1 (calibrated, not EDID-rounded). +static struct leiasr_lnx_display_info +sr_ds1(void) +{ + struct leiasr_lnx_display_info i = {0}; + i.valid = true; + i.width_m = 0.3442f; + i.height_m = 0.1936f; + i.pixel_width = 3840; + i.pixel_height = 2160; + i.recommended_view_width = 1920; + i.recommended_view_height = 1080; + i.refresh_mhz = 59940; + i.nominal_viewer_y_m = 0.01f; + i.nominal_viewer_z_m = 0.60f; + return i; +} + +static struct leia_lnx_screen_binding +ds1_binding(void) +{ + struct leia_lnx_screen_binding b = {0}; + b.monitor_id = 0xD51; + b.desktop_left = 2880; + b.desktop_top = 0; + b.desktop_width = 2560; // a 150 % scaled desktop: logical px ... + b.desktop_height = 1440; + b.native_width = 3840; // ... vs the device mode + b.native_height = 2160; + b.width_mm = 344; + b.height_mm = 194; + snprintf(b.device_name, sizeof(b.device_name), "HDMI-1"); + return b; +} + +/* The DS1 binding resolves to the SR-driven panel with SR's numbers, the + * binding's origin and the device-mode pixels. */ +static void +test_ds1_binding_is_the_sr_panel(void) +{ + const struct leia_lnx_edid_panel panels[1] = {ds1_panel()}; + const struct leiasr_lnx_display_info sr = sr_ds1(); + const struct leia_lnx_screen_binding b = ds1_binding(); + + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, NULL, panels, 1, &sr, 0, &s); + CHECK(s.valid); + CHECK(s.monitor_id == 0xD51); + CHECK(strcmp(s.connector, "HDMI-A-1") == 0); // joined by RandR output name + CHECK(s.is_sr_panel); // SR 1.38 rule: the first panel + CHECK(s.pixel_width == 3840 && s.pixel_height == 2160); + CHECK(s.screen_left == 2880 && s.screen_top == 0); + CHECK(NEAR(s.width_m, 0.3442f) && NEAR(s.height_m, 0.1936f)); // SR's, not EDID mm + CHECK(NEAR(s.nominal_viewer_z_m, 0.60f) && NEAR(s.nominal_viewer_y_m, 0.01f)); + CHECK(s.recommended_view_width == 1920 && s.recommended_view_height == 1080); + CHECK(s.refresh_mhz == 59940); +} + +/* The claim's connector wins over every heuristic; its displayId is carried. */ +static void +test_claim_connector_and_display_id(void) +{ + const struct leia_lnx_edid_panel panels[2] = {ds1_panel(), second_panel()}; + const struct leiasr_lnx_display_info sr = sr_ds1(); + struct leia_lnx_screen_binding b = ds1_binding(); + snprintf(b.device_name, sizeof(b.device_name), "%s", ""); // nothing to join by name + b.desktop_left = 9999; // nor by origin + + struct leia_lnx_claim_binding claim = {.monitor_id = 0xD51, .sr_display_id = DS1_SR_ID}; + snprintf(claim.connector, sizeof(claim.connector), "HDMI-A-1"); + + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, &claim, panels, 2, &sr, DS1_SR_ID, &s); + CHECK(strcmp(s.connector, "HDMI-A-1") == 0); + CHECK(s.sr_display_id == DS1_SR_ID); + CHECK(s.is_sr_panel); // by id + CHECK(s.screen_left == 9999); // origin is the binding's, always + + // The runtime's opaque display_id wins over the claim table. + b.display_id = OTHER_SR_ID; + leia_lnx_screen_resolve(&b, &claim, panels, 2, &sr, DS1_SR_ID, &s); + CHECK(s.sr_display_id == OTHER_SR_ID); + CHECK(!s.is_sr_panel); // ids known on both sides and different +} + +/* A second Leia panel is not the SR-driven one: EDID facts, no SR numbers. */ +static void +test_second_panel_is_not_the_sr_panel(void) +{ + const struct leia_lnx_edid_panel panels[2] = {ds1_panel(), second_panel()}; + const struct leiasr_lnx_display_info sr = sr_ds1(); + struct leia_lnx_screen_binding b = {0}; + b.monitor_id = 0x2; + b.desktop_left = 2880; + b.desktop_top = 2160; + b.desktop_width = 3840; + b.desktop_height = 2160; // no device mode, no mm in the binding + + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, NULL, panels, 2, &sr, 0, &s); + CHECK(s.valid); + CHECK(strcmp(s.connector, "DP-2") == 0); // joined by origin + CHECK(!s.is_sr_panel); // not the first panel + CHECK(NEAR(s.width_m, 0.597f) && NEAR(s.height_m, 0.336f)); // EDID mm + CHECK(s.pixel_width == 3840 && s.pixel_height == 2160); // EDID native + CHECK(s.recommended_view_width == 0 && s.refresh_mhz == 0); // unknown, not SR's + // Centred viewer at SR's distance-to-height ratio. + CHECK(NEAR(s.nominal_viewer_x_m, 0.0f) && NEAR(s.nominal_viewer_y_m, 0.0f)); + CHECK(NEAR(s.nominal_viewer_z_m, 0.60f * (0.336f / 0.1936f))); +} + +/* Two DPs, two screens: each instance holds its own answer; resolving the + * second never touches the first (no shared/static state). */ +static void +test_two_instances_do_not_share_state(void) +{ + const struct leia_lnx_edid_panel panels[2] = {ds1_panel(), second_panel()}; + const struct leiasr_lnx_display_info sr = sr_ds1(); + const struct leia_lnx_screen_binding b1 = ds1_binding(); + struct leia_lnx_screen_binding b2 = {0}; + b2.monitor_id = 0x2; + b2.desktop_left = 2880; + b2.desktop_top = 2160; + b2.native_width = 3840; + b2.native_height = 2160; + b2.width_mm = 597; + b2.height_mm = 336; + + struct leia_lnx_screen s1, s2; + leia_lnx_screen_resolve(&b1, NULL, panels, 2, &sr, 0, &s1); + const struct leia_lnx_screen s1_before = s1; + leia_lnx_screen_resolve(&b2, NULL, panels, 2, &sr, 0, &s2); + + CHECK(memcmp(&s1, &s1_before, sizeof(s1)) == 0); + CHECK(s1.monitor_id != s2.monitor_id); + CHECK(s1.screen_top == 0 && s2.screen_top == 2160); + CHECK(s1.is_sr_panel && !s2.is_sr_panel); + CHECK(!NEAR(s1.width_m, s2.width_m)); + + // And re-resolving the first afterwards gives the identical answer. + struct leia_lnx_screen s1_again; + leia_lnx_screen_resolve(&b1, NULL, panels, 2, &sr, 0, &s1_again); + CHECK(memcmp(&s1, &s1_again, sizeof(s1)) == 0); +} + +/* No SR display (headless / SR down): physical facts only, never "the SR panel". */ +static void +test_no_sr(void) +{ + const struct leia_lnx_edid_panel panels[1] = {ds1_panel()}; + const struct leia_lnx_screen_binding b = ds1_binding(); + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, NULL, panels, 1, NULL, 0, &s); + CHECK(s.valid); + CHECK(!s.is_sr_panel); + CHECK(NEAR(s.width_m, 0.344f) && NEAR(s.height_m, 0.194f)); // binding mm + CHECK(NEAR(s.nominal_viewer_z_m, 0.65f)); // desktop default + + struct leiasr_lnx_display_info invalid = sr_ds1(); + invalid.valid = false; + leia_lnx_screen_resolve(&b, NULL, panels, 1, &invalid, 0, &s); + CHECK(!s.is_sr_panel); +} + +/* Forced probe with no EDID match at all: the one-panel assumption stands. */ +static void +test_no_panel_list(void) +{ + const struct leiasr_lnx_display_info sr = sr_ds1(); + struct leia_lnx_screen_binding b = ds1_binding(); + b.native_width = 0; + b.native_height = 0; + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, NULL, NULL, 0, &sr, 0, &s); + CHECK(s.valid); + CHECK(s.is_sr_panel); + CHECK(s.connector[0] == '\0'); + // No device mode in the binding and no panel: the desktop size. + CHECK(s.pixel_width == 2560 && s.pixel_height == 1440); +} + +/* Nothing usable: invalid, and the DP getters then report false. */ +static void +test_incomplete_is_invalid(void) +{ + struct leia_lnx_screen_binding b = {0}; + b.monitor_id = 7; + struct leia_lnx_screen s; + leia_lnx_screen_resolve(&b, NULL, NULL, 0, NULL, 0, &s); + CHECK(!s.valid); + CHECK(s.monitor_id == 7); +} + +int +main(void) +{ + test_ds1_binding_is_the_sr_panel(); + test_claim_connector_and_display_id(); + test_second_panel_is_not_the_sr_panel(); + test_two_instances_do_not_share_state(); + test_no_sr(); + test_no_panel_list(); + test_incomplete_is_invalid(); + if (g_failures != 0) { + fprintf(stderr, "test_screen_linux: %d failure(s)\n", g_failures); + return EXIT_FAILURE; + } + printf("test_screen_linux: all checks passed\n"); + return EXIT_SUCCESS; +} From 3726b76ec67e732048852bbe9c080de2d8e3fd48 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 11:50:10 -0700 Subject: [PATCH 3/9] test, ci, docs(linux): M4 unit tests in build-linux.sh + CI; runbook M4 section; SDK contract rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/build-linux.sh and build-linux.yml run test_screen_linux and test_sr_routing_linux next to the M0 claims test. - docs/linux-track-b-runbook.md: "One DP per screen, windowless EXTERNAL weaver (multi-screen M4)" — the window = 0 rule, what a DP does with a screen binding, the SR routing/binding chain and its fallbacks, lens ownership under EXTERNAL routing, refresh, what stays per process; the DXR_LEIA_SR_EXTERNAL_ROUTING / SR_WEAVER_ROUTING toggles. - docs/leia-linux-sdk-contract.md §8a: one row per new SR call/struct with its build gate and run-time fallback; R-D1's refresh gap answered. - CLAUDE.md: code-structure rows for the new files. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-linux.yml | 14 ++++ CLAUDE.md | 2 + docs/leia-linux-sdk-contract.md | 20 +++++- docs/linux-track-b-runbook.md | 105 ++++++++++++++++++++++++++++++ scripts/build-linux.sh | 6 ++ 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 63d0ce6..7a9736d 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -207,6 +207,20 @@ jobs: if: needs.DetectChanges.outputs.docs_only != 'true' run: ./build/src/drv_leia_linux/test_display_claims_linux + # Multi-screen M4: one DP per screen resolves its own screen (binding + + # EDID list + SR query) into state it owns — no statics shared between + # two DPs — and the SR weaver chain plan (EXTERNAL routing + display + # binding, gated on the SR runtime's caps). The chain half of the second + # test compiles only against SDK headers that carry the structs; this + # stub build checks the plan. + - name: Unit test — per-DP screen state (multi-screen M4) + if: needs.DetectChanges.outputs.docs_only != 'true' + run: ./build/src/drv_leia_linux/test_screen_linux + + - name: Unit test — SR weaver routing/binding plan (multi-screen M4) + if: needs.DetectChanges.outputs.docs_only != 'true' + run: ./build/src/drv_leia_linux/test_sr_routing_linux + - name: Assert single export (ADR-019 / #496) if: needs.DetectChanges.outputs.docs_only != 'true' run: | diff --git a/CLAUDE.md b/CLAUDE.md index d4b0aec..a69c477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,8 @@ ADR-020 spec: [`displayxr-runtime/docs/adr/ADR-020-plugin-abi-policy.md`](https: | `src/drv_leia_linux/leia_sr_linux_sdk.c` | Track B backend: the real srSDK (C99, API 1.0.0) behind the same seam — instance/display/lens/weaver + event latching. Selected by `-DDXR_LEIA_LINUX_WEAVER=sdk`. | | `src/drv_leia_linux/leia_plugin_linux.c` | Linux `xrtPluginNegotiate` + iface (VK-only factories; env-gated probe). | | `src/drv_leia_linux/leia_display_claims_linux.{c,h}` | Linux `probe_displays` matching (multi-screen M0): runtime monitors × EDID panel list × SR display enumeration → per-monitor claims + private `displayId` table. Pure logic, unit-tested. | +| `src/drv_leia_linux/leia_screen_linux.{c,h}` | Per-DP screen state (multi-screen M4): a `create_dp_vk_for_screen` binding + EDID list + SR query → the `struct leia_lnx_screen` one DP owns and answers its display getters from. Pure logic, unit-tested. | +| `src/drv_leia_linux/leia_sr_routing_linux.h`, `leia_sr_chain_sdk_linux.h` | SR weaver create chain (multi-screen M4): the plan (EXTERNAL routing + display binding, gated on the SR runtime's caps; pure) and its SR-typed `pNext` builder. Both unit-tested; the chain half only against SDK headers that carry the structs. | | `src/drv_leia_linux/leia_display_processor_linux.c` | Linux VK DP — 1×1 grid blits, multi-view goes through the seam. Reuses `../drv_leia/leia_device.c`. | | `installer/DisplayXRLeiaSRInstaller.nsi` | NSIS installer. Drops DLL at `C:\Program Files\DisplayXR\Plugins\LeiaSR\`; writes registry entry `HKLM\Software\DisplayXR\DisplayProcessors\leia-sr` with values `Binary`, `ProbeOrder`, `Version`, `DisplayName`, `Vendor`, `UninstallString`. No prerequisites; see `docs/installer.md`. | | `installer/rm-helper/` | `dxr-rm-close.exe` — generic Restart Manager driver the installer/uninstaller use to close and restart whatever maps the plug-in's files (one `session` process spans both phases). `rm_test_holder.cpp` is a test-only holder (EXCLUDE_FROM_ALL). | diff --git a/docs/leia-linux-sdk-contract.md b/docs/leia-linux-sdk-contract.md index 25b7ba2..4738eb6 100644 --- a/docs/leia-linux-sdk-contract.md +++ b/docs/leia-linux-sdk-contract.md @@ -200,12 +200,30 @@ ask on the C99-API PR (send new asks to **LeiaInc/LeiaSR#75**, which supersedes | R-T4 MANAGED | ◐ | Grace/collapse/auto-2D semantics now understood **from source**: the weaver internally decides weave-vs-blit per frame by `eyeSeparation > 1 mm` (the collapse animation drives the 2D drop), and flips the lens hint on that transition. Still no *observable* grace-period state for the plug-in, so `is_tracking` flips at raw `USER_LOST`, earlier than R-T4 prefers ❌ doc/API ask. | | R-T5 MANUAL | ❌ | No stand-down toggle. MANAGED-only (acceptable for v1 per contract). | | R-T6 threading | ✅ | `srEyeTrackerPredict` documented safe from any thread incl. render thread; callbacks on SDK thread. | -| R-D1 display enumeration | ◐ | `srCreateDisplay({window:0})` + `GetPhysicalSize(cm)/GetPhysicalResolution/GetLocation/GetRecommendedTextureSize/GetDefaultViewingPosition(mm)/GetIdentifier` map 1:1 ✅, readable pre-weaver ✅; **no refresh-rate getter** ❌ (weaver knows it internally for `SetLatencyInFrames`); `GetRecommendedTextureSize` = **per-view, settled**: it wraps the same legacy `getRecommendedViewsTextureWidth/Height` the Windows arm already consumes and logs as "per eye". | +| R-D1 display enumeration | ◐ | `srCreateDisplay({window:0})` + `GetPhysicalSize(cm)/GetPhysicalResolution/GetLocation/GetRecommendedTextureSize/GetDefaultViewingPosition(mm)/GetIdentifier` map 1:1 ✅, readable pre-weaver ✅; **no refresh-rate getter** ❌ (weaver knows it internally for `SetLatencyInFrames`) — **answered by the 876620d62 line:** `srDisplayGetRefreshRate`, used when present (§8a); `GetRecommendedTextureSize` = **per-view, settled**: it wraps the same legacy `getRecommendedViewsTextureWidth/Height` the Windows arm already consumes and logs as "per eye". | | R-D2 2D/3D switch | ✅ | `sr_lens.h`: `srLensEnable/Disable/IsEnabled` (+ `lensPreference` at instance create, `SR_EVENT_TYPE_LENS_ON/OFF` events). | | R-D3 calibration internal | ✅ | No interlace parameters exposed anywhere. | | R-D4 headless-tolerant | ✅ | `srCreateDisplay` succeeds with no panel; getters return `SR_ERROR_DISPLAY_NOT_FOUND`; `srDisplayIsValid` is the probe primitive. | | §7 packaging | ✅ | C API ✅; `find_package(srSDK CONFIG)` package, relocatable ✅; loader-stub dlopen model ✅ (static `libsrSDK_loader.a` → `libLeiaSR_runtime.so`, search order: `/etc/leia/sr/1/active_runtime.json` → `SR_RUNTIME_PATH` → plain dlopen — and the `leiasr-runtime` .deb now registers `active_runtime.json` itself). **The .deb *is* the dev package** (headers + `libsrSDK_loader.a` + `lib/cmake/srSDK/` under `/opt/leiasr`); no separate SDK download. Support matrix (26.04/Mesa ANV): headless build+run confirmed, **fullscreen on-panel weave validated 2026-09-20**, windowed weave pending (→ Q1). | +### 8a. Multi-screen calls (LeiaSR Linux line 876620d62, phases A-C) + +The SR side of the DisplayXR multi-screen plan (runtime `docs/roadmap/multi-screen.md`, +SR-P0/P1/P2). Each row is compiled only when the SDK headers (and, for calls, the loader +archive) carry it, and used only when the **installed** runtime reports it. The installed +1.38 `.deb` has none of them: every row compiles out against `/opt/leiasr`, and a +new-header build running on a 1.38 runtime falls back at run time. + +| Call / struct | Plug-in use | Build gate | Run-time gate / fallback | +|---|---|---|---| +| `srEnumerateDisplays` (slot 97) + `SrDisplayDescriptor` (25) | `probe_displays` claims (multi-screen M0): FPC confidence, serial, `displayId` per monitor, into the plug-in-private claim table. `leia_lnx_sr_active_display_id()` = the first FPC-verified entry. | `DXR_LEIA_LNX_HAVE_SR_DISPLAY_ENUM` (compile + link) | `SR_ERROR_FUNCTION_UNSUPPORTED` → SR 1.38 evidence (lens serial). Never creates a context. | +| `srGetRuntimeCapabilities` + `SrWeaverRoutingCapabilities` (24) + `SrDisplayBindingCapabilities` (26) on `pNext` | Once per context: decides whether routing and binding are chained. Logged as `SR multi-display caps: …`. | `DXR_LEIA_LNX_HAVE_SR_ROUTING` (type-only compile check) | A runtime that predates the structs leaves them "not supported", so nothing is chained. | +| `SrWeaverRoutingInfo{EXTERNAL, flags 0}` (22) on `SrWeaverCreateInfoVulkan.pNext` | Every weaver (multi-screen M4): no `canWeave` gate, one full-input region, phase = present origin + viewport only, no lens vote, no polling. Because the weaver no longer votes, the plug-in turns the lens on at creation and off after the last one is destroyed (`leia_lens_owner_linux.h`). No `KEEP_DRAG_SNAP`: the weaver is windowless. | `DXR_LEIA_LNX_HAVE_SR_ROUTING` | `externalRouting`; a refused create retries SDK-routed (one INFO line). Opt-out: `DXR_LEIA_SR_EXTERNAL_ROUTING=0`. SR's `SR_WEAVER_ROUTING` env overrides either way. | +| `SrDisplayBindingInfo{displayId}` (23) on `SrWeaverCreateInfoVulkan.pNext` | A segment DP's weaver, bound to its screen's SR display (the runtime binding's `display_id`, else the claim table's). | `DXR_LEIA_LNX_HAVE_SR_ROUTING` | `displayBinding && maxBoundDisplays >= 1` and a non-zero id. `SR_ERROR_DEVICE_NOT_AVAILABLE` (EDID-only) or `SR_ERROR_DISPLAY_NOT_FOUND` (unknown id) → retry unbound (active display), one INFO line. | +| `SrDisplayBindingInfo` on `SrDisplayCreateInfo` / `SrEyeTrackerCreateInfo` / `SrLensCreateInfo` | **Not chained.** The context's display, tracker and lens stay process-wide and follow the active display. The tracker must exist before `srInitialize`, while display ids come from enumeration on an initialised context. With `maxBoundDisplays = 1` the only bindable display IS the active one. Two Leia panels per process wait on LeiaSR phase D. | — | — | +| `srDisplayGetRefreshRate` (slot 98) | `refresh_mhz` in the display query (leia-plugin #184), replacing the 60 Hz constant. | `DXR_LEIA_LNX_HAVE_SR_REFRESH` (compile + link) | Failure or an implausible value keeps 60 Hz (one INFO line). | +| `SrWeaverCreateInfoVulkan.window` | **Always 0** since M4 (R-W3 / ADR-033): the runtime's `set_present_origin` → `srWeaverSetPresentOrigin` supplies the phase origin. An X11 id made the SDK refuse a resampled panel (2D under scaled XWayland). | — | — | + ## 9. Open questions for ratification Post-reconciliation status per question — answered ones kept for the record: diff --git a/docs/linux-track-b-runbook.md b/docs/linux-track-b-runbook.md index a090025..8f63cf4 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -276,6 +276,109 @@ Today's runtime builds no monitor list on Linux (`os_display_edid` is a stub the nothing calls `probe_displays` on this platform until the runtime half of M0 lands. `test_display_claims_linux` covers the parser and the matching without hardware. +### One DP per screen, windowless EXTERNAL weaver (multi-screen M4) + +M4's goal on this box: one window straddling the laptop panel (eDP-1, sim_display, +anaglyph) and the DS1 (HDMI-A-1, leia-sr) is woven on the DS1 half by the Leia DP, with +DS1 eye tracking, and anaglyph on the other half. The runtime splits the window into +per-screen **segments** and creates one DP per screen through the appended plug-in slot +`create_dp_vk_for_screen` (runtime M2, #1853). The plug-in side: + +**The weaver is always windowless (`window = 0`).** `leiasr_lnx_create` no longer forwards +the X11 window to `SrWeaverCreateInfoVulkan.window`, on every path (plain factory too). The +runtime supplies the phase origin per frame (`set_present_origin` → +`srWeaverSetPresentOrigin`, ADR-033), which is all the Linux SDK ever used a window for (its +`getScreenRect` is (0,0), contract R-W3). With an X11 id the SDK refuses a resampled panel, +which forced 2D under the fractionally scaled XWayland desktop this box runs. A weaver +*constructed* windowless always weaves (§3, "Native Wayland weaves windowless"). The weave +log line now always reads `window=0x0 = windowless`. If an X11 window was handed in, one +INFO line says it was not forwarded. + +**What a DP does with a screen binding.** `create_dp_vk_for_screen` hands the DP an +`xrt_screen_binding` (monitor id, desktop rect, native px, EDID mm, serial, vendor display +id). The DP resolves it **once, at creation**, into a `struct leia_lnx_screen` it owns +(`leia_screen_linux.c`), and from then on `get_display_dimensions` and +`get_display_pixel_info` answer from that and nothing else. They no longer read the +process-wide SR display or the "first panel" RandR cache, so two DPs can never answer for +each other. The resolution rules: + +| Fact | Source | +|---|---| +| desktop origin | the binding's rect, always (the runtime placed the screen) | +| panel / connector | the `probe_displays` claim's connector → the binding's device name (DRM or RandR spelling) → the panel at the binding's origin | +| pixels | binding native mode → panel EDID native → desktop size | +| "is this the SR-driven panel?" | by SR `displayId` when both sides know one; else the first panel in connector order (SR 1.38's single panel); else (no EDID list, forced probe) yes | +| size, nominal viewer, recommended view, refresh | the SR panel: SR's numbers (the same `get_display_info` reports, so system and per-screen answers agree); any other Leia panel: binding mm → EDID mm, centred viewer at SR's distance/height ratio, no recommended view | + +`get_display_info_for_monitor` (runtime M1 slot, `XR_DXR_display_info` v22) uses the same +resolution and answers only for a claimed monitor that **is** the SR-driven panel +(MANAGED eye tracking, the probe-seeded view scale). It returns `false` for any other Leia +panel, so the runtime derives EDID defaults with no eye tracking. + +A **segment DP** (one made by `create_dp_vk_for_screen`) with a sub-rect canvas confines +every write to the canvas, clamped to the target. The 2D (1×1) blit goes to the canvas +rect only, and the weave's render pass begins with `renderArea` = the canvas (viewport and +scissor already were). The whole-target post-weave alpha-gate is skipped, with one WARN, so +a transparent window spanning screens presents opaque on the Leia segment. The plain +`create_dp_vk` keeps today's behaviour exactly. `get_scanout_caps` now states the answer +explicitly: weave scope `CANVAS`, `flags = 0` (no `TOLERATES_RESAMPLE`, the weave needs +1:1 pixels), so the runtime's per-segment 1:1 gate keeps a resampled Leia segment flat. + +**The SR routing / binding chain** (LeiaSR Linux line 876620d62, phases A-C). Compiled +only when the SDK headers declare the structs (CMake `DXR_LEIA_LNX_HAVE_SR_ROUTING`, a +type-only compile check), and used only when the **installed** runtime says it honours +them. `srGetRuntimeCapabilities` is called with `SrWeaverRoutingCapabilities` → +`SrDisplayBindingCapabilities` chained, once per context, and logs +`SR multi-display caps: externalRouting=… displayBinding=… maxBoundDisplays=…`. Each weaver +create then chains: + +- `SrWeaverRoutingInfo{mode = SR_WEAVER_ROUTING_EXTERNAL, flags = 0}` when + `externalRouting`. This means no `canWeave` gate, one full-input region, phase = + present origin + viewport only, no lens vote, and no polling. `KEEP_DRAG_SNAP` is not + set: it only acts on a real window, and drag snapping is the runtime's + `snap_window_rect` → `srWeaverSnapToPhase`. +- `SrDisplayBindingInfo{displayId}` when the screen has an SR `displayId` (the binding's, + else the `probe_displays` table's), the runtime reports `displayBinding`, and + `maxBoundDisplays >= 1`. + +If the create is refused, the binding is dropped first (`SR_ERROR_DEVICE_NOT_AVAILABLE` +for an EDID-only display, `SR_ERROR_DISPLAY_NOT_FOUND` for an unknown id). If it is still +refused, the routing is dropped too. Each step logs one INFO line and ends in exactly the +pre-M4 weaver. The weaver-created INFO line names the routing (`EXTERNAL`/`SDK`) and the +display (`bound to 0x…` / `active (unbound)`). Against the installed 1.38 runtime, the +caps come back all false, and nothing is chained even from new headers. + +**Lens ownership under EXTERNAL routing.** An EXTERNAL weaver never votes the lens. The +header says the controller owns it, so "leave 3D to the weaver until something asks for 2D" +(LeiaSR #266) cannot hold while one is alive. The plug-in therefore sends +`srLensEnable` when an EXTERNAL weaver is created, unless the last request was 2D. A 3D +`request_display_mode` is always sent while one is alive. When the **last** EXTERNAL +weaver is destroyed with the lens on because of us, the plug-in sends `srLensDisable`. +That release is recorded as "nothing to re-apply", not as a 2D wish. Without it the +long-lived service process would hold an ENABLE preference forever, and SRService keeps +the lens on while any client does (§4, "Co-existing"). The rules and their test live in +`leia_lens_owner_linux.h` / `test_lens_owner_linux`. + +**Refresh** (#184): `srDisplayGetRefreshRate` when the SDK has it (CMake +`DXR_LEIA_LNX_HAVE_SR_REFRESH`, compile + link probe) and the runtime answers a plausible +value. Otherwise it stays at 60 Hz, with one INFO line saying why. The display line logs +`refresh … Hz from srDisplayGetRefreshRate` or the fallback reason. + +**Not per DP yet, by design.** The SR instance, eye tracker, lens and the process display +handle are still one per process and unbound. With **one** Leia panel that is correct: +`maxBoundDisplays` is 1, so only the FPC-verified display binds, and that is the active +display these objects already follow. The tracker also has to exist before +`srInitialize`, while display ids only come from `srEnumerateDisplays` on an initialised +context. **Two Leia panels wait on LeiaSR phase D** (a service driving more than one +device). Until then a second Leia panel's segment DP resolves as "not the SR-driven panel" +(EDID facts, no tracking), and a bound weaver for it is refused and falls back to the +active display. + +Hardware-free coverage: `test_screen_linux` (binding → screen, two instances never share +state), `test_sr_routing_linux` (the chain plan everywhere, plus the real `pNext` chains +and the caps chain against SDK headers that carry the structs), and +`test_lens_owner_linux` (the EXTERNAL lens rules). + ## 3. Real weave on the panel ```bash @@ -382,6 +485,8 @@ present (runtime#1698). | `DXR_LEIA_FORCE_PROBE=1` | Bypass the plugin probe. **Only** needed when no Leia panel is connected — a real panel auto-binds via DRM/EDID. | | `DXR_LEIA_SR_FB_SDK=1` | Hand the caller framebuffer to the SDK (its own render pass) instead of the default plug-in-owned pass + fb=0 mode. Fallback only; reach for it if Vulkan validation complains about the render pass. | | `SR_RUNTIME_PATH` | Explicit path to `libLeiaSR_runtime.so` (overrides the baked rpath / active_runtime.json). | +| `DXR_LEIA_SR_EXTERNAL_ROUTING=0` | Do not chain `SrWeaverRoutingInfo{EXTERNAL}` (multi-screen M4): weavers stay SDK-routed even on a runtime that honours routing. An on-panel A/B, not a setting. | +| `SR_WEAVER_ROUTING=external\|external,keep-drag-snap\|sdk` | **SR's own** override, read by the SR runtime at weaver creation. It wins over whatever the plug-in chained, in either direction, so it tests EXTERNAL without a plug-in rebuild. Caveat: with `sdk` forced over a chained EXTERNAL, the plug-in still takes the lens (it believes the weaver will not vote). | Formerly-open behavior questions (render-pass shape, input width semantics, recommended-texture-size units, windowless weaving, image layouts) were settled by diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 6ab5fc9..dc5d030 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -79,6 +79,12 @@ echo "==> Unit test: stereo camera source parsing (runtime ADR-043, L1)" echo "==> Unit test: EDID panel list + per-monitor claims (multi-screen M0)" "$BUILD_DIR/src/drv_leia_linux/test_display_claims_linux" +echo "==> Unit test: per-DP screen state (multi-screen M4)" +"$BUILD_DIR/src/drv_leia_linux/test_screen_linux" + +echo "==> Unit test: SR weaver routing/binding plan + chains (multi-screen M4)" +"$BUILD_DIR/src/drv_leia_linux/test_sr_routing_linux" + echo "==> Asserting single-export discipline (#496 / ADR-019)" SYMS="$(nm -D --defined-only "$SO" | awk '{print $NF}')" if [ "$SYMS" != "xrtPluginNegotiate" ]; then From 6e77c0e8f2252bf5ad38156aa875e0799c27385c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 12:08:07 -0700 Subject: [PATCH 4/9] feat(linux): legacy FPC serial from the SR service's device store; srLensGetSerialNumber demoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit srLensGetSerialNumber is a stub on the LeiaSR Linux line (always SR_ERROR_FEATURE_NOT_SUPPORTED on 1.38), so a 1.38 claim could never reach VERIFIED. The supported legacy source is the SR service's device store: /active -> Devices/, = $XDG_CACHE_HOME/leiasr, else /var/lib/leiasr/leiasr — what the SR runtime's resolveLinuxPrimaryDeviceSerial reads. srEnumerateDisplays stays the preferred source when SDK and runtime have it. - leia_lnx_sr_device_store_serial[_at] (claims module, no SDK): readlink, target must be .../Devices/ and exist (dangling = no active device), serial [A-Za-z0-9._-]. Unit-tested against a fake tree under a temp dir, incl. the XDG_CACHE_HOME override. - leiasr_lnx_peek_fpc_serial (sdk backend): device store first, then srLensGetSerialNumber as a last resort; source logged once at INFO, a miss once at INFO (was a WARN from the per-call-site LOG_SR_ONCE). - VERIFIED still requires exactly one Leia panel (the serial is system-wide). - Runbook: the 1.38 claim rule, and why the plug-in must be built with the runtime's build type (os_mutex NDEBUG fields shift vk_bundle by 16 bytes: 2176/784 vs 2192/800, #1243). Co-Authored-By: Claude Opus 5.5 --- docs/linux-track-b-runbook.md | 24 +++- .../leia_display_claims_linux.c | 101 +++++++++++++++ .../leia_display_claims_linux.h | 38 ++++++ src/drv_leia_linux/leia_plugin_linux.c | 5 +- src/drv_leia_linux/leia_sr_linux.h | 15 ++- src/drv_leia_linux/leia_sr_linux_sdk.c | 39 +++++- .../tests/test_display_claims_linux.c | 118 +++++++++++++++++- 7 files changed, 327 insertions(+), 13 deletions(-) diff --git a/docs/linux-track-b-runbook.md b/docs/linux-track-b-runbook.md index 8f63cf4..b80ef69 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -242,9 +242,17 @@ identity, never by name). The runtime's monitors are matched against that list - **By origin + size**, else by an unambiguous size match, when it does not (the runtime's XWayland path, #251). An eDP that shares the panel's resolution is never claimed. - **Confidence, SR 1.38** (the installed `.deb`): `EDID` (50), or `VERIFIED` (100) with - `serial` = the lens's FPC serial (`srLensGetSerialNumber`) when a live SR context exists - **and exactly one** Leia panel is connected. That serial is system-global, so with two - panels it cannot be attributed and both stay at `EDID`. + `serial` = the SR service's active device. That serial is read from the service's device + store: `/active` is a symlink to `Devices/`, with `` = + `$XDG_CACHE_HOME/leiasr`, else `/var/lib/leiasr/leiasr` (the service runs with + `XDG_CACHE_HOME=/var/lib/leiasr`). This is the file the SR runtime's own + `resolveLinuxPrimaryDeviceSerial` reads. A dangling link means no active device. The + monitor is `VERIFIED` only when the service has an active device **and exactly one** + Leia panel is connected. The serial is system-wide, so with two panels it cannot be + attributed and both stay at `EDID`. **`srLensGetSerialNumber` is a stub on the Linux + line** (`SR_ERROR_FEATURE_NOT_SUPPORTED` on 1.38). It is kept only as a last resort, + and on 1.38 `VERIFIED` comes from the device store, never from that call. The source is + logged once at INFO (`FPC serial '…' from the SR device store (…)`), and so is a miss. - **Confidence, new SR API** (`srEnumerateDisplays`, LeiaSR Linux line 876620d62+): `VERIFIED` + FPC serial when SR reports the display `FPC_VERIFIED`, joined by DRM connector, then EDID ids + serial. SR's list is authoritative, so a monitor SR lists but @@ -359,6 +367,16 @@ long-lived service process would hold an ENABLE preference forever, and SRServic the lens on while any client does (§4, "Co-existing"). The rules and their test live in `leia_lens_owner_linux.h` / `test_lens_owner_linux`. +**Build the plug-in with the runtime's build type.** The loader compares `sizeof(struct +vk_bundle)` and its function-table offset exactly (#1243). `struct os_mutex` carries two +debug-only fields under `#ifndef NDEBUG`, and `vk_bundle` holds two of them, so a +RelWithDebInfo/Release plug-in (NDEBUG) is 16 bytes short of a Debug runtime (2176/784 vs +2192/800). The runtime then logs `vk_bundle ABI mismatch … Refusing the VK DP factory` and +no Leia code runs. `scripts/build_linux.sh` builds the runtime as Debug, so configure the +plug-in with `-DCMAKE_BUILD_TYPE=Debug` against it. To check without loading anything, dlopen +both `.so`s and read `vk_bundle_abi_size` / `vk_bundle_fn_table_offset` from the iface +`xrtPluginNegotiate` returns (negotiate does not probe or create an SR context). + **Refresh** (#184): `srDisplayGetRefreshRate` when the SDK has it (CMake `DXR_LEIA_LNX_HAVE_SR_REFRESH`, compile + link probe) and the runtime answers a plausible value. Otherwise it stays at 60 Hz, with one INFO line saying why. The display line logs diff --git a/src/drv_leia_linux/leia_display_claims_linux.c b/src/drv_leia_linux/leia_display_claims_linux.c index 4878624..6bf69d2 100644 --- a/src/drv_leia_linux/leia_display_claims_linux.c +++ b/src/drv_leia_linux/leia_display_claims_linux.c @@ -42,7 +42,10 @@ #include #include #include +#include #include +#include +#include uint16_t leia_lnx_pnp_to_manufacturer_id(const char *pnp) @@ -432,3 +435,101 @@ leia_lnx_claims_lookup(uint64_t monitor_id, struct leia_lnx_claim_binding *out) pthread_mutex_unlock(&g_bind_lock); return found; } + + +/* + * + * SR service device store (legacy FPC serial source). + * + */ + +static bool +serial_chars_ok(const char *s) +{ + if (s[0] == '\0') { + return false; + } + for (const char *c = s; *c != '\0'; c++) { + const bool ok = (*c >= 'A' && *c <= 'Z') || (*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9') || + *c == '.' || *c == '_' || *c == '-'; + if (!ok) { + return false; + } + } + return strcmp(s, ".") != 0 && strcmp(s, "..") != 0; +} + +bool +leia_lnx_sr_device_store_serial_at(const char *active_link, char *out, size_t cap) +{ + if (out == NULL || cap == 0) { + return false; + } + out[0] = '\0'; + if (active_link == NULL || active_link[0] == '\0') { + return false; + } + + char target[512]; + const ssize_t n = readlink(active_link, target, sizeof(target) - 1); + if (n <= 0 || (size_t)n >= sizeof(target) - 1) { + return false; // not a symlink, absent, or implausibly long + } + target[n] = '\0'; + // Trailing slashes do not change what it points at. + size_t len = (size_t)n; + while (len > 1 && target[len - 1] == '/') { + target[--len] = '\0'; + } + + // Must be ".../Devices/" (relative "Devices/" in practice). + const char *base = strrchr(target, '/'); + if (base == NULL) { + return false; + } + const char *serial = base + 1; + const size_t dir_len = (size_t)(base - target); + static const char kDevices[] = "Devices"; + const size_t kd = sizeof(kDevices) - 1; + if (dir_len < kd || strncmp(base - kd, kDevices, kd) != 0 || (dir_len > kd && base[-(long)kd - 1] != '/')) { + return false; + } + if (!serial_chars_ok(serial) || strlen(serial) >= cap) { + return false; + } + + // The target must exist (stat follows the link): a dangling link means + // the service has no active device. + struct stat st; + if (stat(active_link, &st) != 0 || !S_ISDIR(st.st_mode)) { + return false; + } + snprintf(out, cap, "%s", serial); + return true; +} + +bool +leia_lnx_sr_device_store_serial(char *out, size_t cap, char *out_source, size_t source_cap) +{ + if (out_source != NULL && source_cap > 0) { + out_source[0] = '\0'; + } + const char *xdg = getenv("XDG_CACHE_HOME"); + if (xdg != NULL && xdg[0] == '/') { + char link[512]; + const int w = snprintf(link, sizeof(link), "%s/leiasr/active", xdg); + if (w > 0 && (size_t)w < sizeof(link) && leia_lnx_sr_device_store_serial_at(link, out, cap)) { + if (out_source != NULL && source_cap > 0) { + snprintf(out_source, source_cap, "%s", link); + } + return true; + } + } + if (leia_lnx_sr_device_store_serial_at(LEIA_LNX_SR_DEVICE_STORE_ACTIVE, out, cap)) { + if (out_source != NULL && source_cap > 0) { + snprintf(out_source, source_cap, "%s", LEIA_LNX_SR_DEVICE_STORE_ACTIVE); + } + return true; + } + return false; +} diff --git a/src/drv_leia_linux/leia_display_claims_linux.h b/src/drv_leia_linux/leia_display_claims_linux.h index 1a39b95..adc923b 100644 --- a/src/drv_leia_linux/leia_display_claims_linux.h +++ b/src/drv_leia_linux/leia_display_claims_linux.h @@ -146,6 +146,44 @@ leia_lnx_claims_store(const struct leia_lnx_claim_binding *bindings, uint32_t co bool leia_lnx_claims_lookup(uint64_t monitor_id, struct leia_lnx_claim_binding *out); +/* + * SR service device store (the legacy FPC-serial source). + * + * On LeiaSR runtimes without srEnumerateDisplays (the 1.38 line), the FPC + * serial of the device the SR service drives is NOT available through the API + * (srLensGetSerialNumber is a stub on Linux: SR_ERROR_FEATURE_NOT_SUPPORTED). + * The service records it in its device store instead: `/active` is a + * symlink to `Devices/`, exactly what the SR runtime's own + * resolveLinuxPrimaryDeviceSerial reads. = $XDG_CACHE_HOME/leiasr — the + * service runs with XDG_CACHE_HOME=/var/lib/leiasr under systemd, so on a box + * with the .deb it is /var/lib/leiasr/leiasr. The serial is system-wide, not + * tied to a panel: the claim logic VERIFIES a monitor with it only when + * exactly one Leia panel is connected (leia_lnx_compute_claims). + */ + +//! Default store link when $XDG_CACHE_HOME does not lead to one. +#define LEIA_LNX_SR_DEVICE_STORE_ACTIVE "/var/lib/leiasr/leiasr/active" + +/*! + * Read one device-store `active` link: readlink, require the target to be + * `Devices/` (relative, or absolute ending so) and to exist as a + * directory (a dangling link = no active device), and return the basename. + * The serial must be 1..cap-1 chars of [A-Za-z0-9._-]. + * @return true and the serial in @p out, else false and "" in @p out. + */ +bool +leia_lnx_sr_device_store_serial_at(const char *active_link, char *out, size_t cap); + +/*! + * The active device's FPC serial from the SR service's device store: + * `$XDG_CACHE_HOME/leiasr/active` when XDG_CACHE_HOME is set and that link + * resolves, else LEIA_LNX_SR_DEVICE_STORE_ACTIVE. @p out_source (may be NULL) + * receives the link path that answered. Cheap (one or two readlink + stat), + * no SR call, no SR context. + */ +bool +leia_lnx_sr_device_store_serial(char *out, size_t cap, char *out_source, size_t source_cap); + #ifdef __cplusplus } #endif diff --git a/src/drv_leia_linux/leia_plugin_linux.c b/src/drv_leia_linux/leia_plugin_linux.c index 58dd567..61fd011 100644 --- a/src/drv_leia_linux/leia_plugin_linux.c +++ b/src/drv_leia_linux/leia_plugin_linux.c @@ -231,8 +231,9 @@ leia_lnx_plugin_probe_displays(struct xrt_plugin_instance *inst, * - every Leia panel on this box (one /sys/class/drm pass + RandR join); * - the new SR API's display list when it is compiled in AND a live SR * context exists (FPC confidence, serial, displayId); - * - otherwise SR 1.38's only per-device fact: the live context's lens - * serial (verifies a single panel). + * - otherwise the SR service's active device serial (its device store, + * /active -> Devices/; srLensGetSerialNumber as a last + * resort) — system-wide, so it verifies a single panel only. * Never creates an SR context (seam header explains why) — on a box where * probe() did not bring one up, claims stay at EDID confidence. */ diff --git a/src/drv_leia_linux/leia_sr_linux.h b/src/drv_leia_linux/leia_sr_linux.h index b3129c3..0df8e79 100644 --- a/src/drv_leia_linux/leia_sr_linux.h +++ b/src/drv_leia_linux/leia_sr_linux.h @@ -540,10 +540,17 @@ leiasr_lnx_get_hardware_3d_state(struct leiasr_lnx *lnx, bool *out_is_3d); struct leia_lnx_sr_display; // leia_display_claims_linux.h /*! - * FPC serial of the device the LIVE SR context drives (srLensGetSerialNumber, - * slot 48, present on SR 1.38). Never creates a context; never touches the - * lens preference (a serial read is not an enable/disable, LeiaSR #266). - * @return false when there is no live context, no lens, or no serial. + * FPC serial of the device the SR service drives, for runtimes WITHOUT + * srEnumerateDisplays (which stays the preferred source). In order: + * 1. the SR service's device store, `/active -> Devices/` + * ($XDG_CACHE_HOME/leiasr, else /var/lib/leiasr/leiasr) — what the SR + * runtime's own resolveLinuxPrimaryDeviceSerial reads; no context needed; + * 2. srLensGetSerialNumber (slot 48) on the live context — a stub on the + * Linux line so far (SR_ERROR_FEATURE_NOT_SUPPORTED on 1.38). + * The source is logged once at INFO, a miss once at INFO. Never creates a + * context; never touches the lens preference (LeiaSR #266). The serial is + * system-wide: the claim logic uses it only with exactly one Leia panel. + * The stub backend returns false (no SR service behind it). */ bool leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap); diff --git a/src/drv_leia_linux/leia_sr_linux_sdk.c b/src/drv_leia_linux/leia_sr_linux_sdk.c index 3af53de..3d148d8 100644 --- a/src/drv_leia_linux/leia_sr_linux_sdk.c +++ b/src/drv_leia_linux/leia_sr_linux_sdk.c @@ -2329,17 +2329,50 @@ leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap) return false; } out_serial[0] = '\0'; + + /* 1. The SR service's device store (/active -> Devices/), + * what the SR runtime's own resolveLinuxPrimaryDeviceSerial reads. The + * supported legacy source: no SR call, no context needed. */ + char source[512]; + if (leia_lnx_sr_device_store_serial(out_serial, cap, source, sizeof(source))) { + static char logged_serial[64]; + if (strncmp(logged_serial, out_serial, sizeof(logged_serial)) != 0) { + snprintf(logged_serial, sizeof(logged_serial), "%s", out_serial); + U_LOG_I("leia_sr_sdk: FPC serial '%s' from the SR device store (%s)", out_serial, source); + } + return true; + } + + /* 2. srLensGetSerialNumber (slot 48). A stub on the Linux line so far + * (SR_ERROR_FEATURE_NOT_SUPPORTED on 1.38) — kept as a last resort for + * a runtime that implements it; its failure is an INFO, once. */ bool ok = false; pthread_mutex_lock(&g_ctx_lock); if (g_ctx.state == SR_CTX_READY && !atomic_load(&g_ctx.context_invalid) && g_ctx.lens != NULL) { uint32_t written = 0; const SrResult res = srLensGetSerialNumber(g_ctx.lens, out_serial, (uint32_t)cap, &written); - if (SR_SUCCEEDED(res)) { - ok = out_serial[0] != '\0'; + if (SR_SUCCEEDED(res) && out_serial[0] != '\0') { + ok = true; + static bool lens_logged; + if (!lens_logged) { + lens_logged = true; + U_LOG_I("leia_sr_sdk: FPC serial '%s' from srLensGetSerialNumber", out_serial); + } } else { - LOG_SR_ONCE("srLensGetSerialNumber", res); out_serial[0] = '\0'; } + if (!ok) { + static bool fail_logged; + if (!fail_logged) { + fail_logged = true; + U_LOG_I( + "leia_sr_sdk: no FPC serial: SR device store has no active device (%s or " + "$XDG_CACHE_HOME/leiasr/active) and srLensGetSerialNumber %s — claims stay at EDID " + "confidence", + LEIA_LNX_SR_DEVICE_STORE_ACTIVE, + SR_FAILED(res) ? srResultToString(res) : "returned an empty serial"); + } + } } pthread_mutex_unlock(&g_ctx_lock); return ok; diff --git a/src/drv_leia_linux/tests/test_display_claims_linux.c b/src/drv_leia_linux/tests/test_display_claims_linux.c index b117e21..b87ec1b 100644 --- a/src/drv_leia_linux/tests/test_display_claims_linux.c +++ b/src/drv_leia_linux/tests/test_display_claims_linux.c @@ -18,6 +18,8 @@ #include #include #include +#include +#include static int g_failures; @@ -165,7 +167,7 @@ test_claims_edid_ids(void) CHECK(c[0].serial[0] == '\0'); CHECK(strcmp(b[0].connector, "HDMI-A-1") == 0 && b[0].sr_display_id == 0); - // 1.38 with a live context's lens serial: one panel -> VERIFIED + serial. + // 1.38 with the legacy serial (SR device store): one panel -> VERIFIED + serial. in.legacy_fpc_serial = "QI012321D10117"; n = leia_lnx_compute_claims(d, 2, &in, c, b, 4); CHECK(n == 1 && c[0].confidence == XRT_DISPLAY_CLAIM_VERIFIED); @@ -424,6 +426,119 @@ test_binding_table(void) CHECK(!leia_lnx_claims_lookup(1, &out)); } +/* SR service device store: /active -> Devices/, read with a + * fake tree under a temp dir (the shape /var/lib/leiasr/leiasr has on the + * DS1 box: active -> Devices/QI012321D10117). */ +static void +mkdir_p2(const char *a, const char *b) +{ + char p[512]; + snprintf(p, sizeof(p), "%s/%s", a, b); + (void)mkdir(p, 0755); +} + +static void +link_at(const char *dir, const char *name, const char *target) +{ + char p[512]; + snprintf(p, sizeof(p), "%s/%s", dir, name); + (void)unlink(p); + CHECK(symlink(target, p) == 0); +} + +static void +test_device_store(void) +{ + char root[] = "/tmp/leia_devstore_XXXXXX"; + CHECK(mkdtemp(root) != NULL); + char store[256], link[320], serial[64]; + snprintf(store, sizeof(store), "%s/leiasr", root); + (void)mkdir(store, 0755); + mkdir_p2(store, "Devices"); + mkdir_p2(store, "Devices/QI012321D10117"); + mkdir_p2(store, "Other"); + mkdir_p2(store, "Other/QI000"); + snprintf(link, sizeof(link), "%s/active", store); + + // The real shape: relative Devices/. + link_at(store, "active", "Devices/QI012321D10117"); + CHECK(leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + CHECK(strcmp(serial, "QI012321D10117") == 0); + + // Absolute target and a trailing slash are fine. + char abs_target[320]; + snprintf(abs_target, sizeof(abs_target), "%s/Devices/QI012321D10117/", store); + link_at(store, "active", abs_target); + CHECK(leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + CHECK(strcmp(serial, "QI012321D10117") == 0); + + // Dangling link: the service has no active device. + link_at(store, "active", "Devices/QI999"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + CHECK(serial[0] == '\0'); + + // Not under Devices/, a bare name, "Devices" itself, suffix-only match. + link_at(store, "active", "Other/QI000"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + link_at(store, "active", "Devices"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + mkdir_p2(store, "NotDevices"); + mkdir_p2(store, "NotDevices/QI1"); + link_at(store, "active", "NotDevices/QI1"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + + // Implausible serial characters. + mkdir_p2(store, "Devices/bad serial"); + link_at(store, "active", "Devices/bad serial"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + + // Too small an output buffer. + link_at(store, "active", "Devices/QI012321D10117"); + char tiny[8]; + CHECK(!leia_lnx_sr_device_store_serial_at(link, tiny, sizeof(tiny))); + + // A plain directory named "active" is not the service's link. + (void)unlink(link); + mkdir_p2(store, "active"); + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + (void)rmdir(link); + + // Absent link / NULL. + CHECK(!leia_lnx_sr_device_store_serial_at(link, serial, sizeof(serial))); + CHECK(!leia_lnx_sr_device_store_serial_at(NULL, serial, sizeof(serial))); + + // Resolver: $XDG_CACHE_HOME/leiasr/active wins when it resolves. + link_at(store, "active", "Devices/QI012321D10117"); + const char *old_xdg = getenv("XDG_CACHE_HOME"); + char saved[512] = {0}; + if (old_xdg != NULL) { + snprintf(saved, sizeof(saved), "%s", old_xdg); + } + setenv("XDG_CACHE_HOME", root, 1); + char source[512]; + CHECK(leia_lnx_sr_device_store_serial(serial, sizeof(serial), source, sizeof(source))); + CHECK(strcmp(serial, "QI012321D10117") == 0); + CHECK(strcmp(source, link) == 0); + if (old_xdg != NULL) { + setenv("XDG_CACHE_HOME", saved, 1); + } else { + unsetenv("XDG_CACHE_HOME"); + } + + // Cleanup (best effort). + char p[600]; + (void)unlink(link); + const char *dirs[] = { + "Devices/bad serial", "Devices/QI012321D10117", "Devices", "Other/QI000", "Other", "NotDevices/QI1", + "NotDevices"}; + for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + snprintf(p, sizeof(p), "%s/%s", store, dirs[i]); + (void)rmdir(p); + } + (void)rmdir(store); + (void)rmdir(root); +} + int main(void) { @@ -436,6 +551,7 @@ main(void) test_fallback_claim(); test_claims_stride(); test_binding_table(); + test_device_store(); if (g_failures != 0) { fprintf(stderr, "test_display_claims_linux: %d failure(s)\n", g_failures); return EXIT_FAILURE; From f45fcf5fc74c7701c47f81cc0590c3b712baaf8f Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 13:57:43 -0700 Subject: [PATCH 5/9] fix(linux): a screen's panel is found by the binding's device name first (review B) find_panel() preferred the probe_displays claim's connector over the runtime binding's device_name. The claim table pairs identical twins by their own rules, while the binding names the output the runtime resolved for THIS monitor, so the binding now wins; the claim's connector and the origin are the fallbacks. test_screen_linux covers the disagreeing case. (Also: the leia_lnx_edid_panels_cached doc now matches the TTL'd shared cache from the #311 review.) Co-Authored-By: Claude Opus 5.5 --- src/drv_leia_linux/leia_edid_probe_linux.h | 7 +++---- src/drv_leia_linux/leia_screen_linux.c | 20 +++++++++++--------- src/drv_leia_linux/leia_screen_linux.h | 4 ++-- src/drv_leia_linux/tests/test_screen_linux.c | 7 +++++++ 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/drv_leia_linux/leia_edid_probe_linux.h b/src/drv_leia_linux/leia_edid_probe_linux.h index 9debe2f..4b9302b 100644 --- a/src/drv_leia_linux/leia_edid_probe_linux.h +++ b/src/drv_leia_linux/leia_edid_probe_linux.h @@ -112,10 +112,9 @@ bool leia_lnx_edid_panel_desktop_position_cached(const char *connector, int32_t *out_left, int32_t *out_top); /*! - * The panel list the position cache resolved (one enumerate-with-positions - * pass per process, shared with leia_lnx_edid_panel_desktop_position_cached). - * Multi-screen M4: what a per-screen DP resolves its screen against, without - * re-scanning sysfs or reconnecting to X per DP. Thread-safe. + * The shared panel cache as it is now (no rescan of its own: probe_displays' + * TTL and SR topology events keep it fresh). What a per-screen DP resolves + * its screen against (multi-screen M4). Thread-safe. * @return entries copied to @p out (<= @p cap). */ uint32_t diff --git a/src/drv_leia_linux/leia_screen_linux.c b/src/drv_leia_linux/leia_screen_linux.c index 5fe2e8b..fb83cd7 100644 --- a/src/drv_leia_linux/leia_screen_linux.c +++ b/src/drv_leia_linux/leia_screen_linux.c @@ -26,16 +26,10 @@ find_panel(const struct leia_lnx_screen_binding *b, if (panels == NULL || panel_count == 0) { return NULL; } - // 1. The claim knows the DRM connector it matched at probe_displays. - if (claim != NULL && claim->connector[0] != '\0') { - for (uint32_t i = 0; i < panel_count; i++) { - if (strcmp(panels[i].connector, claim->connector) == 0) { - return &panels[i]; - } - } - } - // 2. The binding's OS output name: a DRM connector, or (X11) a RandR + // 1. The binding's OS output name: a DRM connector, or (X11) a RandR // output — matched against both spellings the panel list carries. + // The runtime resolved it for THIS monitor, so it outranks the claim + // table (review B: twins paired by list order could swap connectors). if (b->device_name[0] != '\0') { for (uint32_t i = 0; i < panel_count; i++) { if (strcmp(panels[i].connector, b->device_name) == 0 || @@ -45,6 +39,14 @@ find_panel(const struct leia_lnx_screen_binding *b, } } } + // 2. The claim's DRM connector from probe_displays. + if (claim != NULL && claim->connector[0] != '\0') { + for (uint32_t i = 0; i < panel_count; i++) { + if (strcmp(panels[i].connector, claim->connector) == 0) { + return &panels[i]; + } + } + } // 3. The panel sitting at the binding's desktop origin. for (uint32_t i = 0; i < panel_count; i++) { if (panels[i].has_position && panels[i].left == b->desktop_left && panels[i].top == b->desktop_top) { diff --git a/src/drv_leia_linux/leia_screen_linux.h b/src/drv_leia_linux/leia_screen_linux.h index 18f4dea..3b3c8eb 100644 --- a/src/drv_leia_linux/leia_screen_linux.h +++ b/src/drv_leia_linux/leia_screen_linux.h @@ -89,8 +89,8 @@ struct leia_lnx_screen * * Rules (each documented at its line in leia_screen_linux.c): * - origin: always the binding's desktop rect (the runtime placed the screen); - * - panel: the claim's connector, else the binding's device name (DRM - * connector or RandR output), else the panel at the binding's origin; + * - panel: the binding's device name (DRM connector or RandR output), + * else the claim's connector, else the panel at the binding's origin; * - pixels: binding native mode, else the panel's EDID native, else the desktop size; * - SR panel: by displayId when both ids are known, else "the first panel" * (connector order — the one SR 1.38 drives on a one-panel box), else diff --git a/src/drv_leia_linux/tests/test_screen_linux.c b/src/drv_leia_linux/tests/test_screen_linux.c index 159cc71..8a427b5 100644 --- a/src/drv_leia_linux/tests/test_screen_linux.c +++ b/src/drv_leia_linux/tests/test_screen_linux.c @@ -150,6 +150,13 @@ test_claim_connector_and_display_id(void) CHECK(s.is_sr_panel); // by id CHECK(s.screen_left == 9999); // origin is the binding's, always + // The binding's device name outranks the claim's connector (review B): + // a claim made by list order could name the twin. + snprintf(b.device_name, sizeof(b.device_name), "DP-2"); + leia_lnx_screen_resolve(&b, &claim, panels, 2, &sr, DS1_SR_ID, &s); + CHECK(strcmp(s.connector, "DP-2") == 0); + snprintf(b.device_name, sizeof(b.device_name), "%s", ""); + // The runtime's opaque display_id wins over the claim table. b.display_id = OTHER_SR_ID; leia_lnx_screen_resolve(&b, &claim, panels, 2, &sr, DS1_SR_ID, &s); From 7aa02b276bc56ea9d9abb8d4b1ac9d7d705b379c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 13:58:20 -0700 Subject: [PATCH 6/9] build(linux): pin runtime v2.29.0 and Depend on displayxr-runtime (>= 2.27.1) (review 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windowless weaver (window = 0 on every path) drops the LeiaSR 1.38 SDK's own refusal to weave into a resampled X11 window. The runtime only took that refusal over in v2.27.1 (#1831, X11 on a fractionally scaled panel presents 2D), but the Linux pin was v2.27.0 and the .deb's Depends unversioned — so a scaled-desktop X11 app on an older runtime would weave a double image. - DXR_RUNTIME_GIT_TAG_LINUX / build-linux.yml RUNTIME_REF: v2.27.0 -> v2.29.0 (the current tag; vk_bundle, os_mutex and xrt_plugin.h are unchanged in between, so the loader's layout check is unaffected). - package_deb_leia.sh: Depends: displayxr-runtime (>= 2.27.1); the package description and docs/linux-deb-packaging.md say why. - Runbook: the runtime owns the resampled refusal from 2.27.1, which is why the weaver is windowless — and why the X11 window must not come back (on a scaled XWayland desktop it forces 2D even with an explicit origin, the M4 straddling case). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-linux.yml | 6 ++++-- CMakeLists.txt | 10 +++++++++- docs/linux-deb-packaging.md | 2 +- docs/linux-track-b-runbook.md | 12 +++++++++++- scripts/package_deb_leia.sh | 8 ++++++-- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7a9736d..c0762b7 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -69,8 +69,10 @@ env: # vk_bundle ABI fingerprint fields; no feature macro, an older ref fails to # compile). v2.17.1: first line with the rear-depth-budget preview # (XRT_DP_VK_HAS_BACKGROUND_PREVIEW), and a vk_bundle layout that matches - # runtimes v2.16.0..main — see the CMakeLists comment. - RUNTIME_REF: v2.27.0 + # runtimes v2.16.0..main — see the CMakeLists comment. v2.29.0: past + # v2.27.1, which owns the resampled-X11 refusal the windowless weaver + # (multi-screen M4) no longer gets from the SR SDK. + RUNTIME_REF: v2.29.0 jobs: # Classify the change. Same rule as displayxr-runtime's build-linux.yml: diff --git a/CMakeLists.txt b/CMakeLists.txt index a0cad0c..f17f88c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,7 +212,15 @@ set(DXR_RUNTIME_GIT_TAG "v2.27.0" CACHE STRING # Not in v2.17.1: the VK snap_window_rect slot (runtime#1592, # XRT_DP_VK_HAS_SNAP_WINDOW_RECT), so drag phase-snap compiles out at this pin # (expected; it compiles in automatically at the first pin that carries it). -set(DXR_RUNTIME_GIT_TAG_LINUX "v2.27.0" CACHE STRING +# v2.29.0 (from v2.27.0), multi-screen M4 review: the Vulkan weaver is now +# windowless on every path (window = 0), which drops the LeiaSR SDK's own +# refusal to weave into a resampled X11 window. The runtime took that refusal +# over in v2.27.1 (#1831: X11 on a fractionally scaled panel presents 2D), so +# the Linux pin moves past it (to the current tag), and the .deb now Depends +# on displayxr-runtime (>= 2.27.1) (scripts/package_deb_leia.sh) — an older +# runtime would weave a scaled X11 window into a double image. vk_bundle / +# os_mutex / xrt_plugin.h are unchanged v2.27.0..v2.29.0. +set(DXR_RUNTIME_GIT_TAG_LINUX "v2.29.0" CACHE STRING "Runtime git ref for Linux builds. Must equal build-linux.yml's RUNTIME_REF (rule-5 CI check enforces).") # Android carries its own pin too, and as of the get_last_frame_dropped repin it diff --git a/docs/linux-deb-packaging.md b/docs/linux-deb-packaging.md index 2a2e1d2..0c8f46a 100644 --- a/docs/linux-deb-packaging.md +++ b/docs/linux-deb-packaging.md @@ -13,7 +13,7 @@ fallback; see `displayxr-runtime/scripts/package_deb_linux.sh`). | Discovery | Drops `DisplayXR-LeiaSR.so` + `050-leia-sr.json` (probe_order **50**) into `/usr/lib/displayxr/plugins/` — the dir the runtime searches by default (runtime #781 Phase 1). No registry, no env. | | Claim vs fallback | `probe_order=50` beats sim-display's `200`. The **presence probe** (leia-plugin #99, on `main`) claims when the SR runtime is reachable and declines otherwise, so sim-display drives apps on a box without the SR stack. No `DXR_LEIA_FORCE_PROBE`. | | SR runtime discovery | **No baked rpath.** The srSDK loader resolves `libLeiaSR_runtime.so` via `/etc/leia/sr/1/active_runtime.json` (registered by the SR runtime installer) → `$SR_RUNTIME_PATH` → plain dlopen. See `docs/leia-linux-sdk-contract.md` §7. The packager strips any `DT_RUNPATH` with `patchelf` so no build-machine path ships. | -| Dependencies | `Depends: displayxr-runtime` (hard prereq) + **versioned** system-library dependencies derived with `dpkg-shlibdeps`. `Recommends:` the SR runtime package — without it the plug-in still installs and its probe declines. | +| Dependencies | `Depends: displayxr-runtime (>= 2.27.1)` (hard prereq; 2.27.1 is the first runtime that refuses to weave a resampled X11 window itself, which the windowless weaver relies on) + **versioned** system-library dependencies derived with `dpkg-shlibdeps`. `Recommends:` the SR runtime package — without it the plug-in still installs and its probe declines. | | Portability | **One package for Ubuntu 22.04, 24.04 and 26.04.** See below. | ## One package for 22.04, 24.04 and 26.04 diff --git a/docs/linux-track-b-runbook.md b/docs/linux-track-b-runbook.md index b80ef69..a5fbf36 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -299,7 +299,17 @@ runtime supplies the phase origin per frame (`set_present_origin` → `getScreenRect` is (0,0), contract R-W3). With an X11 id the SDK refuses a resampled panel, which forced 2D under the fractionally scaled XWayland desktop this box runs. A weaver *constructed* windowless always weaves (§3, "Native Wayland weaves windowless"). The weave -log line now always reads `window=0x0 = windowless`. If an X11 window was handed in, one +log line now always reads `window=0x0 = windowless`. + +**The runtime owns the resampled refusal, from 2.27.1.** That SDK refusal, not +the window, was what kept a scaled X11 window from weaving into a double image. +Since runtime v2.27.1 (#1831) the runtime itself presents 2D when an X11 window's +pixels are resampled (and gates each segment the same way, using the DP's +`get_scanout_caps`, flags 0 here). That is why the weaver can be windowless. +The Linux runtime pin is therefore v2.29.0, and the `.deb` Depends on +`displayxr-runtime (>= 2.27.1)`. Do not reintroduce the X11 window: on a scaled +XWayland desktop it forces 2D even with an explicit present origin, which is +exactly the M4 straddling case. If an X11 window was handed in, one INFO line says it was not forwarded. **What a DP does with a screen binding.** `create_dp_vk_for_screen` hands the DP an diff --git a/scripts/package_deb_leia.sh b/scripts/package_deb_leia.sh index e6baea4..f557512 100755 --- a/scripts/package_deb_leia.sh +++ b/scripts/package_deb_leia.sh @@ -295,7 +295,11 @@ printf 'Source: %s\n\nPackage: %s\nArchitecture: any\n' "$PKG" "$PKG" >"$SHLIBS_ LIB_DEPENDS="$(cd "$SHLIBS_TMP" && dpkg-shlibdeps -O "$STAGED_SO" | sed -n 's/^shlibs:Depends=//p')" rm -rf "$SHLIBS_TMP" [ -n "$LIB_DEPENDS" ] || { echo "error: dpkg-shlibdeps produced no Depends." >&2; exit 1; } -DEPENDS="displayxr-runtime, $LIB_DEPENDS" +# Versioned (multi-screen M4 review): the plug-in's Vulkan weaver is windowless, +# so the LeiaSR SDK no longer refuses a resampled X11 window itself; the runtime +# does that from v2.27.1 (#1831). On an older runtime a scaled-desktop X11 app +# would weave a double image. +DEPENDS="displayxr-runtime (>= 2.27.1), $LIB_DEPENDS" # cube-hw finding (B): the SR runtime bundles copies of common .so's under # /opt/leiasr/lib. On a box whose linker path reaches them, the owning package # comes out as the vendor SR package — which must stay a Recommends, never a @@ -343,7 +347,7 @@ Description: DisplayXR Leia SR display processor (Linux plug-in) vendor probe_order (50), so it claims the display ahead of the built-in sim-display fallback whenever the Leia SR runtime is present and reachable. . - Requires the DisplayXR runtime (Depends: displayxr-runtime). The Leia SR + Requires the DisplayXR runtime 2.27.1 or newer. The Leia SR runtime is a Recommends: without it the plug-in still installs and its probe declines, so sim-display drives apps. The plug-in resolves the SR runtime via /etc/leia/sr/1/active_runtime.json (registered by the SR runtime installer) — From 0590e2b3acd4ffceaa04d52c5e07b2f315feead1 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 13:58:36 -0700 Subject: [PATCH 7/9] fix(linux): trust the SR device store only with a live SR context (review 2) The device-store serial was read with no live SR context and no check that the device is connected. Devices/ keeps every device ever seen (here both QI012321D10117 and VD012450ET0116), so a stale `active` link or a stopped SRService plus one Leia panel gave VERIFIED with the wrong serial. leiasr_lnx_peek_fpc_serial now returns false unless the SR context is READY and valid (the service is reachable and driving a device); the claim stays at EDID confidence otherwise. Seam doc, probe_displays comment and runbook updated. Co-Authored-By: Claude Opus 5.5 --- docs/linux-track-b-runbook.md | 6 ++++-- src/drv_leia_linux/leia_plugin_linux.c | 8 +++++--- src/drv_leia_linux/leia_sr_linux.h | 9 ++++++--- src/drv_leia_linux/leia_sr_linux_sdk.c | 14 +++++++++++++- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/linux-track-b-runbook.md b/docs/linux-track-b-runbook.md index a5fbf36..42f428b 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -247,8 +247,10 @@ identity, never by name). The runtime's monitors are matched against that list `$XDG_CACHE_HOME/leiasr`, else `/var/lib/leiasr/leiasr` (the service runs with `XDG_CACHE_HOME=/var/lib/leiasr`). This is the file the SR runtime's own `resolveLinuxPrimaryDeviceSerial` reads. A dangling link means no active device. The - monitor is `VERIFIED` only when the service has an active device **and exactly one** - Leia panel is connected. The serial is system-wide, so with two panels it cannot be + store is consulted **only while a live SR context exists** (SRService reachable). + `Devices/` keeps every device ever seen, and with the service stopped `active` can name + one that is gone. The monitor is `VERIFIED` only when the service is reachable, has an + active device, **and exactly one** Leia panel is connected. The serial is system-wide, so with two panels it cannot be attributed and both stay at `EDID`. **`srLensGetSerialNumber` is a stub on the Linux line** (`SR_ERROR_FEATURE_NOT_SUPPORTED` on 1.38). It is kept only as a last resort, and on 1.38 `VERIFIED` comes from the device store, never from that call. The source is diff --git a/src/drv_leia_linux/leia_plugin_linux.c b/src/drv_leia_linux/leia_plugin_linux.c index 61fd011..b646bed 100644 --- a/src/drv_leia_linux/leia_plugin_linux.c +++ b/src/drv_leia_linux/leia_plugin_linux.c @@ -231,9 +231,11 @@ leia_lnx_plugin_probe_displays(struct xrt_plugin_instance *inst, * - every Leia panel on this box (one /sys/class/drm pass + RandR join); * - the new SR API's display list when it is compiled in AND a live SR * context exists (FPC confidence, serial, displayId); - * - otherwise the SR service's active device serial (its device store, - * /active -> Devices/; srLensGetSerialNumber as a last - * resort) — system-wide, so it verifies a single panel only. + * - otherwise, with a live SR context only, the SR service's active + * device serial (its device store, /active -> Devices/; + * srLensGetSerialNumber as a last resort) — system-wide, so it + * verifies a single panel only. Without a live context the store may + * be stale (SRService stopped): EDID confidence. * Never creates an SR context (seam header explains why) — on a box where * probe() did not bring one up, claims stay at EDID confidence. */ diff --git a/src/drv_leia_linux/leia_sr_linux.h b/src/drv_leia_linux/leia_sr_linux.h index 0df8e79..59f5d10 100644 --- a/src/drv_leia_linux/leia_sr_linux.h +++ b/src/drv_leia_linux/leia_sr_linux.h @@ -544,11 +544,14 @@ struct leia_lnx_sr_display; // leia_display_claims_linux.h * srEnumerateDisplays (which stays the preferred source). In order: * 1. the SR service's device store, `/active -> Devices/` * ($XDG_CACHE_HOME/leiasr, else /var/lib/leiasr/leiasr) — what the SR - * runtime's own resolveLinuxPrimaryDeviceSerial reads; no context needed; + * runtime's own resolveLinuxPrimaryDeviceSerial reads — consulted ONLY + * while a live SR context exists (service reachable): Devices/ keeps + * every device ever seen and `active` can be stale when SRService is + * stopped; * 2. srLensGetSerialNumber (slot 48) on the live context — a stub on the * Linux line so far (SR_ERROR_FEATURE_NOT_SUPPORTED on 1.38). - * The source is logged once at INFO, a miss once at INFO. Never creates a - * context; never touches the lens preference (LeiaSR #266). The serial is + * No live context = false. The source is logged once at INFO, a miss once + * at INFO. Never creates a context; never touches the lens preference (LeiaSR #266). The serial is * system-wide: the claim logic uses it only with exactly one Leia panel. * The stub backend returns false (no SR service behind it). */ diff --git a/src/drv_leia_linux/leia_sr_linux_sdk.c b/src/drv_leia_linux/leia_sr_linux_sdk.c index 3d148d8..e5abeb4 100644 --- a/src/drv_leia_linux/leia_sr_linux_sdk.c +++ b/src/drv_leia_linux/leia_sr_linux_sdk.c @@ -2330,9 +2330,21 @@ leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap) } out_serial[0] = '\0'; + /* Only with a live context (review 2): the store is the service's + * on-disk state, and Devices/ keeps every device ever seen. With + * SRService stopped, the `active` link can name a device that is no longer + * connected, and that stale serial must not VERIFY a monitor. A READY, + * valid context means the service is reachable and driving a device. */ + pthread_mutex_lock(&g_ctx_lock); + const bool live = g_ctx.state == SR_CTX_READY && !atomic_load(&g_ctx.context_invalid); + pthread_mutex_unlock(&g_ctx_lock); + if (!live) { + return false; + } + /* 1. The SR service's device store (/active -> Devices/), * what the SR runtime's own resolveLinuxPrimaryDeviceSerial reads. The - * supported legacy source: no SR call, no context needed. */ + * supported legacy source: no SR call. */ char source[512]; if (leia_lnx_sr_device_store_serial(out_serial, cap, source, sizeof(source))) { static char logged_serial[64]; From 5fc488608b592b0fcc64da257e6a9fc16367451a Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 13:59:12 -0700 Subject: [PATCH 8/9] fix(linux): a segment DP that cannot weave its own panel presents flat (review 3) When SR refuses the display binding (an EDID-only displayId gives SR_ERROR_DEVICE_NOT_AVAILABLE, an unknown one SR_ERROR_DISPLAY_NOT_FOUND), the weaver falls back to the ACTIVE display. A screen-bound DP for a second Leia panel that SR does not drive would then weave with the first panel's calibration and tracking, which is wrong-phase output. The backend now reports a refused binding (leiasr_lnx_display_binding_refused; the stub never binds). A segment DP whose screen is not the SR-driven panel, or whose binding was refused, is flat_only: every frame takes the flat blit (left view to the canvas), the 2D over-layer is declined, and one WARN at creation says why. Plain-factory DPs are unchanged. Runbook updated. Co-Authored-By: Claude Opus 5.5 --- docs/linux-track-b-runbook.md | 6 +++++ .../leia_display_processor_linux.c | 25 ++++++++++++++++--- src/drv_leia_linux/leia_sr_linux.h | 12 +++++++++ src/drv_leia_linux/leia_sr_linux_sdk.c | 9 +++++++ src/drv_leia_linux/leia_sr_stub.c | 7 ++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/linux-track-b-runbook.md b/docs/linux-track-b-runbook.md index 42f428b..bf81dd6 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -361,6 +361,12 @@ create then chains: else the `probe_displays` table's), the runtime reports `displayBinding`, and `maxBoundDisplays >= 1`. +A segment DP **never weaves with another panel's calibration**. When its screen is not +the SR-driven panel, or SR refused the display binding and the weaver fell back to the +active display, the DP presents **flat**: the left view is blitted to its canvas, with one +WARN `segment DP for monitor … presents FLAT (no weave): …`. Weaving would otherwise give +wrong-phase output. + If the create is refused, the binding is dropped first (`SR_ERROR_DEVICE_NOT_AVAILABLE` for an EDID-only display, `SR_ERROR_DISPLAY_NOT_FOUND` for an unknown id). If it is still refused, the routing is dropped too. Each step logs one INFO line and ends in exactly the diff --git a/src/drv_leia_linux/leia_display_processor_linux.c b/src/drv_leia_linux/leia_display_processor_linux.c index 9b560b4..b15c5f5 100644 --- a/src/drv_leia_linux/leia_display_processor_linux.c +++ b/src/drv_leia_linux/leia_display_processor_linux.c @@ -58,6 +58,12 @@ struct leia_dp_linux //! the target). False = the plain factory: today's behaviour exactly. bool screen_bound; struct leia_lnx_screen screen; + //! Multi-screen M4 (review 3): this segment DP must NOT weave — its screen + //! is not the panel the SR context drives, or SR refused to bind the + //! weaver to it and fell back to the active (other) display. Weaving + //! would apply another panel's calibration and tracking (wrong-phase + //! output), so every frame takes the flat blit path instead. + bool flat_only; bool segment_alpha_gate_logged; //!< one line when a segment skips the alpha-gate //! XR_DXR_display_zones (ADR-027). Zones need NO special weaver support on @@ -1689,7 +1695,7 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, const uint32_t vp_w = canvas_width != 0 ? canvas_width : target_width; const uint32_t vp_h = canvas_height != 0 ? canvas_height : target_height; const char *why = NULL; - if (tile_columns != 2 || tile_rows != 1 || target_image == (VkImage_XDP)0) { + if (ldp->flat_only || tile_columns != 2 || tile_rows != 1 || target_image == (VkImage_XDP)0) { why = "this frame is not a 2x1 weave"; } else if (overlay_w != vp_w || overlay_h != vp_h) { why = "layer size != weave viewport"; @@ -1707,8 +1713,10 @@ leia_lnx_dp_process_atlas(struct xrt_display_processor *xdp, } // Single-view content: bypass the weaver, blit atlas content to target - // (same convention as the Windows DP's 2D path). - if (ldp->view_count == 1 && target_image != (VkImage_XDP)0) { + // (same convention as the Windows DP's 2D path). A flat-only segment DP + // (review 3) takes this path for every frame: the blit's source is the + // first view tile, i.e. the left eye, shown flat. + if ((ldp->view_count == 1 || ldp->flat_only) && target_image != (VkImage_XDP)0) { VkImageMemoryBarrier pre[2] = { { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, @@ -2582,6 +2590,17 @@ leia_lnx_dp_create(void *vk_bundle, leia_lnx_screen_resolve(binding, have_claim ? &claim : NULL, panels, panel_count, &sr_info, leia_lnx_sr_active_display_id(), &ldp->screen); ldp->screen_bound = true; + const bool binding_refused = leiasr_lnx_display_binding_refused(sr); + ldp->flat_only = !ldp->screen.is_sr_panel || binding_refused; + if (ldp->flat_only) { + U_LOG_W("leia_lnx_dp: segment DP for monitor 0x%016llx presents FLAT (no weave): %s", + (unsigned long long)binding->monitor_id, + binding_refused + ? "SR refused to bind the weaver to this display, and the active display " + "it fell back to is another panel" + : "this screen is not the panel the SR context drives (no calibration or " + "tracking for it until LeiaSR phase D)"); + } U_LOG_W( "leia_lnx_dp: segment DP for monitor 0x%016llx ('%s', connector '%s'): %ux%u px at (%d,%d), " "%.3fx%.3f m, %s, SR display 0x%016llx%s", diff --git a/src/drv_leia_linux/leia_sr_linux.h b/src/drv_leia_linux/leia_sr_linux.h index 59f5d10..e076fff 100644 --- a/src/drv_leia_linux/leia_sr_linux.h +++ b/src/drv_leia_linux/leia_sr_linux.h @@ -428,6 +428,18 @@ leiasr_lnx_snap_to_phase(struct leiasr_lnx *lnx, bool leiasr_lnx_has_sr_snap(const char **out_reason); +/*! + * Multi-screen M4: the weaver was asked to bind to an SR display + * (create_info.sr_display_id with a runtime that honours binding) but SR + * refused (SR_ERROR_DEVICE_NOT_AVAILABLE for an EDID-only display, + * SR_ERROR_DISPLAY_NOT_FOUND for an unknown id) and the weaver fell back to + * the ACTIVE display — whose calibration and tracking are another panel's. + * The DP must then not weave (wrong-phase output) but blit flat. + * False on the stub and whenever no binding was attempted. + */ +bool +leiasr_lnx_display_binding_refused(struct leiasr_lnx *lnx); + /*! * Can this backend's weaver composite a 2D layer inside the weave (ADR-027 * Amendment, "2D under the lens"; LeiaSR ST-5801/ST-5792)? diff --git a/src/drv_leia_linux/leia_sr_linux_sdk.c b/src/drv_leia_linux/leia_sr_linux_sdk.c index e5abeb4..d6abb2d 100644 --- a/src/drv_leia_linux/leia_sr_linux_sdk.c +++ b/src/drv_leia_linux/leia_sr_linux_sdk.c @@ -800,6 +800,7 @@ struct leiasr_lnx * creation and destruction drive the lens owner (leia_lens_owner_linux.h). */ bool external_routed; bool display_bound; + bool display_binding_refused; //!< a binding was chained and SR refused it (see the seam) VkRenderPass render_pass; //!< backend-owned, single color attachment (loadOp LOAD) VkFormat render_pass_format; @@ -1575,6 +1576,7 @@ leiasr_lnx_create(const struct leiasr_lnx_create_info *info, struct leiasr_lnx * pthread_mutex_unlock(&g_ctx_lock); struct leia_lnx_sr_weaver_plan plan = leia_lnx_sr_plan_weaver(&mcaps, debug_get_bool_option_sr_external_routing(), info->sr_display_id); + const bool binding_attempted = plan.bind_display; SrResult sres = SR_SUCCESS; for (;;) { @@ -1623,6 +1625,7 @@ leiasr_lnx_create(const struct leiasr_lnx_create_info *info, struct leiasr_lnx * } lnx->external_routed = plan.routing_external; lnx->display_bound = plan.bind_display; + lnx->display_binding_refused = binding_attempted && !plan.bind_display; if (info->x11_window != NULL) { static bool window_logged; if (!window_logged) { @@ -1917,6 +1920,12 @@ leiasr_lnx_weave(struct leiasr_lnx *lnx, } } +bool +leiasr_lnx_display_binding_refused(struct leiasr_lnx *lnx) +{ + return lnx != NULL && lnx->display_binding_refused; +} + VkRenderPass leiasr_lnx_get_render_pass(struct leiasr_lnx *lnx) { diff --git a/src/drv_leia_linux/leia_sr_stub.c b/src/drv_leia_linux/leia_sr_stub.c index 6d4d71c..3d6e665 100644 --- a/src/drv_leia_linux/leia_sr_stub.c +++ b/src/drv_leia_linux/leia_sr_stub.c @@ -468,6 +468,13 @@ leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap) return -1; // claims stay at EDID confidence } +bool +leiasr_lnx_display_binding_refused(struct leiasr_lnx *lnx) +{ + (void)lnx; + return false; // the stub binds nothing +} + uint64_t leia_lnx_sr_active_display_id(void) { From 85e4272d5ad76b9a52f2777cf2a90b77fcee2d6c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 13:59:36 -0700 Subject: [PATCH 9/9] fix(linux): record the EXTERNAL lens release even when srLensDisable fails (review 4) An EXTERNAL weaver destroyed after SRService invalidated the SR context sent srLensDisable on the dead lens. The call failed, so the release was never recorded, while external_weavers had already reached 0. last_sent stayed 3D, on_new_context re-applied ENABLE to the replacement context with no weaver alive, and the lens stayed on over the 2D desktop. leiasr_lnx_destroy now records the release whatever the call returns: a dead context's preference died with its connection. A missing lens handle is treated the same way. test_lens_owner_linux covers the failed-release case. Co-Authored-By: Claude Opus 5.5 --- src/drv_leia_linux/leia_lens_owner_linux.h | 7 +++--- src/drv_leia_linux/leia_sr_linux_sdk.c | 13 +++++++---- .../tests/test_lens_owner_linux.c | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/drv_leia_linux/leia_lens_owner_linux.h b/src/drv_leia_linux/leia_lens_owner_linux.h index 3e398cd..1e2b007 100644 --- a/src/drv_leia_linux/leia_lens_owner_linux.h +++ b/src/drv_leia_linux/leia_lens_owner_linux.h @@ -191,9 +191,10 @@ leia_lens_owner_on_external_weaver_destroyed(struct leia_lens_owner *o) return LEIA_LENS_ACTION_NONE; } -//! Record a successful release from leia_lens_owner_on_external_weaver_destroyed(): -//! the context stays ours (the SDK does not hand it back), but there is no wish -//! left to re-apply to a later context. +//! Record a release from leia_lens_owner_on_external_weaver_destroyed() — +//! successful OR failed (a dead context's lens handle; its preference died with +//! the connection): the context stays ours (the SDK does not hand it back), but +//! there is no wish left to re-apply to a later context. static inline void leia_lens_owner_commit_release(struct leia_lens_owner *o) { diff --git a/src/drv_leia_linux/leia_sr_linux_sdk.c b/src/drv_leia_linux/leia_sr_linux_sdk.c index d6abb2d..74bb7f3 100644 --- a/src/drv_leia_linux/leia_sr_linux_sdk.c +++ b/src/drv_leia_linux/leia_sr_linux_sdk.c @@ -1703,15 +1703,20 @@ leiasr_lnx_destroy(struct leiasr_lnx *lnx) * SRService keeps the lens ON while ANY client holds an ENABLE, and * this SR context outlives the session (runbook §4, "Co-existing"). */ pthread_mutex_lock(&g_ctx_lock); - if (leia_lens_owner_on_external_weaver_destroyed(&g_lens_owner) == LEIA_LENS_ACTION_DISABLE && - g_ctx.lens != NULL) { - const SrResult lr = srLensDisable(g_ctx.lens); + if (leia_lens_owner_on_external_weaver_destroyed(&g_lens_owner) == LEIA_LENS_ACTION_DISABLE) { + const SrResult lr = g_ctx.lens != NULL ? srLensDisable(g_ctx.lens) : SR_ERROR_HANDLE_INVALID; if (SR_SUCCEEDED(lr)) { - leia_lens_owner_commit_release(&g_lens_owner); U_LOG_W("leia_sr_sdk: lens OFF — the last EXTERNAL-routed weaver is gone"); } else { LOG_SR_ONCE("srLensDisable (EXTERNAL weaver release)", lr); } + /* Recorded EVEN WHEN THE CALL FAILED (review 4): the usual failure + * is a context SRService invalidated (restart), whose lens handle + * is dead. Leaving last_sent = 3D would make the next context + * re-apply ENABLE (leia_lens_owner_on_new_context) with no weaver + * alive, and the lens would stay on over the 2D desktop. A dead + * context's preference died with its connection anyway. */ + leia_lens_owner_commit_release(&g_lens_owner); } pthread_mutex_unlock(&g_ctx_lock); } diff --git a/src/drv_leia_linux/tests/test_lens_owner_linux.c b/src/drv_leia_linux/tests/test_lens_owner_linux.c index 4fc87db..8256216 100644 --- a/src/drv_leia_linux/tests/test_lens_owner_linux.c +++ b/src/drv_leia_linux/tests/test_lens_owner_linux.c @@ -266,6 +266,28 @@ test_external_weaver_respects_2d(void) CHECK(o.last_sent == LEIA_LENS_REQ_2D); } +/* Review 4: the last EXTERNAL weaver dies after SRService invalidated the + * context, so srLensDisable fails on the dead lens. The release is recorded + * anyway, so the NEXT context does not re-apply ENABLE with no weaver alive. */ +static void +test_failed_release_is_still_recorded(void) +{ + struct leia_lens_owner o = {0}; + struct fake_ctx c; + new_context(&c, &o); + external_created(&c, &o); + CHECK(o.last_sent == LEIA_LENS_REQ_3D); + + // SRService restarts: the context is invalid, the disable fails. + CHECK(leia_lens_owner_on_external_weaver_destroyed(&o) == LEIA_LENS_ACTION_DISABLE); + leia_lens_owner_commit_release(&o); // recorded despite the failure + CHECK(o.external_weavers == 0 && o.last_sent == LEIA_LENS_REQ_NONE); + + // The replacement context gets nothing re-applied: the lens stays off. + new_context(&c, &o); + CHECK(c.enables == 0 && c.disables == 0 && !c.app_owns); +} + int main(void) { @@ -277,6 +299,7 @@ main(void) test_external_weaver_turns_the_lens_on_and_releases_it(); test_external_weaver_3d_request_is_sent(); test_external_weaver_respects_2d(); + test_failed_release_is_still_recorded(); if (g_failures != 0) { fprintf(stderr, "test_lens_owner_linux: %d failure(s)\n", g_failures); return EXIT_FAILURE;