Skip to content

fix(provider): submit the ACTIVE mode's view count + mirror display_info spec v19 (runtime#1486) - #328

Merged
dfattal merged 1 commit into
mainfrom
chore/display-info-v19-submit-count
Sep 18, 2026
Merged

dfattal merged 1 commit into
mainfrom
chore/display-info-v19-submit-count

Conversation

@dfattal

@dfattal dfattal commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

What

Two things, both downstream of the runtime's view-configuration change
(displayxr-runtime#1486,
runtime main @ c1e4fe00d):

  1. dxr_prov_submit_frame now derives the projection layer's viewCount from the ACTIVE
    rendering mode at submit time
    , clamped to the slices actually allocated — instead of
    inheriting s_ps.sc_view_count, the mode shadow latched when the swapchain was created.
  2. native~/displayxr_extensions.h reconciled with the runtime header:
    XR_DXR_DISPLAY_INFO_SPEC_VERSION 12 → 19, plus
    XR_VIEW_CONFIGURATION_TYPE_PRIMARY_MULTIVIEW_DXR (1004999212) with a comment saying
    the provider deliberately does not use it.

Why — the runtime contract

PRIMARY_STEREO now reports exactly 2 views, and xrEndFrame under it accepts exactly 2,
or 1 only while the active rendering mode is itself 1-view AND the instance enabled
XR_DXR_display_info
(this provider always does — displayxr_provider_session.cpp:4145).
A 1-view submission in a 2-view mode is XR_ERROR_VALIDATION_FAILURE; the runtime used to
silently flat-blit it. The check reads the runtime's live active_rendering_mode_index
(oxr_session_frame_end.c:verify_projection_view_count), not the mode the app last heard about.

The plugin stays on PRIMARY_STEREO and that is correct: Unity's stereo topology is fixed at 2
(DXR_PROV_MAX_VIEWS, one arraySize=2 swapchain, two render passes/params), so
PRIMARY_MULTIVIEW_DXR buys it nothing. The constant is mirrored for documentation value and
for a future N-view/quilt path (docs~/adr/ADR-007-render-path-by-view-count.md).

The race — reachable, and not only as a thread race

submit_n came from s_ps.sc_view_count, set at displayxr_provider_session.cpp:1305 from
ps_active_view_count() and re-checked only in ps_reconcile_primary() at :2260. That
refresh is skippable and can lag by frames:

Path Where
ps_reconcile_primary() returns on a 0-size target (minimised window, unresolved zone rect) before it ever compares the view count :2255-2258
dxr_prov_reconcile_size() returns early while a frame is begun :2295
A failed ps_recreate_primary_swapchain() leaves the stale value in place — sc_view_count is only assigned after a successful xrCreateSwapchain :1300-1305, :2177
macOS polls the mode event on the main thread while submit runs on the render thread Runtime/Provider/DisplayXRProviderDriver.cs:81 vs displayxr_display_provider.cpp:1367/1409

On Windows/Linux the poll → reconcile → begin_frame sequence is single-threaded inside
GfxPopulateNextFrameDesc (displayxr_display_provider.cpp:1012, :1035), so the ordering
is sound there — but the rows above are enough on their own, and the mode shadow is in any case
only as fresh as the last XR_TYPE_EVENT_DATA_RENDERING_MODE_CHANGED_DXR the provider drained
(displayxr_provider_session.cpp:5268).

Direction that matters: mode flips 1-view → 2-view, latch still says 1, provider submits
viewCount = 1 → rejected. 2 is always legal under PRIMARY_STEREO and 1 never is unless the
mode says so
, so the fix makes 2 the failure direction. Reading the mode at submit closes every
row above; the residual window is the event-delivery latency itself, which no app-side change can
close.

Raising the count is safe content-wise: GfxPopulateNextFrameDesc fills both SPI slices /
both MultiPass passes every frame regardless of mode (:1232 / :1269), so slice 1 is never
stale, and every per-eye bridge array is sized 2. Lowering it to 1 in a genuine 1-view mode is the
existing #172 P4 behaviour, unchanged.

A count/latch disagreement logs to the provider log, hard-capped at 8 lines — never per frame.

Header audit (task 1)

