diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 63d0ce6..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: @@ -207,6 +209,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/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/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-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 a090025..bf81dd6 100644 --- a/docs/linux-track-b-runbook.md +++ b/docs/linux-track-b-runbook.md @@ -242,9 +242,19 @@ 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 + 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 + 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 @@ -276,6 +286,135 @@ 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`. + +**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 +`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`. + +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 +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`. + +**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 +`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 +521,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 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) — diff --git a/src/drv_leia_linux/CMakeLists.txt b/src/drv_leia_linux/CMakeLists.txt index af1607d..22b8e15 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,9 @@ 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 leia_bg_capture_worker_linux.c @@ -332,3 +378,28 @@ 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 +# 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_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_display_processor_linux.c b/src/drv_leia_linux/leia_display_processor_linux.c index 960dfa3..b15c5f5 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,22 @@ 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; + //! 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 //! 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 +1519,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 +1554,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 +1651,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; @@ -1648,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"; @@ -1666,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, @@ -1700,7 +1749,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 +1769,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 +1859,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 +1870,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 +1892,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 +1945,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 +1971,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 +2301,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 +2449,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 +2528,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 +2541,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 +2565,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 +2577,42 @@ 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; + 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", + (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 +2673,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..4b9302b 100644 --- a/src/drv_leia_linux/leia_edid_probe_linux.h +++ b/src/drv_leia_linux/leia_edid_probe_linux.h @@ -111,6 +111,15 @@ 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 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 +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_lens_owner_linux.h b/src/drv_leia_linux/leia_lens_owner_linux.h index ff391f7..1e2b007 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,61 @@ 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 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) +{ + o->last_sent = LEIA_LENS_REQ_NONE; + o->ctx_app_owned = true; +} + #ifdef __cplusplus } #endif diff --git a/src/drv_leia_linux/leia_plugin_linux.c b/src/drv_leia_linux/leia_plugin_linux.c index 3b49a0a..b646bed 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) @@ -229,8 +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 SR 1.38's only per-device fact: the live context's lens - * serial (verifies a single panel). + * - 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. */ @@ -325,6 +330,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 +546,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..fb83cd7 --- /dev/null +++ b/src/drv_leia_linux/leia_screen_linux.c @@ -0,0 +1,153 @@ +// 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 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 || + (panels[i].randr_output[0] != '\0' && + strcmp(panels[i].randr_output, b->device_name) == 0)) { + return &panels[i]; + } + } + } + // 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) { + 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..3b3c8eb --- /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 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 + * (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/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..e076fff 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; @@ -404,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)? @@ -516,10 +552,20 @@ 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 — 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). + * 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). */ bool leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap); @@ -538,6 +584,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..74bb7f3 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,13 @@ 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; + 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; @@ -1478,18 +1569,90 @@ 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); + const bool binding_attempted = plan.bind_display; + + 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; + lnx->display_binding_refused = binding_attempted && !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 +1675,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 +1698,28 @@ 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) { + const SrResult lr = g_ctx.lens != NULL ? srLensDisable(g_ctx.lens) : SR_ERROR_HANDLE_INVALID; + if (SR_SUCCEEDED(lr)) { + 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); + } sdk_fb_cache_flush(lnx); sdk_flip_cache_flush(lnx); if (lnx->render_pass != VK_NULL_HANDLE) { @@ -1676,11 +1862,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); @@ -1732,6 +1925,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) { @@ -2144,17 +2343,62 @@ leiasr_lnx_peek_fpc_serial(char *out_serial, size_t cap) return false; } 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. */ + 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; @@ -2178,7 +2422,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 +2438,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 +2535,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..3d6e665 100644 --- a/src/drv_leia_linux/leia_sr_stub.c +++ b/src/drv_leia_linux/leia_sr_stub.c @@ -467,3 +467,16 @@ leia_lnx_sr_enumerate_displays(struct leia_lnx_sr_display *out, uint32_t cap) (void)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) +{ + return 0; // the stub has no SR display list +} 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; 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..8256216 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,119 @@ 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); +} + +/* 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) { @@ -183,6 +296,10 @@ 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(); + 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; 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..8a427b5 --- /dev/null +++ b/src/drv_leia_linux/tests/test_screen_linux.c @@ -0,0 +1,291 @@ +// 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 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); + 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; +} 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; +}