The mirror is complete for everything the provider uses. Diffed every Xr*DXR /
XR_*_DXR identifier referenced across native~/**.{c,cpp,h,mm} against what
displayxr_extensions.h defines: the only residue is extension-name strings inside comments and
XrLocal3DZoneMaskDXR, which the header does define via XR_DEFINE_HANDLE at :694. No v13–v18
constant is referenced-but-missing — v13's isActive/isRequestable, v16's
XrDisplayDesktopPositionDXR and v18's XrDisplayDesktopInfoDXR are all already present. The
version macro was simply never bumped past 12 while the structs kept being added.

Binary (task 3, per CLAUDE.md)

CLAUDE.md: "after modifying any file in native~/, run the build script for the current platform
… then commit source + your platform's binary … CI builds all three platforms."
Native code
changed, so the rebuilt macOS Universal bundle (Runtime/Plugins/macOS/displayxr_unity.bundle,
x86_64 + arm64) is committed. The Windows MSVC DLL and the Linux .so come from CI on this PR —
the .so is gitignored by rule, and no maintainer box here builds the MSVC DLL.

Verification

  • native~/build-mac.sh — green, Universal (x86_64 + arm64); the rebuilt bundle contains the new
    code path (verified by string match on the new log line).
  • native~/build-win.sh (MinGW cross-compile check on macOS) — green.
  • Header audit performed mechanically (identifier diff), not by eye.

NOT tested

  • No hardware run. Nothing was exercised against a real panel, a real runtime, or a Unity
    Play Mode session. The 2D↔3D mode-flip transition — the exact window this changes — is
    unverified end-to-end; it wants a Leia SR box with runtime main (≥ c1e4fe00d), a
    2D/3D toggle (shell V, or xrRequestDisplayModeDXR), and a check that the provider log shows
    no rejected xrEndFrame and no ghost-weave in 2D.
  • No MSVC build. MinGW is a compile check, not an ABI-equivalent substitute; the shipping
    Windows DLL is CI's.
  • No Linux build. Docker/colima was not running on this box, so the
    ubuntu:22.04 container check in CLAUDE.md was skipped — CI's build-linux job covers it. The
    changed code is platform-neutral (no #ifdef touched), so the risk is nil.
  • package.json not bumped, and the CHANGELOG entry is under ## [Unreleased]. Per repo
    convention the release flow owns the bump; note that /release step 2.2 prepends a new version
    section above [Unreleased] rather than folding it in, so whoever cuts the next release should
    merge the two by hand.

🤖 Generated with Claude Code

Release ordering (do not release before the runtime does)

This change re-vendors XR_DXR_display_info.h at spec 19, which landed on runtime main with displayxr-runtime#1500 and is not in any released runtime yet. Merging this PR is fine at any time; releasing it must wait for a runtime release that contains #1500, otherwise drift_audit::check_consumer_floors will (correctly) flag this consumer as under-declared. Against an older runtime the code degrades to PRIMARY_STEREO and keeps working; the constraint is about the declared floor, not runtime behaviour.

… latch (DisplayXR/displayxr-runtime#1486)

Runtime #1486 tightened xrEndFrame: under PRIMARY_STEREO — the type this
provider begins with, because Unity's stereo topology is fixed at 2 — a
projection layer must carry exactly 2 views, or 1 ONLY while the ACTIVE
rendering mode is itself 1-view and the instance enabled XR_DXR_display_info
(the provider always does). A 1-view submission in a 2-view mode is now
XR_ERROR_VALIDATION_FAILURE where the runtime used to silently flat-blit it.

dxr_prov_submit_frame derived that count from s_ps.sc_view_count, a shadow of
the mode latched at swapchain create and refreshed only by
ps_reconcile_primary(). That refresh is skippable and can lag by frames:

  - ps_reconcile_primary() early-returns on a 0-size target (minimised window,
    unresolved zone rect) BEFORE it compares the view count;
  - dxr_prov_reconcile_size() returns early while a frame is begun;
  - a failed ps_recreate_primary_swapchain() leaves the old value in place;
  - on macOS the mode event is polled from the MAIN thread
    (DisplayXRProviderDriver.LateUpdate) while submit runs on the render thread.

So a 1-view submission could outlive its 1-view mode. Read the active mode at
submit instead and clamp to the slices actually allocated (arraySize, always
2): 2 is always legal, so it is the failure direction, and a 1 is emitted only
while the mode really says 1-view. Raising the count is safe content-wise —
GfxPopulateNextFrameDesc fills both SPI slices / both MultiPass passes every
frame whatever the mode, so slice 1 is never stale. A count/latch disagreement
logs at most 8 times, never per frame.

Also reconciles the hand-written mirror native~/displayxr_extensions.h against
the runtime header: XR_DXR_DISPLAY_INFO_SPEC_VERSION 12 -> 19, plus
XR_VIEW_CONFIGURATION_TYPE_PRIMARY_MULTIVIEW_DXR (1004999212) with a note that
the provider deliberately does not use it. Documentation value only — audited
by diffing every XR_*_DXR identifier referenced in native~/ against what the
mirror defines; it is otherwise complete for everything the provider uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dfattal
dfattal merged commit e2c8b04 into main Sep 18, 2026
8 checks passed
@dfattal
dfattal deleted the chore/display-info-v19-submit-count branch September 18, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant