172 Commits
Author SHA1 Message Date
simplysanatori ea1460bddb Merge branch 'lane/Z' into native 2026-10-16 02:48:54 -04:00
simplysanatoriandClaude Fable 5.1 051cfc9dfe release: allowlist the bundled toolchain and translator for the tarball's boundary and privacy checks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:48:54 -04:00
simplysanatoriandClaude Sonnet 5.5 b232483623 Wrapper: never start the panel recorder after the game is gone, stop it on every exit path; add test
The recorder subshell slept 8 s and then started even when the game had died at 3 s, and kept
recording the headset for hours. It now starts only if the game is alive, runs in its own session,
and is stopped (SIGINT, bounded wait, TERM, KILL, pkill) on game exit and on TERM/INT/HUP, with a
log line saying which path ran.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 02:48:37 -04:00
simplysanatoriandClaude Fable 5.1 51fb6268a1 0.9.0-alpha.1 pre-release: README in its final shape (status, the double image, what has been done, performance), release notes, changelog, technical notes, untested list; wrapper defaults KARTFRAME_END_PHASE=after
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:43:09 -04:00
simplysanatoriandClaude Fable 5.1 b2aea99bef perf: session-h20 results (after-lock clean but one refresh late; before unreachable; the clean probe ends at the default phase, so phase alone is not the cause)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:13:23 -04:00
simplysanatoriandClaude Opus 5.5 34c16e95be perf: session-h20 (end phase after / before / 4000 control), DEV.md lane X, mock results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:59:44 -04:00
simplysanatoriandClaude Opus 5.5 1fa087d47c mock: MOCK_XR_ALIGN_US wake-up grid, stub update window (STUB_UPDATE_MS), mockrun runs the grid publisher with KARTFRAME_END_PHASE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:59:44 -04:00
simplysanatoriandClaude Opus 5.5 b849ada211 pacing: KARTFRAME_END_PHASE=after|before (test only): end locked to the published compositor grid, fixed-delay fallback, 10 s achieved-phase log, 4 pairs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:59:44 -04:00
simplysanatoriandClaude Opus 5.5 95dd11233a vrtiming.py: publish the compositor grid for KARTFRAME_END_PHASE (vsync in CLOCK_MONOTONIC, period, update start/end percentiles; 64-byte record, atomic rename, 0.1 s polls)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:59:44 -04:00
simplysanatoriandClaude Opus 5.5 9bfea79f14 perf: LANE_X.md (session 19 per frame: ends never before the update start; the edge is the update end) and phase.py view C, _RAW clock, frame-ready check fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:42:06 -04:00
simplysanatoriandClaude Fable 5.1 5322233f1d perf: session-h19 results (clean vs doubled 4 ms launches separated by the end's phase against the compositor's update window)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 01:31:41 -04:00
simplysanatoriandClaude Opus 5.5 6ab24c86af perf: LANE_W.md (clock matching, the end lock design and risks); session-h19 env files and README
Two launches at KARTFRAME_END_DELAY_US=4000 with KARTFRAME_VRTIMING_FRAMES=1, so a
clean and a doubled launch at the same delay can be compared by phase.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:34:30 -04:00
simplysanatoriandClaude Opus 5.5 7074f4758d XRF lines: wake-up, release and display time in CLOCK_MONOTONIC (m= r= t=)
m= is the wake-up in CLOCK_MONOTONIC ms (the end is m + e), r= the eye releases
after the wake-up (-1 on a repeat), t= the predicted display time converted with
xrConvertTimeToTimespecTimeKHR; an 'XRF clock:' line says how. Lines the app's
timeline up with vrframes.log (frame/tools/phase.py). DEV.md: lane W section.
Mock (END_DELAY 4000, MOCK_XR_TIME_OFFSET_S=5): ok, offset 5000 ms removed,
r = e - 0.01 ms; ctest 32/32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:34:30 -04:00
simplysanatoriandClaude Opus 5.5 a538cd7eff phase.py: where the app's end lands in SteamVR's compositor frame, per launch
Reads vrframes.log and the XRF lines of a session folder: SteamVR's own app
timings against its vsync reference (frame ready, poses ready, margin to the
compositor's update start), and the app's wake-up, end, release and display
time minus the nearest compositor anchor through the P-line clock offset,
with 0.25 ms histograms and a per-second drift table. openvr-stub.c: a fake
libopenvr_api.so to exercise vrtiming.py's frame mode without SteamVR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:29:36 -04:00
simplysanatoriandClaude Opus 5.5 8816fe9a7d vrtiming: KARTFRAME_VRTIMING_FRAMES=1 logs every compositor frame to vrframes.log
One F line per compositor frame (de-duplicated by index, the newest two of each
poll held back), a P line per poll tying SteamVR's system time to CLOCK_MONOTONIC
through GetTimeSinceLastVsync, G lines for missed frames. SIGTERM now ends the
loop cleanly so both logs are flushed. KARTFRAME_STEAMVR_DIR picks the library
(tests). Checked against a stub libopenvr_api (120 Hz, no gaps over 4 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:26:38 -04:00
simplysanatoriandClaude Fable 5.1 960e3f11c4 perf: session-h18 results (4.0 ms not clean on repeat; the band is not fixed to the app's wake-up)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 00:19:50 -04:00
simplysanatoriandClaude Fable 5.1 8055bc5048 perf: session-h17 results (2.5 ms ends were bimodal, 6 ms bad for the right eye); session-h18 band-mapping env files (4000, 4500, 3500)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 00:10:36 -04:00
simplysanatoriandClaude Fable 5.1 0ddc778b23 perf: session-h16 results (ending the frame 4 ms after the wake-up removes the doubling); session-h17 delay sweep env files
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 19:58:14 -04:00
simplysanatoriandClaude Sonnet 5.5 2ca6ebedc8 pacing: KARTFRAME_END_DELAY_US test switch (frame ended N us after the wake-up, next set started before the sleep, 4 eye pairs)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:20:45 -04:00
simplysanatoriandClaude Fable 5.1 7601f392b9 perf/session-h16: launch 2, set render start delayed 4 ms after the wake-up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 16:50:52 -04:00
simplysanatoriandClaude Fable 5.1 1ad004ba19 perf: session-h15 results (90 Hz and 1200 px both doubled: GPU load size and refresh rate ruled out); session-h16 env (thread priority boost and uclamp off)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 16:49:43 -04:00
simplysanatoriandClaude Fable 5.1 791be71dd1 perf: session-h14 headset results (desktop present and release backlog ruled out; drained queue cleared the left eye only); session-h15 env files (90 Hz, 1200 px eyes); wrapper KARTFRAME_EYE_PX
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 15:26:44 -04:00
simplysanatoriandClaude Opus 5.5 4fe9c0f03f perf: LANE_U.md (release ordered correctly, verdict B; ranked candidates) and session-h14 (no desktop swapchain; release idle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:46:22 -04:00
simplysanatoriandClaude Opus 5.5 e4df6be3fa KARTFRAME_RELEASE=idle: drain Dawn's queue before each set's releases and its xrEndFrame (headset A/B)
Default unchanged. With idle, vkQueueWaitIdle under the queue guard right before
xrReleaseSwapchainImage of the eye pairs and before xrEndFrame, so SteamVR's
client submissions there never wait behind our next set's GPU work (the probe,
clean on the headset, had nothing queued at those points). Logs the drain's
cost every 10 s. DEV.md lines for the switch and the mock's new lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:29:44 -04:00
simplysanatoriandClaude Opus 5.5 dab2dd23d0 xrmock: strict release check (bytes at release vs next acquire) and release-queue latency per swapchain
The exact check's second sample is taken once an empty submission made at the
release completed, so it could not see work queued after the release. The new
strict check compares the image's bytes at its release with its bytes at its
next acquire (no queue involved). A waiter thread now times each release's
empty submission: how long a submission made inside xrReleaseSwapchainImage
(as SteamVR's client makes) waits behind the app's queued GPU work, per
swapchain and frame parity. Both only with MOCK_XR_BUSY_MS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:21:05 -04:00
simplysanatoriandClaude Fable 5.1 4a582ddd29 perf/session-h13: headset results (all six replay cases clean)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 13:08:31 -04:00
simplysanatoriandClaude Fable 5.1 bc320f84a5 session-h13 replay-case.sh: drop the launcher's arguments when run from the queue (xrprobe exited 2 on --oom-score-adjust)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:55:17 -04:00
simplysanatoriandClaude Opus 5.5 4ab88a166b Session 13 ready: recording notes (61 s, standing case = first 168 sets), replay launch recipe and owner routine, mock verification (replaycheck.py), DEV.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:31:55 -04:00
simplysanatoriandClaude Opus 5.5 84ee01956d xrprobe --replay: KartFrame's recorded race eye images at their recorded poses (session 13 cases), replay-case.sh, queue.sh, run-probes PROBE_SET=replay / PROBE_MOCK=1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:22:15 -04:00
simplysanatoriandClaude Opus 5.5 e3b7439117 xrmock: MOCK_XR_SWAY's still head only when a start time is given (the default sway unchanged for every time)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:46:09 -04:00
simplysanatoriandClaude Opus 5.5 285e608713 Lane S: the race eye images are clean in the mock (zigzag 0 % per eye, standing and driving); session-h13 xrprobe replay plan
frame/perf/LANE_S.md: 240-set windows of consecutive released eye images in the scripted race (head
still, standing with a 25 deg sway, driving), H7's zigzag rule per eye 0 % at two GPU loads while an
injected period-2 jump is flagged in 99-100 % of moving sets; no stale image, no eye difference.
frame/perf/session-h13/README.md: recorded race images through GalaxyFrame's loop (xrprobe --replay,
to write), cases for the content phase, KartFrame's pair rotation and race quad.
mockdump/dumprace.sh reproduces the recording; DEV.md lists the new mock switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:45:51 -04:00
simplysanatoriandClaude Opus 5.5 f0fa6323a9 xrmock: MOCK_XR_DUMP_QUEUE (dumps without stalling the app: blit down, queue, write when the dumps pause); zigzag.py view and scene tracks
The stalling dump read every image back inside xrReleaseSwapchainImage and skipped about nine
refreshes in ten, so consecutive sets never existed. With the queue every set of a 240-set window
is dumped (end after wake-up p99 1.1 ms during the windows), and index.txt carries each image's
stamp (eye, display time, scene words), decoded from the stamp rows copied beside it.
zigzag.py: H7's rule on two tracks per eye (view: columns of the upper image; scene: the
track-side strips moving apart), the scene stamp's steps, eye phase and a lagdiff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:41:03 -04:00
simplysanatoriandClaude Opus 5.5 488df49c41 xrmock: MOCK_XR_SWAY (sway size, period, start), press hold/until, dump index with stamps; mockdump/zigzag.py
Lane S tooling to test the panel zigzag on the mock's released eye images: the head's sway can be
made larger and held still until a given time, a scripted press can be held (driving) and can stop,
and MOCK_XR_DUMP writes index.txt with each image's eye and stamped display time. zigzag.py applies
H7's rule, a lagdiff and the eye phase per dump window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:19:27 -04:00
simplysanatoriandClaude Fable 5.1 62c357a2c8 perf/session-h12: headset results (pose check, one-ahead lateness, fresh poses, panel zigzag per eye and over time)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 06:12:08 -04:00
simplysanatoriandClaude Opus 5.5 6779476781 perf/session-h12: lane R's plan (measure, one ahead, fresh poses) and mock results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:33:51 -04:00
simplysanatoriandClaude Opus 5.5 de92c3891c xrmock fresh-pose check: accept any locate for the display time that gave the layer's poses (equal floats at the sway's turning points)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:26:29 -04:00
simplysanatoriandClaude Opus 5.5 17c2a46f4b DEV.md: lane R switches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:25 -04:00
simplysanatoriandClaude Opus 5.5 34dcb0e4db KARTFRAME_SUBMIT_POSE=fresh; xrmock MOCK_XR_STAMP=fresh checks fresh-pose layers against their own display time's locate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:13 -04:00
simplysanatoriandClaude Opus 5.5 4dca761b8f KARTFRAME_AHEAD_REFRESHES=1: start the next set at the wake-up before ending the frame (GalaxyFrame's order), 4 pairs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:54:30 -04:00
simplysanatoriandClaude Opus 5.5 01a2e0bdc1 Pose check at every xrEndFrame (GalaxyFrame's notePoses), prediction distance, session-report rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:53:06 -04:00
simplysanatoriandClaude Fable 5.1 fd266ec2b8 DEV.md: lane Q switches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 16:13:28 -04:00
simplysanatoriandClaude Opus 5.5 c494058b39 perf/session-h11: lane Q's plan (race quad launch, first-write barrier launch) and mock results
Replaces lane P's one-launch plan. Mock: 0 errors, 0 of 42792 releases changed
after the GPU, stamps and end times exact, every race frame projection + quad,
validation layer clean, race timing unchanged A/B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:11:19 -04:00
simplysanatoriandClaude Opus 5.5 6596f27cf2 Race quad (KARTFRAME_RACE_QUAD) and GalaxyFrame's first-write barrier (KARTFRAME_FIRST_WRITE_BARRIER)
A frame whose only layer would be the projection gets a 16x16 transparent quad
(alpha blended, 1 cm, 2 m ahead) after it; its images are cleared once by the
bridge's own command buffer, waited for and released. Before each set's first
writes the bridge submits a barrier ALL_COMMANDS/MEMORY_WRITE to
ALL_COMMANDS/MEMORY_READ|WRITE on its images (layout kept), on Dawn's queue under
its lock, ahead of Aurora's set. XRF lines carry n=<layers>; xrmock counts the
layer lists by shape and flags quads on never-released swapchains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:56:56 -04:00
simplysanatoriandClaude Fable 5.1 a75676c0b4 DEV.md: lane P instrumentation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 15:43:27 -04:00
simplysanatoriandClaude Opus 5.5 1c42c4d919 perf/session-h11: the eye-pose checks' results and the eleventh session's launch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:42:01 -04:00
simplysanatoriandClaude Opus 5.5 dac9971ecc Eye pose checks: frustum word, GPU uniform probe, cheap stamp; mock bookkeeping before the wait
The design already snapshots each set's packet once (ProvideStereoFrame copies it under
published_mutex_; every eye path uses that copy). This makes the remaining ways one eye
could differ measurable on the headset too:
- the stamp's fourth pose word: the frustum tangents each eye's fill used; xrmock compares
  them with the layer's submitted fov (and the other eye's);
- a GPU uniform probe (KARTFRAME_FRAME_LOG=1 or KARTFRAME_UNIFORM_PROBE=1): after both eyes,
  each eye's first perspective range is read back from g_uniformBuffer (now CopySrc) and
  compared with the bytes its fill wrote, counted per 10 s;
- the XRP fields ride on the XRS line (one log call per set).
The test stamp is 64x15 pixels, one pixel a bit (was 4x4 cells), and xrmock does its
read-back bookkeeping before xrWaitFrame's sleep instead of after the wake-up it measures
from: with the larger stamp that work alone moved 'ends later than 1 ms' by 2 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:35:43 -04:00
simplysanatoriandClaude Opus 5.5 d2c67bab62 Per-eye pose stamp: the packet and eye pose each eye's uniform fill wrote
Both fill sites (the seal's eye pair and prepare_late_stereo_replay) record per eye the
packet (display time, token) and the eye transform written, and count draws filled with
another; the stamp block gains three words (rows 7-12, 256x52), an XRP line per set under
KARTFRAME_FRAME_LOG=1 and a 'VR eye poses' summary every 10 s (always). xrmock decodes the
words and compares each eye with the pose its layer submitted, and left against right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:25:39 -04:00
simplysanatoriandClaude Fable 5.1 f20f221f4f DEV.md: lane O switches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 12:49:01 -04:00
simplysanatoriandClaude Opus 5.5 f5a439af27 H9.md: call sequence, Vulkan hand-over, switches, lane N verdict; session 10 launch files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:44:37 -04:00
simplysanatoriandClaude Opus 5.5 c003213fd7 End locate through the runtime's dispatch (the replay test links no loader)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:37:43 -04:00
simplysanatoriandClaude Opus 5.5 ba78a375e1 Eye swapchains as GalaxyFrame's: R8G8B8A8_SRGB, COLOR_ATTACHMENT|SAMPLED, a locate at the frame's time before xrEndFrame
KARTFRAME_XR_FORMAT=bgra, KARTFRAME_XR_USAGE=kart and KARTFRAME_END_LOCATE=0 restore the old way;
KARTFRAME_XR_EXTENSIONS=galaxy asks for GalaxyFrame's extension list. The sRGB sink draw writes the
RGBA image (and the menus' screen image) from Aurora's BGRA eyes. The clock fallback without
XR_KHR_convert_timespec_time is decided once, at the first frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:34:16 -04:00
simplysanatoriandClaude Opus 5.5 d4f4880c1b xrmock: MOCK_XR_TRACE_DETAIL, the hand-over as a runtime sees it (call times, spaces, locates, layer structs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:04:17 -04:00
simplysanatoriandClaude Opus 5.5 c791f524a6 H9.md: SteamVR's logs of KartFrame against GalaxyFrame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:00:06 -04:00
simplysanatoriandClaude Fable 5.1 3337d223e1 DEV.md: lane L and M switches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 07:03:07 -04:00
simplysanatoriandClaude Fable 5.1 cc5da3d04e Merge lane M: per-eye offset logging, fixed eye offsets switch, one-ahead switch, session 9 plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 07:03:06 -04:00
simplysanatoriandClaude Fable 5.1 01c416bf5a Merge lane L: scene stamp per eye (instrumentation; the eyes never differ)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 07:03:06 -04:00
simplysanatoriandClaude Opus 5.5 557e907619 perf/session-h9: the ninth session's queued launches (located with XRE eye-offset logging, fixed eye offsets, optional fixed + one refresh ahead), what to look for, and the mock numbers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:02:14 -04:00
simplysanatoriandClaude Opus 5.5 3e27c4456b xrmock: a recomposed layer whose one eye matches a locate exactly and the other only to rounding is from that locate (fixed eye offsets: 504 false "eyes from different locates" in 3 minutes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:01:55 -04:00
simplysanatoriandClaude Opus 5.5 f282e61610 KARTFRAME_AHEAD_REFRESHES=1: sets located for the next refresh instead of two ahead (default 2); the pacing line names it and KARTFRAME_AHEAD_START_US
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:23:28 -04:00
simplysanatoriandClaude Opus 5.5 2eb8adae37 Eye offsets (lane M): XRE line per locate and a per-second "vr eyes" summary of each eye's offset from the head (KARTFRAME_FRAME_LOG=1); KARTFRAME_EYE_OFFSETS=fixed renders and submits each eye at the head pose with the median eye-to-head offset and field of view of the first 120 focused locates; session-report shows it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:23:28 -04:00
simplysanatoriandClaude Opus 5.5 140030cc37 xrmock: the VIEW reference space follows the head (the eyes' midpoint and orientation); the pose check also matches a locate within 1e-6 (fixed eye offsets recompose the eye poses), counted in the summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:23:28 -04:00
simplysanatori 80f72c1bfd Revert "Wrapper: KARTFRAME_GAZE_EYES=off switches SteamVR's gaze-dependent eye positions off for one launch and restores them at exit (or at the next launch after a crash)"
This reverts commit b09d4a812a.
2026-10-14 05:17:16 -04:00
simplysanatoriandClaude Opus 5.5 b09d4a812a Wrapper: KARTFRAME_GAZE_EYES=off switches SteamVR's gaze-dependent eye positions off for one launch and restores them at exit (or at the next launch after a crash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:13:47 -04:00
simplysanatoriandClaude Opus 5.5 597fef4a41 Scene stamp per eye: weight, game-frame pair and draw counts each eye's draws used, in the frame stamp and an XRS line per set; xrmock compares the eyes and scores the scene steps
The frame stamp (KARTFRAME_FRAME_STAMP=1) gains two 64-bit words per eye (rows 3-6, block 256x28):
the weight and previous/current game frames of the fill, whether it was filled at the set's sample
time, and per eye the perspective draws filled from the pair, filled current-only, drawn with the
recorded uniform and drawn with a range outside the set's fill (counted where the eye replay binds
them). The block is now built on the CPU and copied once per eye. KARTFRAME_FRAME_LOG=1 adds one XRS
line per set. xrmock (MOCK_XR_STAMP) reports sets whose eyes differ, the weight per eye, the draw
shares and the scene steps between sets one refresh apart (MOCK_XR_SCENE_DUMP=<file> the sequence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:44:37 -04:00
simplysanatoriandClaude Opus 5.5 c56c3f764c DEV.md: lane K switches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:37:05 -04:00
simplysanatoriandClaude Opus 5.5 c5d409b967 perf/session-h8: the eighth session's two queued launches (old ending, sets ahead), recorded with the frame log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:36:15 -04:00
simplysanatoriandClaude Opus 5.5 199a43bd39 H7.md: the mock's scoring, the A/B with SteamVR's compositor running (loaded race: ends p99 7.8 -> 0.47 ms, 111 -> 120 sets/s, 7.8 -> 0.03 % late) and the next session's plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:36:15 -04:00
simplysanatoriandClaude Opus 5.5 c5aa23b1aa xrmock: end after the runtime's wake-up (histogram, p50/p99, share later than 1 ms) and a late-end model; mockrun prints them, KF_MOCKRUN_END_GATE=<percent> fails a run above it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:01:37 -04:00
simplysanatoriandClaude Opus 5.5 52103ecc55 Pipelined pacing renders sets two refreshes ahead and ends every frame at its wake-up with the newest finished set (KARTFRAME_PIPELINED=wait: the old loop); three eye pairs for it, the old loop's default back to 2; pacing line dropped= end_late=
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:01:37 -04:00
simplysanatoriandClaude Opus 5.5 cf2cc7c527 H7.md: the seventh session's zigzag lined up with the XRF lines (black-refresh alignment): it does not follow the end time, retained repeats or the pair
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:47:37 -04:00
simplysanatoriandClaude Opus 5.5 0cb388ba83 Wrapper: a queue of one-launch settings (session.queue); DEV.md: lane J switches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:20:43 -04:00
simplysanatoriandClaude Opus 5.5 7b9dc8fa9f PANELS.md: the panel recording's findings, the mechanism, the mock proof and the next session's A/B; session-report: eye pairs and end-after-wake rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:18:31 -04:00
simplysanatoriandClaude Opus 5.5 79e21e5685 OpenXR Vulkan: extra eye pairs made with the first per-refresh set (render-first path keeps two; replay tests' swapchain counts hold); never-shown pairs are free
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:17:02 -04:00
simplysanatoriandClaude Opus 5.5 3530569e56 OpenXR Vulkan: six eye swapchain pairs, a pair written again only 5 refreshes after the last frame that showed it (KARTFRAME_EYE_PAIRS=2: the old alternation); end_ms/pairwait in the pacing line; KARTFRAME_FRAME_LOG=1 XRF lines
The panel recording of 20261014-010727 showed one eye at a time displaying an image it had shown 3-5
refreshes before (race: left 10.4 %, right 7.0 %, both together 0.02 %, eyes out of step 19.4 %; menus 0),
at the old pixels while the image moved: SteamVR resolves each eye's image when it composes, after our
alternating pair was already acquired again. The mock's compositor model (lazyacq) reproduces it with two
pairs (left 24.6 %, right 15.1 %, eyes from different sets 18.8 %) and shows none with six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:05:14 -04:00
simplysanatoriandClaude Opus 5.5 7f6caafff8 perf/panels: cadence summary and one-command analysis; xrmock: compositor model (spec/lazyrel/lazyacq), end-after-wake histogram
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:48:07 -04:00
simplysanatoriandClaude Opus 5.5 51e4dabcea perf/panels: decode a panel recording per eye, lag differences, global motion (audioop, no numpy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:34:32 -04:00
simplysanatoriandClaude Opus 5.5 63b8a551fa Wrapper: find framecorder by its full path (Steam's PATH lacks ~/.local/bin); log when recording is impossible
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:32:11 -04:00
simplysanatoriandClaude Opus 5.5 64a67644c4 Wrapper: KARTFRAME_RECORD=raw records both panels into the session folder with framecorder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:27:30 -04:00
simplysanatoriandClaude Opus 5.5 7daa878698 DEV.md: lane I switches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:11 -04:00
simplysanatoriandClaude Opus 5.5 8926e7fb21 Merge lane I: sRGB sink into the swapchain, native race render cut and clipped, display-time fix, GPU segment profile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:00 -04:00
simplysanatoriandClaude Opus 5.5 b13db89928 RACE120.md: the race's GPU profile before and after, A/B pairs, soaks at 120 and 90 Hz; frame/perf/abgp.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:12:59 -04:00
simplysanatoriandClaude Opus 5.5 66da38ac61 Race eyes reach the swapchain through an sRGB-decoding draw; the images lose MUTABLE_FORMAT (2.3 -> 0.47 ms a set)
The eyes were copied raw into a UNORM view of the sRGB swapchain images, which needed
MUTABLE_FORMAT; without a format list the driver lays such an image out uncompressed, and the copy
of both 1728 eyes cost 2.3 ms of every refresh set (mock GP race, SteamVR's compositor running, GPU
at its top clock, SoC 93 C). Now a full-screen draw decodes the eyes' sRGB-encoded values and writes
them through an sRGB view: the same bytes (the mock's stamps read back exactly), no mutable flag, and
the images compress. Two A/B pairs, same clock and temperature: sink 2.35/2.28 -> 0.47/0.48 ms, refresh
set 7.16/7.08 -> 5.14/5.10 ms, race 21.3/19.4 -> 1.6/1.6 % late, 94.8/97.0 -> 118.4/118.5 sets/s.
KARTFRAME_SINK=copy|blit restores the copy (and the flag). The direct-to-swapchain trial stays for
those modes; with the default it never applies. Menus: copies as before (non-mutable images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:07 -04:00
simplysanatoriandClaude Opus 5.5 4171e7b1e3 Direct eye output: measured in Aurora with GPU timestamps on the race's refresh sets; run.sh samples the GPU clock
The backend's submit-to-done time could not tell the two ways apart (queue backlog carried from one
block into the next; the first try picked direct on host-visible mock images, 25 % late). Aurora now
times the eye passes through the sink of each trial set itself (first eye to the end of the copy or
stamp), blocks of 16 alternating after the first 600 refresh sets, and keeps the cheaper way for the
process (KARTFRAME_DIRECT_EYES=0/1 forces). The bridge no longer takes a per-set permission.
frame/perf/run.sh records the GPU devfreq clock and the hottest thermal zone (gpufreq.tsv).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:03:13 -04:00
simplysanatoriandClaude Opus 5.5 10e8fe0668 Menus: the pointer's target marks drawn into the screen quad's image; environment layer under the screen by default
SteamVR on the Frame showed no ray or mark over the screen with the environment layer submitted over
the quad (fourth headset session): it evidently composites the projection layer under the quad
whatever the submission order. The marks now lie on the screen's own surface (both hands), and the
environment layer goes under the quad again (KARTFRAME_MENU_ENV=over: lane H's order). H4.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:16 -04:00
simplysanatoriandClaude Opus 5.5 b097870161 H4.md: fix branch hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:16 -04:00
simplysanatoriandClaude Opus 5.5 851597086a Menus: the pointer's target marks drawn into the screen quad's image; environment layer under the screen by default
SteamVR on the Frame showed no ray or mark over the screen with the environment layer submitted over
the quad (fourth headset session): it evidently composites the projection layer under the quad
whatever the submission order. The marks now lie on the screen's own surface (both hands), and the
environment layer goes under the quad again (KARTFRAME_MENU_ENV=over: lane H's order). H4.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:32:57 -04:00
simplysanatoriandClaude Opus 5.5 6a9632b694 Full-view fades only for untextured constant-colour quads; log each full-frame 2D draw class
Lane H's R1 (f127ea4) mapped every blended full-frame 2D rectangle drawn after the scene across the
whole eye, head-locked from frame space. Textured ones are composites of a picture (the Time Trial
ghost from its offscreen copy, the player's character): pinned to the head they doubled against the
world on every head turn (90 Hz headset session). A fade now needs no texgens (the respawn black,
white flashes, wipes, the scene fader); textured full-frame quads go back to their pre-lane-H path.
'Full-frame 2D draw class: ... -> ...' names each class and its path once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:18:34 -04:00
simplysanatoriandClaude Opus 5.5 65e6fce6be Full-view fades only for untextured constant-colour quads; log each full-frame 2D draw class
Lane H's R1 (f127ea4) mapped every blended full-frame 2D rectangle drawn after the scene across the
whole eye, head-locked from frame space. Textured ones are composites of a picture (the Time Trial
ghost from its offscreen copy, the player's character): pinned to the head they doubled against the
world on every head turn (90 Hz headset session). A fade now needs no texgens (the respawn black,
white flashes, wipes, the scene fader); textured full-frame quads go back to their pre-lane-H path.
'Full-frame 2D draw class: ... -> ...' names each class and its path once.
Also: the backend links the bridge's allow_direct weakly (test binaries stub the bridge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:18:18 -04:00
simplysanatoriandClaude Opus 5.5 1d5fce9b15 WIP lane I: per-segment GPU profile, direct eye targets (measured choice), native race render clip, exact display-time stamp
KARTFRAME_FPSLOG=2 now times every set (game-frame and refresh sets) with in-pass timestamps per
draw class (scene, HUD, fades, EFB effects, sink copy); frame/perf/gpuprof.py tabulates them.
Eyes may render straight into the swapchain images (the bridge's DirectTargets); the Vulkan backend
measures both ways on the first race sets and keeps the cheaper (KARTFRAME_DIRECT_EYES=0/1 forces).
The native render of an immersive race frame stops after its last copy for the eyes on Linux too
(KARTFRAME_MONO_FULL=1 keeps it whole) and is clipped to the copies' rectangles
(KARTFRAME_NATIVE_CLIP=0). DisplayTimeNanos returns the converted CLOCK_MONOTONIC time directly
(two clock reads put two stamps 0.24 ms off in the orchestrator's soak); the mock counts such
offsets as 'between', honours MUTABLE_FORMAT. Not yet soaked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:11:43 -04:00
simplysanatoriandClaude Opus 5.5 2fe42c471e DEV.md: lane H switches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:56:00 -04:00
simplysanatoriandClaude Opus 5.5 d9e77fc4ec H3.md: third headset session analysis, lane H fixes and measurements
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:55:24 -04:00
simplysanatoriandClaude Opus 5.5 96bb810331 Eye replay skips blended screen filters with an alpha under 2/255; full-view fade counts in the log
An idle screen filter (blended, untextured, the material alpha passed through, no depth write)
leaves the picture unchanged; drawn across both 1728 eyes it costs fill. The "Immersive replay" line
counts full-view draws and how many were skipped; "VR large 2D rectangle" lines carry the blend
factors and alpha source (up to 200 distinct a run, keyed by alpha) to identify full-screen effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:50:47 -04:00
simplysanatoriandClaude Opus 5.5 3175a780d9 Menus: the environment layer goes over the screen quad by default, so the pointer shows on the screen
Owner, third session: the laser ray reached the screen's edge and stopped; its part over the screen
and its target mark were hidden. Lane G's environment projection layer (controllers, rays, marks)
was under the screen's quad. It now goes over it, blended by its alpha
(XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT, the frame's only projection layer): the screen's
area is (0,0,0,0) except where a ray, a target mark or a controller is drawn, all opaque, so
premultiplied and straight alpha give the same result. A controller held in front of the screen
stays in front of it. KARTFRAME_MENU_ENV=under restores the old order.

xrmock: MOCK_XR_DUMP_ALPHA=1 dumps the alpha channel too (layers blended by alpha can be composited
from the dumps), MOCK_XR_HANDS=high raises the right controller in front of the menu screen;
hudrace.sh converts the alpha dumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:50:47 -04:00
simplysanatoriandClaude Opus 5.5 798a4a5180 Minimap: half-scale copies skip their last 3 logical columns, removing the HUD's thin vertical line (R2)
The line along the race map's right edge is in the minimap texture itself: its bake copy (440x440
logical -> 220x220 IA4, half scale, at 1.467x internal resolution) carries a faint white stripe
(alpha 1/15) in the last two or three logical columns. Sampling the source without its last 4 render
pixels removed the line in the eye dumps, 3 did not; texture clamping did not. Half-scale copies at a
fractional scale now leave out their last 3 logical columns (0.7 % stretch).
KARTFRAME_COPY_TRIM=0 restores the full source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:15:08 -04:00
simplysanatoriandClaude Opus 5.5 f127ea4538 Full-frame fades cover the whole eye in the race, not just the virtual screen (R1)
A blended 2D rectangle covering the whole frame, drawn after the pass's scene (the respawn's black
filter, the race start/end wipes and fades, the scene fader, white flashes) is no longer claimed by
the virtual screen: the eye replay maps it from the frame onto the whole eye, head-locked. Opaque and
logic-op full-frame quads (backgrounds, a logic-op pass every frame) stay as they were; one shown
across the eye blacked it out in a test. Not in the immersive window or split screen.
KARTFRAME_FULL_VIEW_FADES=0 restores the old behaviour. Mock race start: the fade-in darkens the
whole view instead of showing a black rectangle on the screen. The "Immersive replay" line counts
full-view draws; "VR large 2D rectangle" lines (24 at most) and KARTFRAME_LOG_2D_QUADS help find
other full-screen effects (the Blooper ink was not reproduced headless).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:15:08 -04:00
simplysanatoriandClaude Opus 5.5 4b87fcfca7 Game thread and frame worker get a utilization clamp (uclamp min 1024 / 800) so they run on the big cores
Mock race without other load: 60.0 game fps and 75 slow frames in 74 s with the clamp, 58.2 fps and
376 without (nice -15 alone), 58.8 fps and 286 with neither: the game thread's 11 to 14 ms of logic
a frame wants the Frame's one 1024-capacity core. sched_setattr needs no privilege for this.
KARTFRAME_UCLAMP=0 leaves the clamps alone; the "thread priority:" lines show what was set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:15:08 -04:00
simplysanatoriandClaude Opus 5.5 0409ac25b6 xrmock: unique head poses (no false stamp errors), dump files named to a tenth of a second
The head's sway repeats about its sine peaks, so two locates could share a pose and the pose check
matched the twin: 2 to 4 false "image rendered for a display time 183 ms after its poses" errors a
run, at the same mock frame whatever the game's thread priorities. The head now rises 0.1 mm a
second; with it the stamp check is clean (16386 eye images, 0 mismatches). README: lane H's
defaults (the Frame's FOVs, 3-refresh lead, 0.02 ms jitter, end-time check, MOCK_XR_DUMP_STEP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:15:08 -04:00
simplysanatoriandClaude Opus 5.5 673408a71b Raised priority for the threads a headset frame waits on (RealtimeKit nice -15); timing runs hold both projects' locks
H3: a PrimeFrame build during the session starved the game thread (2095 slow game frames, runq
waits). The pacing thread raises itself, the game thread, the GX thread, Aurora's frame worker and
the GPU watcher to the lowest nice RealtimeKit grants (at most -15), else what RLIMIT_NICE allows
(KARTFRAME_THREADPRIO=high|nice|off; "thread priority:" log lines). Aurora publishes the frame
worker's thread id and names it on Linux.

frame/perf/run.sh takes PrimeFrame's build lock as well as KartFrame's; frame/perf/bgload.sh is a
background compile load for runs; hudrace.sh keeps its runs under kf-laneH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:26:09 -04:00
simplysanatoriandClaude Opus 5.5 d46788da34 Frames end with their own cycle's predicted display time; the mock checks it on the Frame's FOVs and display lead
H3: lane G's end-at-locate rule gave every set shown a refresh late (24 % of the race) a display
time one refresh older than its cycle's; SteamVR's mispresented frames rose from 116 to 1284 and
reused app frames from 0.2 to 4.4 %. Every xrEndFrame now carries its cycle's predicted time
(KARTFRAME_END_TIME=locate restores lane G's rule). The "vr display times" line counts sets shown a
refresh or more late apart from SteamVR's +-0.02 ms prediction jitter (which made nearly every set
"located for another time").

xrmock: the Frame's own per-eye FOVs by default, predictedDisplayTime 3 refreshes after the wake
(MOCK_XR_LEAD; SteamVR's lead was 25 to 27 ms), 0.02 ms prediction jitter by default, an ERROR for
every xrEndFrame whose display time is not its frame's predicted time ("xrmock: end times"), and
MOCK_XR_DUMP_STEP for full-resolution dumps. The GPU watcher publishes its thread id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:26:04 -04:00
simplysanatoriandClaude Opus 5.5 1ddc491382 Headless runs wait for and stop on the headset-in-use marker; installer test starts from a Steam copy without KartFrame shortcuts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:32:47 -04:00
simplysanatoriandClaude Opus 5.5 d67155e5f8 Wrapper marks the headset as in use while the game runs (~/.cache/headset-in-use) so builds and headless runs wait
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:30:00 -04:00
simplysanatoriandClaude Opus 5.5 ff9b277a0f DEV.md: lane G switches and mock knobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:43:31 -04:00
simplysanatoriandClaude Opus 5.5 f07a603225 H2.md: weight range as measured
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:42:49 -04:00
simplysanatoriandClaude Opus 5.5 6979840db6 H2 session analysis and lane G fixes (frame/perf/H2.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:42:44 -04:00
simplysanatoriandClaude Opus 5.5 4ae779558a Menus: environment projection layer with the controllers and the pointer beside the screen quad
Second headset session: 'the pointer and controller models didn't render when it was a 2D screen'.
Since lane E the menus are a room-fixed quad and nothing was drawn into the eyes. Now a menu set also
renders the room around the screen into a packed two-eye image (3456x1728, left eye left half): plain
background, the Frame controller models, the laser pointers and their target marks, and the screen's
area left transparent black. The backend shows it as one projection layer with the set's own poses,
under the screen's quad (the quad stays the sharp, reprojected picture), plus the panel quad; never
two projection layers. KARTFRAME_MENU_ENV=over puts it over the quad, alpha-blended (a controller in
front of the screen then stays in front where the compositor keeps the layer order), =0 turns it off.
Same release-after-GPU rule (separate swapchain pair, swapped like the eyes').

Plumbing without new public API: target 1 of a virtual-screen set is the environment image
(OpenXRPresentation::menu_environment), the packet's 'window' flag (meaningless outside immersive
replay) asks Aurora for it, vrui::render draws one eye into its half (viewport) with the hole.

Mock (MOCK_XR_HANDS=1, Frame profile, warm caches, menus 30-90 s): 120.5 sets/s, 0 late, GPU 2.60 /
2.99 ms a set (off: 1.25 / 5.89), encode 0.52 / 1.04 ms (off 0.15 / 0.60); 0 mock errors, every
release checked finished; the dump shows both eyes' controllers and lasers ending on the screen's
hole. Menus to race to menus in one run: 0 errors, 12866 race eye images with matching stamps.
The frame stamp is written for immersive sets only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:42:15 -04:00
simplysanatoriandClaude Opus 5.5 9ede807285 aurora: HUD clip block at the WGSL struct's end, fail open without its marker; hudrace mock tool
Second headset session: 'the HUD wasn't present and would flicker in only'. The screen clip (lane E)
wrote its block at screen_clip_offset(ShaderInfo::uniformSize), but uniformSize is padded to the
64-byte dynamic-offset alignment while the shader's clip fields follow the WGSL struct's own end. The
shader read padding (stale bytes of earlier uniforms), so the 2D draws on the virtual screen were
discarded almost always, now and then passing on leftover bytes. The CPU self-check could not see it
(the '-2' in the headset log is its 'not run yet' value; in the race it read 0 there too).

Found headless: scripted A presses (MOCK_XR_PRESS) take the mock from the title screen to a real
Grand Prix race on Luigi Circuit; with hud_virtual_screen = true and hand_hud = false (the owner's
settings) the eye dumps had no HUD with the clip on or off (KARTFRAME_HUD_CLIP=0 only blanks the CPU
block), the HUD came back with the shader clip removed, and with the fix TIME, LAP, position and map
are on the screen in both eyes (asymmetric Frame-like FOVs; self-check 0.00000).

- ShaderInfo::structSize (before the alignment) places the block on both sides.
- The block carries a marker (gx::ScreenClipMarker) the shader checks; without it nothing is clipped,
  so a future layout slip shows the HUD unclipped instead of hiding it.
- frame/perf/hudrace.sh: the scripted race with the owner's HUD settings, eye images as PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:30:23 -04:00
simplysanatoriandClaude Opus 5.5 e3e091483d Lane G: exact image/pose check (frame stamp), end layers at their locate time, scene clock for VR interpolation
Second headset session: images doubled on head motion although every rate was clean.

- KARTFRAME_FRAME_STAMP=1 (tests): the Vulkan bridge writes the display time an eye set was located
  for and the eye into a corner block of each eye image; the mock (MOCK_XR_STAMP) reads it back at
  every release and compares it with the locate of the poses each projection layer submits.
  Pipelined and inline, with and without prediction jitter: 10064-14318 eye images per run, all
  rendered for the locate of their submitted poses. The image/pose pairing is not the cause.
- A layer is ended with the display time its poses were located for (not this cycle's prediction,
  which on SteamVR need not be one period after the last), keeping display times increasing; repeats
  keep the cycle's time. KARTFRAME_END_TIME=cycle restores the old behaviour. Mock with
  MOCK_XR_PREDICT_JITTER=2: before 0 of 7155 fresh layers carried their poses' time, after 7157 of
  7159. New log line every 10 s: 'vr display times' (prediction step error, display lead, sets shown
  for another time than their cycle's); the eye fields of view are logged once.
- VR interpolation never interpolated with pipelined pacing: a set's display time lies 2-3 refreshes
  after the newest game frame's boundary, so the weight was clamped to 1 for 98 % of race sets (mock:
  between 2-54 of ~1180 per 10 s) and each game frame showed on two refreshes: 60 Hz scenery on a
  120 Hz display, moving things doubled. stereo::SceneClock samples the scene at the display time
  minus a learnt delay (running mean of display - boundary, minus half an interval): mock race now
  80-87 % of sets between two frames. KARTFRAME_SCENE_CLOCK=0 restores the old rule. New aurora line
  'VR interpolation weights (10 s)'.
- xrmock: MOCK_XR_STAMP, MOCK_XR_PREDICT_JITTER, increasing display time check, asymmetric
  Frame-like FOV by default (MOCK_XR_FOV), MOCK_XR_HANDS, scripted presses (MOCK_XR_PRESS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:01:51 -04:00
simplysanatoriandClaude Fable 5.1 044131654f diagnostics test: the pacing clock line at Reset is not the per-second line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 05:04:35 -04:00
simplysanatoriandClaude Fable 5.1 f0243c734a Merge lane E: window sync fix, quad menus, no half-rate flapping, HUD clip, measurements
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 04:57:32 -04:00
simplysanatoriandClaude Opus 5.5 a01ebc81f2 Lane E results (frame/perf/RESULTS.md); perf tools split 1p and split-screen demo races; mock MOCK_XR_RATES
Shipped default (1728, 120 Hz, pipelined, sysmem, HUD screen on), 10-minute mock soak: 1-player race
119.5 sets/s, 0.72 % late, GPU 5.19 / 6.37 ms a set, game 58.3 fps; menus (quad) 120.3, 0.04 %.
2160 at 120: 22 % late; 2160 at 90: 0.05 % late, GPU p99 8.67. Validation layer clean. dlopen
audit, hitch tables, half-rate history, cold-cache and pipeline-seed results in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:56:09 -04:00
simplysanatoriandClaude Opus 5.5 cfc366e9a6 H1 session analysis (frame/perf/H1.md); DEV.md: KARTFRAME_HUD_CLIP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:22:08 -04:00
simplysanatoriandClaude Opus 5.5 bba3286d06 aurora: clip reprojected 2D draws to the frame and the game's scissor
A 2D draw on the race's virtual screen is drawn with the whole eye viewport and scissor, so neither
the frame's edge nor the game's scissor clipped it: Mario Kart's item box, which slides up out of the
TV frame when there is no item, stayed visible above the screen showing the previous item (the old
patch only skipped draws wholly outside the scissor).

The virtual-screen (exactScreenDepth) shader variants now read a 64-byte clip block after the draw's
own uniform (gx::screen_clip_offset of ShaderInfo::uniformSize, carried in UniformReplayLayout): three
rows taking an eye pixel to homogeneous frame NDC on the screen (stereo_replay::window_mask, the
immersive window's mapping, with the pixel-to-NDC step folded in) and the rectangle of the draw's
recorded scissor within the frame. A fragment outside it is discarded, last, after every texture
sample. The block is written per eye by write_stereo_uniform, for the seal and for the late
(display-time) replay; name balloons stay unclipped. KARTFRAME_HUD_CLIP=0 turns it off.

Checks: a self-check every 5 s maps frame points through the 2D draws' own projection and back through
the clip rows ("screen clip self-check 0.00000" in the mock race, hud_virtual_screen on, about 57 2D
draws a frame); mock 0 errors, 0 releases before the GPU finished; validation layer clean through
menus and the demo race start. Not seen yet: the item box itself (the attract-mode demo race shows no
HUD in the eye dumps); first headset check pending.

Also: the immersive display-copy source logs every 10 s how many frames fell back to the full EFB
(H1's virtual screen alternated every frame between the display copy and the fallback), and the 2D
draw count per frame on the virtual screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:21:58 -04:00
simplysanatoriandClaude Opus 5.5 9c8c17b549 perf/run.sh: wait only for game processes, not for shells that mention one
An agent's own "kfbuild.sh . kartframe" command line kept run.sh waiting forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:10:12 -04:00
simplysanatoriandClaude Fable 5.1 d2e23b56b9 privacy-allow: the installer test's scratch home path and upstream's toolchain script comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 03:43:13 -04:00
simplysanatoriandClaude Fable 5.1 57518941c1 Drop the APK-era ignore lines; vrtiming comment names the KartFrame wrapper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 03:41:56 -04:00
simplysanatoriandClaude Sonnet 5.5 75631e296b frame/release.sh: builds kartframe-1.0.0-linux-arm64.tar.gz, runs the boundary and privacy checks, writes the sha256
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 03:38:35 -04:00
simplysanatoriandClaude Sonnet 5.5 7b119ba36f Third party notices: the Linux Arm64 kit, toolchain, translator and sysroot
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 03:37:57 -04:00
simplysanatoriandClaude Sonnet 5.5 f69acf76c4 Release notes, changelog, technical notes and the untested list for 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 03:37:41 -04:00
simplysanatoriandClaude Sonnet 5.5 df0c7140cd README for KartFrame in the GalaxyFrame shape; remove the APK era steam-frame/ directory (kept on branch steam-frame)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 03:36:37 -04:00
simplysanatoriandClaude Opus 5.5 b240caf8ff Frame: menus on a quad layer, every refresh by default, refresh request only for an offered rate
After H1 (projected menus, half-rate flapping 16 down / 15 up in 4 minutes, a 90 Hz request):
- Linux desktop takes the PC rule for OpenXRProjectedMenus: with menu_shader_quality 0 the game
  screen is a quad layer fixed in the room; the first-run guide stays projected. The quad shows only
  the picture (OpenXRVirtualScreenContentRect, as the Android backend), not the square eye image
  with black bands. Mock: menus 12869 quad frames, 0 errors, 0 releases before the GPU finished.
- Half rate: never by default (a late set repeats the retained layer; SteamVR reprojects it).
  KARTFRAME_HALF_RATE=1 always, =auto drops after more than 5 % late over 30 s and never returns
  within the session. The 10 s return rule, the GPU gate and the retry wait are gone.
- xrRequestDisplayRefreshRateFB only for a rate SteamVR offers exactly (H1 offered only 90 and the
  game asked for 90); otherwise the per-app setting decides, logged.
- session-report.py / pacing-parts.py: the presentation lines (immersive-race, virtual-screen) mark
  races too; a race restart logs no new "entered RaceScene" and H1's report counted 150 s of race
  as menus.
- DEV.md knobs paragraph updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:33:27 -04:00
simplysanatoriandClaude Opus 5.5 02a64fd4ab Mock: MOCK_XR_DUMP writes released images as PPM; mockrun KF_MOCKRUN_INTRO=1 shows the first-run guide
MOCK_XR_DUMP=<dir> (Vulkan) copies the image being released at the MOCK_XR_DUMP_AT seconds
(MOCK_XR_DUMP_FRAMES consecutive releases per swapchain) into <dir>/t<s>-sc<id>-<n>.ppm, every
4th pixel, after waiting for the app's queue inside xrReleaseSwapchainImage. The waits cost a few
refreshes at each dump time. KF_MOCKRUN_INTRO=1 leaves welcome/tutorial unseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:33:27 -04:00
simplysanatoriandClaude Opus 5.5 57526ee843 aurora: SDL_SyncWindow only after a window request, not every game frame
window::pump_events called SDL_SyncWindow on every begin_frame. On X11, when the window's geometry is
not what SDL expects (Xvfb without a window manager; gamescope, which sizes windows itself) and nothing
is pending, it ends in SDL_Delay(10): 10 ms of every game frame on the game thread. The race's game
logic takes 13 to 17 ms a frame, so it ran at 38 game frames a second (slow motion: H1's "felt like
half speed" ran at 38.5 in its race). Mock, 1728 sysmem pipelined, demo race:
KARTFRAME_SYNC_WINDOW=1 (old) 38.1 game fps, 72.9 sets/s, 3.7 % late; new 59.8 game fps,
120.6 sets/s, 0.0 % late; menus 58.5 -> 60.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:24:43 -04:00
simplysanatoriandClaude Opus 5.5 106c4112ac Lane E: hitch lines with game-thread CPU, run-queue and begin_frame time; game frame rate log on Linux
- hitch: lines carry game_cpu_ms (the thread's own CPU time), runq_ms (runnable, waiting for a CPU),
  begin_ms (inside aurora_begin_frame), the GPU-lock wait in end_frame and how often begin_frame joined
  the frame worker's preparation; hitches.py classifies by them.
- KARTFRAME_FPSLOG=1 turns on the Android fpslog line on Linux (game frame rate and the producer and
  worker split every 5 s; 2 adds per-pass GPU timestamps). perf/run.sh sets it; pacing-parts.py
  reports game frames/s per part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:10:21 -04:00
simplysanatoriandClaude Opus 5.5 fffb57ca5d Half-rate return: GPU time gate and a doubling retry wait
Lateness measured at half rate says nothing about full rate: at 2160 per eye and 120 Hz (GPU 9 ms a
set) the loop cycled 10 s at half rate and 3 s at full rate with 40 % of sets late. It now returns
early only when the GPU time per set (p90 of the sets shown at half rate) is under 60 % of a
refresh, and otherwise after a retry wait that doubles (10, 20, 40 ... 160 s) whenever a return
dropped again within 10 s. Mock, 2160 at 120 Hz: race late 13.5 % -> 4.2 %, back to full rate in
the menus after the race. The shipped default never drops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:51:06 -04:00
simplysanatoriandClaude Opus 5.5 1ab059b3cd Vulkan bridge: enable VK_EXT_surface_maintenance1 on the instance when offered
Dawn enables VK_EXT_swapchain_maintenance1 on the device whenever the device offers it; the instance
needs VK_EXT_surface_maintenance1 (and VK_KHR_get_surface_capabilities2) for that
(VUID-vkCreateDevice-ppEnabledExtensionNames-01387). With this and the TexCopyConv fix a validation
run through menus and the demo race (200 s, mock, sysmem, pipelined) reports nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:03:32 -04:00
simplysanatoriandClaude Opus 5.5 1d82bb30c9 Lane E instrumentation: game-frame hitch lines, per-set pacing samples, perf harness; log fixes
- aurora: one 'hitch:' line per game frame over 20 ms (end_frame to end_frame) with the process's
  pipeline waits and inline compiles, texture conversion and uploads, and the frame-worker wait in it
  (gfx/hitch_stats.hpp counters).
- pacing: 'vr pacing clock' line (session start on CLOCK_MONOTONIC); KARTFRAME_PACING_SAMPLES=<file>
  writes every set's GPU and encode ms per second for exact per-part percentiles.
- frame/perf: run.sh (a measured mock run under the build lock with an I/O sampler), pacing-parts.py,
  hitches.py, iosample.py. mockrun: KF_MOCKRUN_RENDER_SCALE.
- The display-copy source region is logged once per distinct region (the race alternates between two
  every frame: 30 lines a second); the pacing banner is written in one piece (another thread's output
  landed inside it: 'pacing: 0per-refresh').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:55:44 -04:00
simplysanatoriandClaude Opus 5.5 375e0e3c02 aurora: TexCopyConv blit into RGBA8 conversion textures uses an RGBA8 pipeline
The EFB RAM native readback blits into an RGBA8Unorm conversion texture with the blit pipeline built
for the surface format (BGRA8Unorm on X11/Xvfb): VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08910
in the race. The blit now picks a pipeline matching its destination's format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:49:09 -04:00
simplysanatoriandClaude Fable 5.1 eb8192d93f test-installer: pacing keys read by the integration, sysmem on by default
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 01:33:00 -04:00
simplysanatoriandClaude Fable 5.1 e0570848db Input keeps its state through focus blips under 1 s; Linux clock conversion for controller timing; pacing defaults (auto, 120 Hz, sysmem on) and knobs documented
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 01:30:58 -04:00
simplysanatoriandClaude Opus 5.5 6b1c5aef88 mockrun: keep the user's explicit Vulkan layers (validation) findable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:27:40 -04:00
simplysanatoriandClaude Opus 5.5 e04ff933c5 Log which display-time clock is used
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:01:52 -04:00
simplysanatoriandClaude Opus 5.5 a6bb685f1c session-report: pick the log with the pacing lines, menus and race apart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:41:32 -04:00
simplysanatoriandClaude Opus 5.5 062eb49bf4 Merge native into lane/A
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:41:05 -04:00
simplysanatoriandClaude Opus 5.5 e9eaa6db03 Pacing: auto is pipelined (measured); pacing line format test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:39:44 -04:00
simplysanatoriandClaude Opus 5.5 067dea8194 Mock: exact check that released images were finished; half rate judged by its own deadline
MOCK_XR_BUSY_MS now samples the released image's (host-visible) memory at the
release and again once an empty submission made there completed; a change
means the GPU still wrote it. Unrelated queued work no longer counts.
Negative test (KARTFRAME_NO_GPU_WAIT=1): 7078 of 7110 releases flagged;
with the wait: 0 of 6986. mockrun seeds welcome_complete/tutorial_completed
so the attract demo race starts headless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:35:04 -04:00
simplysanatoriandClaude Opus 5.5 60c50c10f0 Eye sets every refresh: worker serves them during the prepare wait, menu sort cheaper
Aurora's frame worker sat out the producer's prepare permit (about 10 ms of
every game frame) and an eye set published meanwhile waited with it, so
inline sets were late 86 % of the time. With the stereo provider and
interpolation active the sealed frame is now encoded before the permit wait
and the wait serves retained replays. The VR menu sorted ~9000 controller
triangles per eye by moving them and switched material almost every
triangle; it now sorts keys in 5 mm slices by material (encode 6.5 -> 3 ms,
GPU 5 -> 2.6 ms per set in menus). Pacing: 5 s warm-up before the half-rate
rule counts, KARTFRAME_HALF_RATE=0|1, KARTFRAME_PACING_DEBUG timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:23:48 -04:00
simplysanatoriandClaude Opus 5.5 89f797c015 frame_controls test: TARGET_PC like the other input test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:54 -04:00
simplysanatoriandClaude Opus 5.5 5ff60c5131 Privacy allow entries for the kit's sysroot, licences and upstream example IPs; DEV.md on static third-party libs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:04:32 -04:00
simplysanatoriandClaude Sonnet 5.5 45b57aed01 frame/SETUP.md: kit, first-run setup, DT_NEEDED decisions, toolchain and sysroot
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 00:03:43 -04:00
simplysanatoriandClaude Opus 5.5 82c4396378 Mock and tools: timespec conversion, Frame eye size, focus blips, profile, fresh-layer counts
xrmock: XR_KHR_convert_timespec_time (MOCK_XR_TIME_OFFSET_S), 1728x1728
eyes (MOCK_XR_EYE), refresh-rate requests honoured, MOCK_XR_FOCUS_BLIP,
MOCK_XR_PROFILE, frames counted by newly released vs repeated layers.
mockrun: per-checkout mock rebuilt on change, Frame VR defaults seeded,
narrower 'game running' check. trace-compare and session-report adapted to
KartFrame traces and pacing lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:43:55 -04:00
simplysanatoriandClaude Opus 5.5 a87e5cb533 OpenXR pacing: an interpolated eye set every refresh, inline or pipelined
[vr] frame_pacing = auto|inline|pipelined (KARTFRAME_PACING wins): one
xrLocateViews per set for the time it is displayed, screens, panel and
pointer from those poses, late sets never released early (the retained
layer is repeated), half rate only on real misses (>12 of 240, back at <=6
of 600 after 10 s). VR interpolation available on desktop Linux with the
CLOCK_MONOTONIC fallback; projected menus on the Frame; 120 Hz requested
through XR_FB_display_refresh_rate; focus blips under 1 s ignored; the
Frame controller extension enabled. One 'vr pacing:' line a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:43:55 -04:00
simplysanatoriandClaude Opus 5.5 0285a83cfe Vulkan bridge: release eye images only after the GPU finished them
After Aurora's queue.Submit and the bridge's release barrier, an empty
vkQueueSubmit under Dawn's device lock signals a timeline semaphore; a
watcher thread records each set's completion (submit-to-complete time) and
WaitForSubmissionUntil succeeds only once the GPU finished. FinishFrame
refuses to release an unfinished set. KARTFRAME_NO_GPU_WAIT=1 is the
negative test. Adds per-refresh eye-set primitives (BeginCycle, PrepareSet,
EndCycleWithSet, EndCycleRetained, CancelSet, DropSet) for inline and
pipelined pacing, logs the Vulkan layer chain, and reports sets Aurora
rendered from a retained frame. Replay test covers the sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:43:55 -04:00
simplysanatoriandClaude Sonnet 5.5 67c3d87259 tests: frame_controls port test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:28:37 -04:00
simplysanatori 047f48a00a Merge branch 'native' into lane/B 2026-10-12 23:28:33 -04:00
simplysanatoriandClaude Sonnet 5.5 0ceedfd967 Kit: export-kit.py, kitbuild.cmake, make-kit.sh, setup.sh build/seed/all steps, boundary-check globs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:28:33 -04:00
simplysanatoriandClaude Opus 5.5 275f061d1f install.sh: use its data folder variable for the models
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:22:45 -04:00
simplysanatoriandClaude Opus 5.5 7083292ff6 Frame controller models go to KARTFRAME_HOME/UserData/controller_models/frame, where the game reads them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:21:59 -04:00
simplysanatoriandClaude Opus 5.5 31f10a9b7a Merge lane C: Frame controller profile and layout in code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:21:05 -04:00
simplysanatoriandClaude Sonnet 5.5 17d743c2a9 frame: adopt the portable install layout (KARTFRAME_HOME/bin, UserData, portable.txt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:09:29 -04:00
simplysanatoriandClaude Sonnet 5.5 89495f3123 Kit groundwork: Linux kit probe, vendored static abseil/zlib/libpng/freetype, sysroot builder
kartframe_kit_probe is WiiCompiled without the game (like the Quest probe); data_sections_init
and guest_symbol_table stay out of the unity batches on Linux too. aurora/extern builds abseil,
zlib, libpng and freetype from the pinned sources as static libraries on Linux
(AURORA_VENDOR_THIRD_PARTY) so the binary needs only stock libraries. make-sysroot.sh copies the
glibc/libstdc++/libgcc/kernel headers and crt objects from this Frame's packages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:08:40 -04:00
simplysanatoriandClaude Sonnet 5.5 a44d4e757c frame/test-installer.sh: install, uninstall and wrapper tests against a scratch Steam folder and fake vrcmd
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:07:02 -04:00
simplysanatoriandClaude Sonnet 5.5 f681546cdc frame: install.sh, uninstall.sh, collect-logs.sh
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:04:59 -04:00
simplysanatoriandClaude Opus 5.5 19b31449da Frame controls in code: valve/frame_controller bindings, profile latch, Frame models from the user data dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:04:51 -04:00
simplysanatoriandClaude Sonnet 5.5 c174743bdb frame/steam-cef.py: copy of the CEF helper plus an offline shortcuts.vdf mode
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:04:09 -04:00
simplysanatoriandClaude Sonnet 5.5 dd6457fbd4 frame/kartframe.sh: launch wrapper (SteamVR per-app settings, session dir, A/B switches, kit check)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:03:31 -04:00
simplysanatoriandClaude Sonnet 5.5 5dc800e435 frame/Config.frame.toml: Frame defaults for the native build
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:02:41 -04:00
simplysanatoriandClaude Opus 5.5 f52b1d6636 Frame controls: pure layout mapping and ViewButton with table test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:02:39 -04:00
simplysanatoriandClaude Opus 5.5 90a2b673fa Merge lane 0: native Linux arm64 build, patched Dawn, tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:50:14 -04:00
simplysanatoriandClaude Opus 5.5 aa58aeed07 frame/DEV.md: headless runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:49:24 -04:00
simplysanatoriandClaude Opus 5.5 499701b873 Headless runs: KARTFRAME_HEADLESS offscreen desktop target, mockrun.sh with a private Xvfb, xrmock gets the three calls the game makes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:48:33 -04:00
simplysanatoriandClaude Opus 5.5 68739377f2 Linux ExecutableDirectory via /proc/self/exe (platform paths test passes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:31:44 -04:00
simplysanatoriandClaude Opus 5.5 c037e98d9d Vulkan OpenXR replay test builds and runs on Linux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:30:54 -04:00
simplysanatoriandClaude Opus 5.5 c306cd27dd frame/SETUP.md: correct sizes and pin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:20:51 -04:00
simplysanatoriandClaude Opus 5.5 ec1f7a2dba frame/SETUP.md: developer notes for extraction, translation, toolchain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:20:44 -04:00
simplysanatoriandClaude Opus 5.5 47611ce317 frame/DEV.md, mockrun.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:55:47 -04:00
simplysanatoriandClaude Opus 5.5 99843d4659 Linux: SDL without XScrnSaver, OpenXR loader with its vendored jsoncpp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:54:29 -04:00
simplysanatoriandClaude Opus 5.5 4746269655 frame: publish-translator, toolchain and boundary-check scripts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:53:27 -04:00
simplysanatoriandClaude Opus 5.5 0e796d933c Linux build wiring: OpenXR on by default, pinned Vulkan headers and static OpenXR loader, patched Dawn package, ELF blob assembly, relocated shard manifest, kartframe binary name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:46:20 -04:00
simplysanatoriandClaude Opus 5.5 e00242a3b6 Desktop Linux OpenXR: reuse the PC shared-device Vulkan bridge
Build aurora's Vulkan interop on Linux with the patched Dawn linked statically, extend the Vulkan OpenXR backend guards to desktop Linux, and route openxr_integration to it with XR_KHR_vulkan_enable2 and timespec display-time conversion. Re-applied from the earlier native Linux commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:43:03 -04:00
simplysanatoriandClaude Opus 5.5 dddd530d79 frame/tools: copies of xrmock, xrprobe, vrtiming, session-report, privacy-check, golden, imgdiff, ccache/vvl build scripts
Adapted only for paths here: xrmock build.sh headers, privacy-check root, KartFrame lock and ccache dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:52 -04:00
simplysanatoriandClaude Opus 5.5 df2e9939f7 frame/setup.sh: extract and translate steps (nodtool pinned, RMCP01 and sha256 pins checked)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:23 -04:00
simplysanatoriandClaude Opus 5.5 578b9f94f2 frame/build-dawn.sh: patched static Dawn for linux-aarch64
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:41:30 -04:00
372 changed files with 22930 additions and 132548 deletions

No files matched your search

+1 -6
View File
@@ -1,4 +1,4 @@
/generated/
/generated
/Assets/
/assets/
/PulsarPacks/
@@ -87,9 +87,4 @@ output.txt
# Local patched launcher build, reproducible from integrations/wheelwizard-vr.patch.
/integrations/wheelwizard-work/
# Steam Frame build (steam-frame/build.sh): toolchain downloads, objects, APKs and the signing key
/steam-frame/dl/
/steam-frame/out/
/steam-frame/release/
/steam-frame/signing/
__pycache__/
+178 -355
View File
@@ -1,431 +1,254 @@
# Mario Kart Wii VR on the Steam Frame
# KartFrame
This is a fork of [heurazy/Wiicompiled_VR-PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS),
the Mario Kart Wii VR port. It runs standalone on Valve's Steam Frame through Lepton, the Frame's
Android compatibility tool. No PC is involved.
Mario Kart Wii in VR, running natively on the Valve Steam Frame.
Upstream's Quest APK installs on the Frame, but the game crashes as soon as it starts. With the
changes here it starts from the Steam library, runs at 90 Hz, and uses a gamepad-style layout on
the Frame controllers.
KartFrame is a fork of heurazy's [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS), the Mario Kart Wii VR port. The game in it is heurazy's static translation of the Wii game to C++, drawn by the aurora renderer on Dawn (WebGPU) and shown through OpenXR. KartFrame builds it as a Linux Arm64 program that runs in the Frame's own SteamVR. You do not need Lepton, an APK, adb or a PC.
The branch is `steam-frame`. The fork's files are in [`steam-frame/`](steam-frame/), plus six
commits to `runtime/` and `aurora-main/`. Upstream's README is kept as
[README.upstream.md](README.upstream.md). Read it for what the port itself does. This file only
covers the Frame.
You need your own Mario Kart Wii disc. No game data is in this repository or its releases.
**Status, October 2026.** Release `v1.3.1-frame.3` works on my Steam Frame with SteamOS 0.4.5,
Lepton 2.8.14 and SteamVR. Tested: single player, the original and first-person cameras, the
menus and the VR settings panel. Read [Known problems](#known-problems) before you start.
What it does:
**AI use.** The code and the docs in this fork were written with Claude Code (Anthropic's Claude
Opus 5.5 and Claude Fable 5.1), running on the Frame. I chose what to try, tested every build in
the headset and reported what I saw. Nobody from the upstream project has reviewed this fork.
Read the diffs before you trust them.
- Plays Mario Kart Wii in VR on the Steam Frame, in the Frame's SteamVR.
- Renders both eyes on every refresh of the 120 Hz display, from your head position at that refresh, at 1728 pixels per eye.
- Uses the Steam Frame controllers through SteamVR's own controller profile.
- Builds the game on your Frame from your own disc, once, in about 6 minutes.
## Contents
All of that has been played on a Frame. One problem is still being worked on, the double image in races; this pre-release carries the mitigation that removes it. See [The double image](#the-double-image-known-issue).
- [What the fork changes](#what-the-fork-changes)
- [Requirements](#requirements)
- [Install](#install)
- [First run: import the disc and build the game](#first-run-import-the-disc-and-build-the-game)
- [Updating](#updating)
- [Controls](#controls)
- [Settings](#settings)
- [Known problems](#known-problems)
- [Troubleshooting](#troubleshooting)
- [Logs and reporting a problem](#logs-and-reporting-a-problem)
- [Building the APK yourself](#building-the-apk-yourself)
- [Where things are](#where-things-are)
- [How this was made](#how-this-was-made)
- [Licence and credits](#licence-and-credits)
## What this adds to Wiicompiled VR PLUS
## What the fork changes
Upstream runs on Windows and on the Meta Quest. On the Frame that would mean Lepton and an Android layer between the game and SteamVR. KartFrame changes the platform side and leaves the game as it is:
Upstream's design stays the same. The APK carries a "game kit": precompiled runtime objects,
headers and a toolchain. The headset translates your own disc and links `libmain.so` against that
kit. No Nintendo code is in the APK or in this repository.
- A native Linux Arm64 build.
- Dawn's Vulkan backend bound to SteamVR's own Vulkan device, through upstream's own Dawn patch.
- Eye images handed to SteamVR only after the GPU has finished them.
- Both eyes rendered on every refresh from that refresh's head pose, with the game's own VR frame interpolation filling the display rate from the game's 60 frames a second.
- Pipelined frame pacing, with the older single stage mode kept as a switch.
- The Frame controller layout in code, with no SteamVR binding file.
- Simplified models of the Frame's controllers, converted at install from the Frame's own SteamVR files.
- The game's 2D screen shown as a flat quad layer in front of you.
- A first run that extracts your disc, translates the game and compiles it on the Frame with a bundled compiler, using a kit of prebuilt runtime parts.
- A launch wrapper that sets SteamVR's per-app settings (120 Hz, no throttling, no extra prediction, motion smoothing off, 1728 resolution) at every launch and writes a session log folder.
The Steam Frame APK is upstream's v1.3.1 Quest APK with four kit files replaced by ones compiled
from this branch, one patched `classes.dex`, and a new kit fingerprint. When the headset builds
your game, it links it against the Frame objects.
## Status
1. **The app starts.** Upstream's app registers a clipboard listener at startup. Lepton's Android
has no clipboard service, so the app crashed with a `NullPointerException`. That call is
replaced with no-ops in `classes.dex` ([`steam-frame/dexpatch.py`](steam-frame/dexpatch.py)).
Version 0.9.0-alpha.1 is a pre-release. It plays, at 120 Hz and 1728 pixels per eye, through twenty headset sessions so far. Version numbers stay below 1.0.0 until the double image (next section) is fixed at its cause rather than worked around.
2. **90 Hz.** Upstream turns VR frame interpolation off on Android. On the Frame that showed as
judder and ghosting when turning the head. Interpolation is on again, so the eyes render at the
headset's refresh rate with a fresh head pose each frame while the game runs at 60 fps.
SteamVR's OpenXR in Lepton lacks `XR_KHR_convert_timespec_time`, so there is a
`CLOCK_MONOTONIC` fallback. (`runtime/src/vr/openxr_integration.cpp`)
Confirmed in the headset: the native build in SteamVR; 119 to 120 sets of eyes a second in races with under 1 % late; the game at its full 60 frames a second; the menus as a steady flat screen; the race HUD on the virtual screen with correct clipping; the Frame controls, hand steering and the controller models; the first run from a disc; the installer, the library entry, the launch wrapper and the session logs.
3. **Frame controller layout.** The game's OpenXR actions were written for Quest Touch
controllers. On the Frame, SteamVR mapped A to three actions and left B, X and Y unused. The
runtime now also has hand-independent actions (`frame_item`, `frame_drift`,
`frame_look_back`, `frame_start`, `frame_back` and four D-pad actions).
[`steam-frame/bindings_frame_controller.json`](steam-frame/bindings_frame_controller.json)
binds the Frame buttons to them. When SteamVR reports them bound, the game uses the layout in
[Controls](#controls) for the rest of the session. (`runtime/src/vr/openxr_input.cpp`)
Known issues: the Time Trial ghost is flat; full-view 2D effects such as Blooper ink do not cover the whole view; the laser pointer's dot is not visible on the 2D menu screen (the ray is); two and four player races are heavier and not yet measured in the headset. The short list of what is built but not yet confirmed is in [frame/UNTESTED.md](frame/UNTESTED.md).
4. **Menus at 90 Hz.** Upstream's animated menu background held the menus at 37 to 45 fps. On
Android the menus are now always drawn into the eye images, and menu background quality 0
gives a plain dark background. (`openxr_integration.cpp`, `menu_shader_quality = 0`)
## The double image (known issue)
5. **Item box (attempted, does not work yet).** The port now skips 2D HUD draws that lie wholly
outside the game's scissor rectangle. The empty item box still shows in the headset. The
change is harmless and stays in. (`aurora-main/lib/gfx/common.cpp`)
In races, turning your head could show the world twice, one copy about two refreshes of head motion ahead of the other, alternating every refresh, worse in one eye. Menus were always clean, and so was GalaxyFrame on the same SteamVR.
6. **Frame settings.** The launch wrapper seeds `Config.toml` from
[`steam-frame/Config.frame.toml`](steam-frame/Config.frame.toml) on the first launch, and
`install.sh` sets per-app SteamVR values. See [Settings](#settings).
Twenty sessions of measurement, each one eliminating something by a direct test, ended with this: SteamVR's compositor on the Frame takes each eye's most recently handed-over image at its own point inside the refresh, the right eye after the left. The game was handing frames over right where those points sit, so from one refresh to the next the two eyes did not always get the same set. A probe that showed SteamVR the game's own recorded race images was clean in every variation, and logging every compositor frame beside the game's own timeline showed the clean launches keeping one side of the pick-up every frame and the doubled ones flipping.
7. **Launch wrapper.** The app's 2D launcher never shows in the headset on the Frame.
[`steam-frame/wiicompiled-launch.sh`](steam-frame/wiicompiled-launch.sh) waits for the
launcher and starts the game activity itself, once a game built for this APK's kit is
installed. A launch from the library reaches the title screen in about ten seconds.
What this build does about it: the launch wrapper runs a small helper that reads SteamVR's compositor timing and publishes its grid, and the game hands every frame over at a fixed point against that grid, just after the compositor's update (`KARTFRAME_END_PHASE=after`, the default). The doubling is gone with that. What remains, and why this is a mitigation rather than a fix: the right eye runs one set ahead of the left, which reads as the right eye being a touch more sensitive to head movement, and the compositor shows a frame twice now and then. The probe hands its frames over at the same point as the old default and is clean, so the point itself is not the cause; the remaining difference is the 4 to 7 ms of GPU work the game submits right after each hand-over on the GPU the compositor also composes on. That is the next experiment. The records of every session are in `frame/perf/`, and the mechanism is written up in the [technical notes](docs/TECHNICAL_NOTES.md).
8. **Grips in the cockpit.** In the first-person cockpit the grips hold the steering wheel. In
the other cameras they drift. The right bumper drifts in every camera.
If you want the old behaviour to compare, put `KARTFRAME_END_PHASE=off` in `session.env` for one launch.
9. **Frame controller models.** Inside Lepton the app gets no SteamVR render models, so upstream
showed Quest controllers in the menus.
[`steam-frame/frame-controller-models.py`](steam-frame/frame-controller-models.py) converts the
Frame controller models from your own SteamVR install into the game's format and simplifies
them to about 4,000 triangles per hand. `install.sh` runs it, and the launch wrapper copies the
result over the Quest files on each launch. Valve's models are never included in the repository
or the APK.
## What has been done so far
The cameras, hand IK, HUD and Retro Rewind support are upstream's work and are unchanged.
The work since the first headset session, in the order it mattered:
- The game ran at 38 frames a second under gamescope: a window-sync call slept 10 ms every game frame. Fixed; the game runs at its full 60.
- The 2D menus moved from projected panels to a quad layer, as upstream does on PC, which ended the flicker and the 5 to 7 ms of GPU the menus cost.
- An sRGB sink and a cut of the native render pass: copying the eyes to the swapchain went from 2.3 ms to 0.47 ms, which is what lets races hold 120 Hz at 1728.
- Pipelined pacing with the sets of eyes rendered two refreshes ahead, every frame handed over at the same point of the refresh, each set released only after a GPU fence, a retained repeat when a set is late, half rate never by default.
- Turnip's sysmem mode by default: 7 to 8 % faster for this draw-bound game.
- Thread priority through RealtimeKit and a utilisation clamp, so the game thread and the frame worker stay on the big cores.
- The race HUD clipped to its frame on the virtual screen, the thin line beside the map removed, full-view fades only for untextured colour fades.
- Input kept through SteamVR's short focus losses; the refresh rate requested only when SteamVR offers it.
- The double-image work above, with the tools it needed: a mock OpenXR runtime with exact checks, a probe that replays recorded eye images, per-frame compositor timing, panel recordings and their analysis.
## Requirements
- A Steam Frame.
- **Lepton**, installed from Steam (Library > Tools).
- Your own clean PAL Mario Kart Wii disc image, game ID `RMCP01`. ISO works. Upstream's importer
also lists RVZ, WBFS and other formats. Other regions and modified images are rejected.
- About 5 GB free.
- A terminal on the Frame: Konsole in desktop mode, or SSH from another computer.
- Steam running while you install. No root and no `pacman` are needed.
- A Steam Frame with SteamVR, and about 1 GB free for the toolchain and kit, plus the extracted disc.
- Your own clean PAL Mario Kart Wii disc image (RMCP01).
- A terminal on the Frame for the one time setup: SSH, or Konsole from desktop mode.
- Steam running on the Frame while you install, so the installer can add the library entry.
## Install
1. Get the repository:
```sh
git clone -b steam-frame https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame.git
cd Wiicompiled_VR-PLUS-steam-frame
git clone https://github.com/simplysanatori/KartFrame.git
cd KartFrame
```
2. Download `WiiCompiled-VR-SteamFrame-*.apk` from the
[Releases](https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame/releases) page.
Use `v1.3.1-frame.2` or newer. Each release lists the APK's SHA-256 in `SHA256SUMS.txt`:
2. Download `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz` and its `.sha256` file from the [Releases](https://github.com/simplysanatori/KartFrame/releases) page. Check the download and install it:
```sh
sha256sum ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
sha256sum -c kartframe-0.9.0-alpha.1-linux-arm64.tar.gz.sha256
frame/install.sh ~/Downloads/kartframe-0.9.0-alpha.1-linux-arm64.tar.gz
```
You can also build the APK yourself ([Building the APK yourself](#building-the-apk-yourself)).
The APK contains no game data.
The installer unpacks the program into `~/Games/KartFrame/app`. It adds **KartFrame** to your Steam library as a non-Steam shortcut, or updates the entry if it is there. It converts the Frame's controller models from SteamVR's own files. The script talks to Steam through its local debugging port, which SteamOS has on by default.
3. Run the installer with Steam running. Use the `install.sh` from the same release tag or newer:
3. Build the game from your disc, once:
```sh
steam-frame/install.sh ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
~/Games/KartFrame/app/setup.sh /path/to/MarioKartWii.iso
```
The installer:
Setup checks that the disc is RMCP01, extracts it to `~/.local/share/KartFrame/game`, translates the game, compiles it with the bundled compiler and installs the finished program. It took about 6 minutes on my Frame. It can be run again after an interruption and picks up where it stopped.
- copies the APK, the launch wrapper and the seed config to `~/Games/WiiCompiledVR-SteamFrame/`;
- converts the Frame controller models from SteamVR;
- adds a **Mario Kart Wii VR** entry to the Steam library, set to the Lepton compatibility
tool, through Steam's client API;
- writes the controller binding and the 90 Hz settings into SteamVR for that entry;
- prints the entry's appid. You need it for the first run.
4. Put the headset on and launch **KartFrame** from your Steam library.
Running it again updates the same entry.
To update, download the new tarball and run `frame/install.sh` again, then `setup.sh` again if it tells you the kit changed. Your game files, saves and settings stay where they are.
## First run: import the disc and build the game
The installer changes nothing outside the folders listed under [Files](#files), the Steam shortcut and the controller models. To remove KartFrame, run `~/Games/KartFrame/app/tools/uninstall.sh`. It keeps your data folder unless you pass `--purge`.
1. Put the disc image in `~/Documents/` on the Frame. Lepton shows that folder to the app as
`/sdcard/Documents`.
## First run
2. Put the headset on and launch **Mario Kart Wii VR** from the library. The first launch takes
about a minute while Lepton installs the APK. You will only see SteamVR's empty space. The
app's launcher is running but cannot be seen. Leave it running.
The launch wrapper checks that the installed program was built for the installed kit. If it was not, it stops and writes the reason to the session log; run `setup.sh` again.
3. In the terminal, import the disc. Replace `<appid>` with the number from `install.sh` and
`MarioKart.iso` with your file name:
On the very first start the game creates its own settings file and a fresh Wii save area. The game makes the Wii licence in-game on first boot.
```sh
LEPTON=~/.local/share/Steam/steamapps/common/Lepton/lepton
$LEPTON exec steamlaunch-<appid> am start-foreground-service -n org.wiicompiled.quest/.launcher.GameSetupService \
-d file:///sdcard/Documents/MarioKart.iso --es task ExtractDisc
```
This checks the disc and extracts it into the app's data folder. It takes a few minutes.
4. Build the game:
```sh
$LEPTON exec steamlaunch-<appid> am start-foreground-service -n org.wiicompiled.quest/.launcher.GameSetupService \
--es task BuildGame
```
The headset translates and compiles the game against the Frame kit. This took about 13
minutes on my Frame. Progress goes to
`~/.local/share/Steam/steamapps/compatdata/<appid>/external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Logs/build_*.log`.
You can also follow it over adb:
```sh
/usr/lib/android-sdk/platform-tools/adb connect localhost:5555
/usr/lib/android-sdk/platform-tools/adb -s localhost:5555 logcat | grep -iE 'GameSetup|DiscExtract|GameBuild|WiiCompiled'
```
5. When the build log says the game is installed, quit the app (Steam button, then exit) and
launch it again. From now on every launch goes straight into the game. On the first start the
game asks you to pick a camera with the pointer. Your choices are saved to `Config.toml`.
## Updating
- **From an older Frame release.** Run the newer `install.sh` with the new APK. It updates the
same library entry. Lepton reinstalls the app on the next launch. A new release usually has a
new kit fingerprint, so run the `BuildGame` command from the first run again. The disc import,
saves and settings are kept. After an update the Frame controller models show from the second
launch, because the app unpacks its own files again first.
- **From upstream's Quest APK on the Frame.** If your library entry has the same name, `install.sh`
points it at the Frame APK. The extracted disc stays, but the old game was built against
upstream's kit and will not run. Run `BuildGame` again. The disc import is not needed.
- **Newer upstream versions.** This fork is built on upstream v1.3.1. A newer upstream APK has a
different kit, and the patches need porting first. `build.sh` stops if the kits do not match.
If the game cannot find the disc files, the setup did not finish. Run `setup.sh status` to see which step is missing.
## Controls
`install.sh` installs the binding for the library entry. The game switches to this layout by
itself when SteamVR reports the Frame actions as bound. The layout is the same in every camera.
The game sees a Wii wheel and pad. The Frame controllers are mapped like this.
The Frame's left controller has the D-pad and View button. The right controller has A, B, X, Y
and the Menu button.
| Steam Frame | In the game |
|---|---|
| Right trigger, **A** | Accelerate |
| Left trigger | Reverse |
| **B** | Brake, and back in menus |
| **X**, left bumper | Use item |
| **Y** | Look back |
| Both grips, right bumper | Drift |
| Left D-pad | D-pad |
| Right **Menu** | Start |
| Left **View** | Back. Held for half a second, it opens the VR settings panel. Pressed while the panel is open, it closes it |
| Frame controller | Racing | Game menus |
| --- | --- | --- |
| Left stick | Steer | Move |
| Right trigger | Accelerate | Confirm |
| A | Accelerate | Confirm |
| B | Brake and reverse | Back |
| Left trigger | Brake and reverse (works with accelerate held) | Back |
| X | Use item (hold to drag it behind) | |
| Left bumper | Use item | |
| Y | Look back | |
| Right bumper | Hop and drift | |
| Grips (either) | Hop and drift. In the first-person cockpit: hold the wheel | |
| D-pad | GameCube D-pad. Up does a trick, or a wheelie on a bike | D-pad |
| Right stick | Same as the D-pad (push up for a trick) | |
| Right stick click | Next camera: original, first person, diorama | |
| Menu | Pause (Start) | Start |
| View, tap | Back | Back |
| View, hold 0.5 s | Open the VR settings panel | Open the VR settings panel |
Notes:
- The View tap is sent when you let go, so a hold never also goes back.
- The right stick click does not change the camera during the race intro. It can be turned off
with `first_person_toggle_click = false` under `[vr]` in `Config.toml`.
- **Cockpit wheel.** In the first-person camera, reach for the wheel and squeeze a grip to hold
it, then turn it to steer. Let go to steer with the stick again. With `hand_steering = false`
under `[vr]`, the grips drift in every camera and you always steer with the stick.
- **VR settings panel.** Hold View for half a second to open it. Point with a controller. The hand
whose trigger you pulled last does the pointing. A trigger or A selects, either stick scrolls,
and a press of View closes the panel. While the panel is open the game gets no input.
- **Controller models.** The menus show Frame controllers when `install.sh` converted them. The
tutorials still name Quest buttons. Use the table above.
- To change the layout, use SteamVR's binding UI. The binding is a normal SteamVR binding and shows
up under Manage Controller Bindings for the entry.
Hand steering: in the first-person cockpit the grips hold the wheel, and the right bumper drifts there. Elsewhere the grips drift.
## Settings
The launch wrapper copies these values into `Config.toml` on the first launch only. The VR
settings panel edits the same file. Upstream's README lists all settings under "VR settings".
Hold left View for half a second. The VR settings panel opens. Everything on it is also in `~/.local/share/KartFrame/UserData/Config.toml`, which is seeded from `Config.frame.toml` on the first run. The keys that matter:
| `Config.toml` | Seed value | What it does |
| --- | --- | --- |
| `[vr] frame_interpolation_fps` | `1` | Render at the headset's refresh rate (90 Hz), interpolated from the game's 60 fps. |
| `[vr] hand_steering` | `true` | Grips hold the wheel in the first-person cockpit. |
| `[vr] menu_shader_quality` | `0` | Plain dark menu background. Higher values cost frame rate on the Frame. |
| `[vr] render_scale` | `1.0` | Eye render scale. |
| `[video] resolution_multiplier` | `1.0` | Game resolution. 1.5 dropped frames on the Frame. |
| `[vr] hand_hud` | `false` | Upstream's hand HUD, off. |
| `[diagnostics] openxr_logging` | not set (off) | Logs frame timing once a second to `console.log`. Leave it off for play. It caused small hitches. |
| Key | Default | What it does |
|---|---|---|
| `[vr] render_scale` | 1.0 | Eye size relative to the size SteamVR's resolution setting gives the game (1728 per eye at SteamVR's 100 %) |
| `[vr] frame_interpolation_fps` | 1 | 1 renders at the headset's refresh rate, interpolated from the game's 60 frames a second |
| `[vr] frame_pacing` | `"auto"` | `auto`, `inline` or `pipelined`. Auto picks pipelined |
| `[vr] refresh_rate` | 120 | The refresh rate the game asks for |
| `[vr] menu_shader_quality` | 0 | 0 is a plain dark background behind the menus |
| `[vr] hand_steering` | true | Grips hold the wheel in the first-person cockpit |
| `[vr] hud_virtual_screen` | true | Menus and the race HUD on a virtual screen |
| `[vr] hud_distance_meters` | 2.0 | How far away that screen is |
| `[vr] hud_width_meters` | 2.4 | How wide it is |
| `[video] resolution_multiplier` | 1.0 | Resolution of the flat desktop mirror |
`install.sh` also sets these SteamVR values for the entry: `preferredRefreshRate 90`,
`framesToThrottle 0` and `additionalFramesToPredict 0`. They are in the `steam.app.<appid>`
section of `~/.config/openvr/config/steamvr.vrsettings`.
I do not ship a default below 1728 per eye. Lower values of `render_scale` work, but the picture gets soft.
Environment variables for the scripts:
Switches for testing go in `~/.local/share/KartFrame/session.env`, one `NAME=value` per line, read at the next launch only:
| Variable | Script | Effect |
| --- | --- | --- |
| `WCF_FRAME_MODELS=0` | `install.sh` | Keep the Quest controller models. |
| `WCF_FRAME_MODEL_TRIANGLES` | `frame-controller-models.py` | Triangle target per hand (default 4000). |
| `WCF_SHORTCUT_NAME` | `install.sh` | Library entry name (default `Mario Kart Wii VR`). |
| `WCF_STAY_IN_LAUNCHER=1` | `wiicompiled-launch.sh` | Leave the invisible launcher up. Put it before the wrapper in the entry's launch options. |
| `WCF_TOOLS` | `build.sh` | Toolchain folder (default `~/.local/opt/android-build`). |
| `WCF_SIGNING` | `build.sh` | Signing key folder (default `steam-frame/signing`). |
| `WCF_LOG_DIR` | `collect-logs.sh` | Where the log archive goes (default your home folder). |
| Name | What it does |
|---|---|
| `KARTFRAME_PACING` | `auto`, `inline` or `pipelined`, overriding the config |
| `KARTFRAME_REFRESH_HZ` | 90 or 120, the rate asked of SteamVR |
| `KARTFRAME_SYSMEM` | 0 turns off the GPU's direct memory rendering mode (`TU_DEBUG=sysmem`); it is on by default |
| `KARTFRAME_FDM_LAYER` | 0 disables Valve's foveation layer in the Vulkan chain, to compare |
| `KARTFRAME_END_PHASE` | `after` (default), `before`, or `off`: when in the refresh each frame is handed to SteamVR, against the compositor grid the wrapper's helper publishes. `off` hands over at the wake-up, which doubles the image |
| `KARTFRAME_END_DELAY_US` | A fixed delay after the wake-up instead of the grid, in microseconds; also the fallback when the grid is missing (4000) |
| `KARTFRAME_VRTIMING_FRAMES` | 1 logs every compositor frame to `vrframes.log` in the session folder |
| `KARTFRAME_HOME` | The data folder, if it is not `~/.local/share/KartFrame` |
Two Android properties help when testing. Set them over adb before the game starts:
`setprop debug.wiicompiled.vr_interp 0|1|72|90|120` overrides the interpolation setting for one
launch, and `setprop debug.wiicompiled.fpslog 1` logs the game's frame rate.
## Performance
## Known problems
Measured on my Frame, from the session logs:
- **The empty item box stays on screen** after the roulette slides up. Change 5 does not fix it.
- **Grip steering in the cockpit is rough.** Grabbing and steering work, but upstream tuned the
grab range and steering response for Quest controllers. They have not been adjusted. Set
`hand_steering = false` to steer with the stick.
- **Small hitches.** Races hold 90 fps on the counter, with brief hitches now and then. The logs
do not show a cause yet.
- **The 2D launcher is invisible** in the headset, so the disc import and build use the terminal
commands above. Upstream's "Select disc image" button needs a file picker Lepton does not have.
- **`v1.3.1-frame.1` does not work.** Its game build fails at the final link on the headset. Use
`v1.3.1-frame.2` or newer.
- **Not tested:** Retro Rewind, online play, the diorama camera for more than a minute, USB wheels,
multiplayer, passthrough, hand tracking, Lepton 3.x.
- **Not for Quest.** Use upstream's APK on a Quest. The interpolation change is what upstream
turned off on Quest, and the Frame layout only works with the SteamVR binding.
| | |
|---|---|
| Display | 120 Hz, 1728 x 1728 per eye |
| Races | 119 to 120 sets of eyes a second, 0.2 to 1 % late, 2 to 3 % retained repeats |
| Menus | 110 to 113 sets a second (the quad layer and the intro screens) |
| GPU per set | about 5 ms in races (about 7 with the hand-over timing of this build, since the next set starts before the hand-over), 1.5 to 2.5 ms in menus |
| Game thread | 60 frames a second; about 8 % of race frames take 20 to 33 ms (the game's own course loading and item logic); a 144 ms hitch at race start |
| First run | about 6 minutes from the disc: extract 1 minute, translate 50 seconds, compile 5 minutes |
What the build does, so you know what the numbers mean:
- At 120 Hz there are about 8.3 ms per refresh, and about 7 ms of that is the GPU's for both eyes beside SteamVR's compositor.
- Each refresh locates the head once and renders both eyes from that pose, two refreshes ahead of the refresh that shows them.
- A set of eyes is handed to SteamVR only after the GPU has finished it, at a fixed point of the refresh against the compositor's own clock. If a set is not finished in time, the previous set is shown again and the miss is counted.
- Every second, the game logs one `vr pacing:` line with the refresh rate, the sets rendered, interpolated, late and missed, and GPU and encode times; every 10 seconds an `end phase:` line with the achieved hand-over phase. `report.txt` in each session folder sums them up, and `tools/session-report.py` lays several sessions side by side.
## Files
| What | Where |
|---|---|
| Program, wrapper, kit, toolchain, translator | `~/Games/KartFrame/app` |
| Game files, built game, saves, settings | `~/.local/share/KartFrame` (`game/`, `build/`, `bin/`, `UserData/`) |
| Controller models | `~/.local/share/KartFrame/UserData/controller_models/frame` |
| Session logs | `~/.local/share/KartFrame/sessions/<date-time>/` (`launch.log`, `stdout.log`, `game.log`, `vrtiming.log`, `report.txt`); `sessions/latest` points at the newest |
| Downloaded tools | `~/.cache/kartframe` (nodtool, fetched by `setup.sh`) |
| One launch's test settings | `~/.local/share/KartFrame/session.env` |
The game does not write to `~/.local/share/WiiCompiled`.
## Troubleshooting
- **`install.sh` says Steam's debugging port is not reachable.** Steam must be running. The script
talks to Steam on `127.0.0.1:8080`, which SteamOS enables by default.
- **The launch shows only SteamVR's empty space.** No game is built yet, or the built game is for
another kit. `/tmp/wiicompiled-launch.log` says which. Run the `BuildGame` command.
- **The disc is rejected.** The importer accepts only a clean PAL `RMCP01` image.
- **The game build fails at the last step with `undefined symbol: std::__ndk1::__hash_memory`.**
You have the `v1.3.1-frame.1` APK. Install `v1.3.1-frame.2` or newer and build again.
- **The buttons act like a Quest layout** (A does several things, X and Y do nothing). The SteamVR
binding is not applied. Run `install.sh` again, then check Manage Controller Bindings for the
entry. The game's `console.log` has the line `Steam Frame button layout active` when the Frame
layout is on.
- **The menus show Quest controllers.** Launch once more after an update. If they still show,
run `install.sh` again and look for a warning about the controller models.
- **The menus or races stutter.** Check `Config.toml` for `menu_shader_quality = 0`,
`resolution_multiplier = 1.0` and `openxr_logging` off. Higher resolution settings dropped
frames on the Frame.
- **Settings are not saved after you copied `Config.toml` in by hand.** Run
`chmod 664 Config.toml` on it. The app cannot write it otherwise.
- **Anything else.** Collect the logs (next section) and look at `console.log` and `lepton.log`.
- **`install.sh` says Steam's debugging port is not reachable.** Start Steam and run it again.
- **The game does not start and `launch.log` says it refuses.** Read the reason in `~/.local/share/KartFrame/sessions/latest/launch.log`. If it names a kit mismatch, run `setup.sh` again.
- **`setup.sh` says the disc is not Mario Kart Wii RMCP01.** Only the clean PAL disc is supported.
- **SteamVR's per-app settings were not applied.** `launch.log` lists each one and what SteamVR holds after the write. They are only set when the game is started from the Steam library.
- **The world doubles when you turn your head in a race.** That is the known issue above. Check `launch.log` says `KARTFRAME_END_PHASE=after` and `game.log` has `end phase: locked to the compositor grid`; if it says it fell back to the fixed delay, the helper did not get SteamVR's timing (it needs SteamVR's `libopenvr_api.so`, which the wrapper finds under `/opt/steamvr`).
- **The right eye feels more sensitive to head movement than the left.** The known issue's remainder: the right eye runs one set ahead. Not fixed yet.
- **Motion is uneven.** `report.txt` shows late and repeated sets per second. A few percent is normal; more usually means something else is using the GPU (a recording, a stream).
## Logs and reporting a problem
## Reporting a problem
| Log | Where |
| --- | --- |
| Launch wrapper | `/tmp/wiicompiled-launch.log` |
| Lepton | `~/.local/share/Steam/logs/lepton-steamlaunch-<appid>.log` |
| Game, one folder per run | `.../WiiCompiledOpenXRVR/Logs/<run>/console.log` |
| Disc import and build | `.../WiiCompiledOpenXRVR/Logs/build_*.log` |
`...` is `~/.local/share/Steam/steamapps/compatdata/<appid>/external/Android/data/org.wiicompiled.quest/files`.
To report a problem:
1. Run `steam-frame/collect-logs.sh` (or `steam-frame/collect-logs.sh <appid>`). It writes
`~/wiicompiled-frame-logs-<date>.tar.gz` with the logs above, your `Config.toml` and a short
summary (SteamOS, Lepton, APK and kit versions). It masks IP and MAC addresses, UUIDs, host
names, Wi-Fi names, serial numbers, Steam IDs, email addresses, tokens and certificate blocks.
Lepton's log includes the Frame's kernel log, which lists hardware serial numbers. Read the
files before you post them. The masking can miss things.
2. Open an issue on this repository. Give the release tag, what you did, what happened and what
you expected, and attach the archive.
3. If the problem also happens with upstream's APK on a Quest, report it upstream instead.
Pull requests are welcome. Say what you tested and on which headset. Upstream's
[CONTRIBUTING.md](CONTRIBUTING.md) rules apply. Never attach game files or disc images.
## Building the APK yourself
Open an [issue](https://github.com/simplysanatori/KartFrame/issues) with what you did, what you expected and what happened, and the output of:
```sh
steam-frame/build.sh # downloads upstream's v1.3.1 APK and checks its SHA-256
steam-frame/build.sh path/to/WiiCompiled-VR-Quest3-v1.3.1.apk
~/Games/KartFrame/app/tools/collect-logs.sh
```
The output is `steam-frame/release/WiiCompiled-VR-SteamFrame-<git tag>.apk`. On the Frame this
takes about 20 seconds once the toolchain is downloaded. It needs `curl`, `tar`, `python3` and
`openssl`, which SteamOS has. It needs no root.
It gathers the newest session's logs into one archive and masks IP and MAC addresses, host names, network names, serial numbers, Steam ids, email addresses and user names in paths. The masking is a best effort. Read the files before you attach them.
1. It downloads Android NDK r29 into `~/.local/opt/android-build/`. Google ships no arm64 Linux
NDK, so on the Frame it uses [HomuHomu833's r29 build](https://github.com/HomuHomu833/android-ndk-custom).
On x86_64 it uses Google's r29, which is untested. The NDK must be r29, the version the kit
was built with. Objects built with r30 fail the final link on the headset.
2. It compiles the two runtime unity groups that hold the patched files, and Aurora's
`common.cpp`, with upstream's Android flags. The third-party headers they need are in
[`steam-frame/build-deps/`](steam-frame/build-deps/) (see its `NOTICE.md`).
3. It compares each new object with the original in the upstream kit. The exported symbols must
match, and the new object may not import a C++ symbol the original does not. Otherwise it stops.
4. [`make-apk.py`](steam-frame/make-apk.py) writes the APK: the four kit files, the new kit
fingerprint in `kit.json`, the patched `classes.dex`, zip alignment and an APK Signature
Scheme v2 signature made with `openssl`. No Java, apksigner or zipalign is needed.
## Build from source
The signing key is created in `steam-frame/signing/` on first use. Keep it. An APK signed with
another key will not install over the previous one. `steam-frame/signing/` is ignored by git, so
the key never ends up in the repository. `make-apk.py verify file.apk` checks an APK's signature.
The game itself (`libmain.so`) is never built here. The headset builds it from your disc.
## Where things are
- **Library entry:** a non-Steam shortcut whose executable is the APK and whose compatibility tool
is `lepton`. Its launch options run `~/Games/WiiCompiledVR-SteamFrame/wiicompiled-launch.sh`.
The empty file `lepton-show-flatscreen` next to the APK lets Lepton show the 2D window.
- **App data:** `~/.local/share/Steam/steamapps/compatdata/<appid>/`.
`external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/` holds `Config.toml`,
`DATA` (the extracted disc), saves and `Logs/`.
`internal/org.wiicompiled.quest/files/game/base/` holds the built `libmain.so` and `game.json`.
- **SteamVR settings:** the `steam.app.<appid>` section of
`~/.config/openvr/config/steamvr.vrsettings`. While SteamVR runs, the installer writes them with
`/opt/steamvr/bin/linuxarm64/vrcmd --set-settings-int`.
- **Shared folders:** Lepton's Android sees `~/Documents`, `~/Downloads` and `~/Videos` as
`/sdcard/Documents`, `/sdcard/Download` and `/sdcard/Movies`.
- **Shell access:** `lepton exec steamlaunch-<appid> <command>` runs a command in the running
container. `adb connect localhost:5555` reaches it too.
## How this was made
The work took about a week in October 2026, in Claude Code sessions on the Frame. I tested each
build in the headset and described what I saw. The AI read the logs and the code and made the
changes.
- The first attempt used upstream's v1.3 Quest APK. The clipboard crash showed in logcat and was
patched. The game ran, but it flickered and the head tracking was shaky.
- Upstream's Windows build under Proton and a native Linux ARM64 build were also tried. The Proton
build was too slow under x86 emulation. Both were dropped. This fork is the Android path only.
- Turning interpolation back on, with the clock fallback, fixed most of the flicker and judder.
The controller layout, the menu change and the item box attempt followed.
- The first release was built with NDK r30 and failed to link on the headset. `build.sh` now uses
r29 and checks the C++ imports of each object.
- A higher resolution for sharper menus caused dropped frames and was rolled back.
- After the first releases came the grips change and the Frame controller models. The full
models slowed the menus to 45 to 60 fps. With the mesh simplifier the menus hold 90 Hz.
## Licence and credits
GPL-3.0, as upstream ([LICENSE](LICENSE)). The fork's additions use the same licence.
Third-party headers used for the build are listed with their licences in
[`steam-frame/build-deps/NOTICE.md`](steam-frame/build-deps/NOTICE.md). The rest of the
third-party code is listed in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
- The VR port is [heurazy's Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS),
built on [iChris4's Wiicompiled VR](https://github.com/iChris4/Wiicompiled_VR) and the
WiiCompiled static recompilation project. It uses
[Aurora](https://github.com/encounter/aurora), Dawn, SDL3 and OpenXR. The full credits are in
[README.upstream.md](README.upstream.md).
- The Android NDK build for arm64 Linux is
[HomuHomu833's](https://github.com/HomuHomu833/android-ndk-custom).
- Lepton, SteamVR and the Frame controller models are Valve's. The models are read from your own
SteamVR install and are not redistributed.
- Mario Kart Wii is Nintendo's. Nothing of Nintendo's is in this repository or in the APK. You
need your own disc.
- Steam Frame port by simplysanatori, written with Claude Code as described above.
See the [technical notes](docs/TECHNICAL_NOTES.md). The patched Dawn is built once on the Frame, then the game and the release kit. `frame/tools/privacy-check.sh` checks the tree, the history and a release tarball for personal data before publishing, and `frame/boundary-check.sh` checks that nothing from the disc is in a release.
## Support
If you would like more Steam Frame ports, you can [buy me a coffee](https://ko-fi.com/sana748653). It pays for the Claude API credits these ports are built with, spent as carefully as I can. The credit for the games themselves belongs to the upstream projects named above.
KartFrame exists because of heurazy's [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS). If you want to support someone, support them first: star and follow it, report what you find there, and send improvements to the game upstream.
If you would like more Steam Frame ports, you can also [buy me a coffee](https://ko-fi.com/sana748653). It pays for the Claude API credits these ports are built with, spent as carefully as I can.
## Credits
- Built by [simplysanatori](https://github.com/simplysanatori).
- [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS) by heurazy, which this is built on, and heurazy's [Mario Kart Wii VR port](https://github.com/heurazy/mario-kart-wii-VR-port) before it.
- [Wiicompiled VR](https://github.com/iChris4/Wiicompiled_VR) by iChris4, the OpenXR port of WiiCompiled that Wiicompiled VR PLUS started from, and the WiiCompiled translator that turns the Wii game into C++.
- [aurora](https://github.com/encounter/aurora), the renderer, and [Dawn](https://dawn.googlesource.com/dawn), the WebGPU implementation under it.
- [SDL3](https://github.com/libsdl-org/SDL) and the [OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK).
- [nod and nodtool](https://github.com/encounter/nod) by encounter, which extracts the disc.
- [LLVM](https://llvm.org/), the compiler bundled for the first run, and the [.NET runtime](https://github.com/dotnet/runtime), which the translator carries.
- Valve, for the Frame controller models. They are converted on your Frame from your own SteamVR files and never redistributed.
- [framecorder](https://github.com/simplysanatori/framecorder), my headset recorder, which recorded the panels the double-image work was measured on.
- bigmak94's [GalaxyQuest](https://github.com/bigmak94/GalaxyQuest) and the GalaxyFrame work, for what I learned about pacing for SteamVR and for the test tools (`xrmock`, and `xrprobe`, GPLv3).
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) has the full list with licences.
Mario Kart Wii, Nintendo and their logos are trademarks of Nintendo. This is an unofficial fan project. It is not affiliated with or endorsed by Nintendo, Valve or the upstream author.
## Licence
GPLv3, as upstream: see [LICENSE](LICENSE). Third-party parts keep their own licences: see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) and the `licenses/` folder. The test probe in `frame/tools/xrprobe` is GPLv3 and is not part of the game. No Nintendo game data is included.
Upstream's own README is kept as [README.upstream.md](README.upstream.md).
## How this was made
I wanted Mario Kart Wii on my Frame without Lepton, so I started from Wiicompiled VR PLUS and had it built natively for SteamVR: a Linux Arm64 build, Dawn's Vulkan backend on SteamVR's own device, pacing written for the Frame's compositor, the Frame controllers in code, and a first run that builds the game from your own disc on the Frame itself.
This is vibe coded, with manual review throughout. I wrote it with Claude Code, using Anthropic's Fable 5.1, Opus 5.5 and Sonnet 5.5 models running on the Frame itself, in parallel lanes with a mock OpenXR runtime so that almost everything could be proven without putting the headset on. The double image took twenty headset sessions of about a minute each, every one of them a test of one thing, and the records of those are in `frame/perf/`.
Developer notes and what was learned about SteamVR on the Frame are in the [technical notes](docs/TECHNICAL_NOTES.md).
+39
View File
@@ -235,6 +235,45 @@ unmodified, with their license texts, in the installer's `licenses/` folder.
---
## Native Linux Arm64 build for the Steam Frame (KartFrame)
The KartFrame release tarball carries a kit, a toolchain and a translator so that the game can be
built on the user's own Steam Frame from the user's own disc. Pins and the build of each are in
`frame/SETUP.md`; license texts for the kit are in the kit's `licenses/` folder.
| Component | Version | License | Upstream |
| --- | --- | --- | --- |
| LLVM clang, lld, llvm-ar, llvm-ranlib (portable toolchain, linux-aarch64) | 22.1.8 | Apache-2.0 WITH LLVM-exception | <https://github.com/llvm/llvm-project> |
| CMake (portable toolchain) | 4.3.3 | BSD-3-Clause | <https://cmake.org/> |
| Ninja (portable toolchain) | 1.13.2 | Apache-2.0 | <https://ninja-build.org/> |
| .NET 8 runtime (inside the self-contained `Translator.Cli`) | 8.0 | MIT | <https://github.com/dotnet/runtime> |
| YamlDotNet (inside `Translator.Cli`) | as pinned by the translator | MIT | <https://github.com/aaubry/YamlDotNet> |
| nodtool (disc image extraction, fetched on the user's machine at setup, not redistributed) | v2.0.0-alpha.10 | MIT OR Apache-2.0 | <https://github.com/encounter/nod> |
| Dawn and Tint, SDL3, OpenXR-SDK with its loader, abseil, zlib, libpng, FreeType, Dear ImGui, {fmt}, Zstandard, SQLite, Tracy (prebuilt static in the kit) | as listed above | as listed above | as listed above |
The kit also carries a minimal sysroot made unmodified from the packages of the Frame it was built
on, used only to compile and link the user's game against, with their license texts in the kit
(`sysroot/LICENSES`):
| Component | License | Upstream |
| --- | --- | --- |
| glibc headers, crt objects, link-time copies of `libc`, `libm` and the dynamic loader | LGPL-2.1-or-later | <https://www.gnu.org/software/libc/> |
| GCC runtime: libstdc++ headers and library, `libgcc`, `libgcc_eh`, `libgcc_s`, `crtbegin` and `crtend` objects | GPL-3.0-or-later WITH GCC-exception (Runtime Library Exception) | <https://gcc.gnu.org/> |
| Linux kernel API headers | GPL-2.0 WITH Linux-syscall-note | <https://www.kernel.org/> |
The built game links the user's own glibc, libstdc++ and libgcc_s dynamically. Complete corresponding
source for the glibc and GCC parts is available from the upstream projects at the versions recorded
in `sysroot/LICENSES`, and this project will supply it on request for the exact version shipped.
Test tools in `frame/tools` (`xrmock`, `xrprobe`) are not part of the game. `xrprobe` is GPLv3
(`frame/tools/xrprobe/LICENSE`). The release tarball carries only `vrtiming.py` and `session-report.py`.
Valve's Steam Frame controller models are read from the user's own SteamVR install at install time,
simplified and written to the user's data folder. They are never copied into the repository or a
release.
---
## Development-only dependencies
Not redistributed in any release artifact.
+4
View File
@@ -39,6 +39,10 @@ if (AURORA_ENABLE_GX)
# of producing unresolved interop references.
if (CMAKE_SYSTEM_NAME STREQUAL Windows)
target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp lib/webgpu/vulkan_win32_interop.cpp)
elseif (CMAKE_SYSTEM_NAME STREQUAL Linux)
# Desktop Linux OpenXR uses the same shared-device Vulkan bridge as Windows,
# with the patched Dawn linked statically instead of loaded from a DLL.
target_sources(aurora_core PRIVATE lib/webgpu/vulkan_win32_interop.cpp)
endif ()
# Android/Vulkan counterpart: the AHardwareBuffer stereo bridge. The file
# compiles to C ABI stubs on every other platform so the runtime's OpenXR
+18
View File
@@ -9,7 +9,24 @@ else ()
set(_USE_SHARED OFF)
endif ()
# KartFrame: a shipped Linux binary must not depend on libraries that are not part of the stock
# SteamOS image (abseil, libpng, freetype and their dependency chains were installed by hand on the
# development machine). With this on, the four packages below are built from the pinned sources
# below, as static libraries, instead of being found on the system.
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(_AURORA_VENDOR_DEFAULT ON)
else ()
set(_AURORA_VENDOR_DEFAULT OFF)
endif ()
option(AURORA_VENDOR_THIRD_PARTY "Build abseil, zlib, libpng and freetype from source (static) instead of using system copies" ${_AURORA_VENDOR_DEFAULT})
if (AURORA_VENDOR_THIRD_PARTY)
set(_USE_SHARED OFF)
endif ()
function(aurora_find_package_global)
if (AURORA_VENDOR_THIRD_PARTY AND ARGV0 MATCHES "^(absl|ZLIB|PNG|Freetype)$")
return()
endif ()
set(_PREV_FIND_PACKAGE_TARGETS_GLOBAL ${CMAKE_FIND_PACKAGE_TARGETS_GLOBAL})
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(${ARGV})
@@ -171,6 +188,7 @@ if (AURORA_ENABLE_GX)
if (NOT TARGET Freetype::Freetype)
message(STATUS "aurora: Fetching Freetype")
set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE)
set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE)
set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE)
set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE)
FetchContent_Declare(Freetype
@@ -17,6 +17,10 @@ bool aurora_vulkan_win32_set_targets_with_panel(uint64_t token, const AuroraD3D1
const AuroraD3D12StereoTarget* panel);
bool aurora_vulkan_win32_cancel(uint64_t token);
bool aurora_vulkan_win32_disable();
// Only inside the submitted callback, on the frame worker: true when the set being
// reported was rendered from a retained game frame (VR frame interpolation, the
// projected menu) instead of a newly sealed one.
bool aurora_vulkan_win32_submitting_retained();
void* aurora_vulkan_win32_lock_queue();
void aurora_vulkan_win32_unlock_queue(void* guard);
#ifdef __cplusplus
File diff suppressed because it is too large. Load diff
+19 -1
View File
@@ -529,9 +529,27 @@ void GXCopyTex(void* dest, GXBool clear) {
handle.handle->isEfbCopy = true;
handle.handle->lastEfbCopyFrame = currentFrame;
}
// R2 (lane H): at the internal resolution the minimap's half-scale bake copy (440x440 logical -> 220x220
// IA4) carries a faint white stripe (alpha 1/15) in its last two or three logical columns, which the
// race HUD shows as a thin vertical line along the map's right edge; trimming the last 4 render pixels
// of the sampled source removed it, 3 did not (eye dumps; what in the bake draws it is not known).
// Half-scale copies at a fractional scale are sampled without their last 3 logical columns (a 0.7 %
// stretch of a 440 px bake). KARTFRAME_COPY_TRIM=0 restores the full source; =<n> trims n columns.
static const float copyTrimLogical = [] {
const char* env = std::getenv("KARTFRAME_COPY_TRIM");
return env != nullptr ? std::max(0.0f, static_cast<float>(std::atof(env))) : 3.0f;
}();
auto sampleRect = sourceRect.sampleRect;
if (copyTrimLogical > 0.f && g_gxState.texCopyHalfScale && !aurora::gx::is_depth_format(texCopyFmt) &&
g_gxState.texCopySrc.width > 0) {
const float scale = sampleRect.z() / static_cast<float>(g_gxState.texCopySrc.width);
if (std::fabs(scale - std::round(scale)) > 0.01f && g_gxState.texCopySrc.width > 8.f * copyTrimLogical) {
sampleRect = {sampleRect.x(), sampleRect.y(), sampleRect.z() - copyTrimLogical * scale, sampleRect.w()};
}
}
aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth,
clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt,
&sourceRect.sampleRect, g_gxState.texCopyHalfScale, &copyFilter, forceOpaqueAlpha,
&sampleRect, g_gxState.texCopyHalfScale, &copyFilter, forceOpaqueAlpha,
sourceRect.sampleRect.w() / std::max<float>(g_gxState.texCopySrc.height, 1.0f),
(g_gxState.copyClamp & GX_CLAMP_TOP) != 0, (g_gxState.copyClamp & GX_CLAMP_BOTTOM) != 0,
persistentCopy);
File diff suppressed because it is too large. Load diff
+58 -1
View File
@@ -8,6 +8,7 @@
#include <cstring>
#include <array>
#include <memory>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>
@@ -331,6 +332,10 @@ struct StereoReplayFrame {
// The immersive window (AuroraStereoFrame::window): each eye is masked to the
// 2D layer's screen after its last draw (gfx/window_mask.hpp).
bool window = false;
// Lane P: the packet these eye transforms came from (AuroraStereoFrame::displayTimeNanos and
// frameToken), recorded per eye where the eye uniforms are filled (EyeSceneRecord::pose*).
uint64_t displayTimeNanos = 0;
uint64_t frameToken = 0;
};
void end_frame(const wgpu::CommandEncoder& cmd);
@@ -374,6 +379,38 @@ bool has_late_stereo_replay(const SealedFrame& frame) noexcept;
bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
float weight);
// Lane L: the scene state each eye of the set being encoded actually used, for the frame stamp and the
// per-set log line (KARTFRAME_FRAME_STAMP, KARTFRAME_FRAME_LOG). Filled where the eye uniform ranges are
// written (prepare_late_stereo_replay at the set's sample time, or the seal's own eye pair) and where the
// eye replays bind them (render_stereo_eye). The frame worker's own record; run_stereo_sink takes it.
struct EyeSceneRecord {
bool valid = false;
bool late = false; // filled at the set's sample time (interpolation), else at the seal (current only)
bool anchored = false;
float weight = 1.f; // the clamped weight the fill used
bool continuous = false; // set by the caller: a previous game frame was available
uint32_t previousFrame = 0, currentFrame = 0; // set by the caller: the pair's game (logical) frames
uint32_t fillOffset = 0, fillSize = 0; // the uniform buffer bytes this fill wrote
// Per eye, perspective draws: filled from the previous/current pair (weight < 1), filled with the
// current transforms only; drawn with the recorded uniform (no eye range), drawn with an eye range
// outside this fill (one some other set or frame wrote).
std::array<uint16_t, 2> interpolated{}, current{}, recorded{}, outside{};
// Lane P: per eye, the head pose its uniform fill actually wrote: the packet (display time, token) and
// the eye transform (viewFromCenter) of the first draw filled for that eye, and how many later draws of
// the eye were filled with a different packet or transform.
std::array<bool, 2> posed{};
std::array<uint64_t, 2> poseTime{}, poseToken{};
std::array<Mat3x4<float>, 2> pose{};
std::array<Mat4x4<float>, 2> projection{}; // the eye's frustum (StereoReplayEye::projection) of that draw
std::array<uint16_t, 2> poseMixed{};
// The GPU uniform probe (aurora.cpp UniformProbe): per eye, the first filled range's offset in
// g_uniformBuffer and the first bytes the fill wrote there, read back after the set's eyes.
std::array<bool, 2> probed{};
std::array<uint32_t, 2> probeOffset{}, probeSize{};
std::array<std::array<uint8_t, 64>, 2> probeBytes{};
};
EyeSceneRecord& eye_scene_record() noexcept;
// Encode a sealed frame. Never touches the producer-visible recording state,
// so this may run concurrently with the producer's FIFO drains.
// `nativeRenderLastPass` limits the passes that do render work (texture bakes still run for every
@@ -474,14 +511,34 @@ enum class GpuTimingCategory : uint8_t {
DepthPeek, // the depth snapshot compute pass
Snapshot, // presentation snapshot and its ImGui pass
Present, // the desktop presentation copy
// Segments inside an eye's render passes and around the headset submission, timed with in-pass
// timestamps (gpu_timing_mark) where the device has them (KARTFRAME_FPSLOG=2).
EyeScene, // perspective (world) draws
EyeHud, // 2D draws on the virtual screen
EyeFade, // 2D draws across the whole eye (full-view fades, unclaimed 2D)
EyeEfbEffect, // 2D draws sampling the game's own EFB copies (bloom, blur and the like)
EyeClear, // clear draws
EyeOther2D, // 2D draws that are none of the above
Cockpit, // the VR cockpit and the immersive window's mask
Upload, // the late replay's uniform upload
Sink, // copies into the headset's swapchain images
HudLayer, // the race's 2D layer rendered once into its own image
MenuEnv, // the menus' environment layer
Count,
};
void gpu_timing_set_enabled(bool enabled) noexcept;
bool gpu_timing_enabled() noexcept;
// Opens the current frame's query slot; a frame whose slot is still being read back is skipped.
void gpu_timing_begin_frame() noexcept;
// kind 0: a game frame's cycle (native render and its eye set), 1: a refresh's eye set.
void gpu_timing_begin_frame(uint8_t kind = 0) noexcept;
// Timestamp writes for one pass of the open frame, or nullptr when timing is off or exhausted.
const wgpu::PassTimestampWrites* gpu_timing_pass(GpuTimingCategory category) noexcept;
// True when the open frame is timed and the device writes timestamps inside passes.
bool gpu_timing_marks_enabled() noexcept;
// A timestamp starting a segment of `category` inside a timed pass, or on the encoder between passes
// (no category: ends the segment). Segments end at the next timestamp of the frame in GPU order.
void gpu_timing_mark(const wgpu::RenderPassEncoder& pass, GpuTimingCategory category) noexcept;
void gpu_timing_mark(const wgpu::CommandEncoder& encoder, std::optional<GpuTimingCategory> category) noexcept;
// Resolves the open frame's queries on `encoder`, which must be the frame's last submission.
void gpu_timing_end_frame(wgpu::CommandEncoder& encoder) noexcept;
// After that submission: starts the readback of the resolved queries.
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <atomic>
#include <chrono>
#include <cstdint>
// Game-frame hitch accounting (lane E, KartFrame). Cheap always-on counters: a few relaxed atomic
// adds where the work happens; end_frame() (aurora.cpp) logs one "hitch:" line for every game frame
// whose interval since the previous end_frame exceeds 20 ms, with the deltas of these counters over
// that interval. frame/perf/hitches.py reads the lines. Counters are shared by the game thread, the
// frame worker and the pipeline workers, so a delta says what happened in the process during the
// slow frame, not necessarily on the game thread.
namespace aurora::gfx::hitch {
inline std::atomic<uint64_t> g_pipelineWaitNs{0}; // render/worker threads blocked on a queued pipeline
inline std::atomic<uint64_t> g_pipelineSyncNs{0}; // pipelines compiled inline (not on a pipeline worker)
inline std::atomic<uint64_t> g_pipelineSyncCount{0};
inline std::atomic<uint64_t> g_texConvertNs{0}; // CPU texture conversion (GX formats to RGBA8 etc.)
inline std::atomic<uint64_t> g_texConvertBytes{0}; // bytes of GX texture data converted
inline std::atomic<uint64_t> g_texUploadBytes{0}; // bytes staged for texture uploads
inline uint64_t now_ns() noexcept {
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
}
// Adds the scope's duration to a counter.
class ScopedNs {
public:
explicit ScopedNs(std::atomic<uint64_t>& counter) noexcept : counter_(counter), start_(now_ns()) {}
~ScopedNs() { counter_.fetch_add(now_ns() - start_, std::memory_order_relaxed); }
ScopedNs(const ScopedNs&) = delete;
ScopedNs& operator=(const ScopedNs&) = delete;
private:
std::atomic<uint64_t>& counter_;
uint64_t start_;
};
} // namespace aurora::gfx::hitch
+5
View File
@@ -4,6 +4,7 @@
#include "../gx/pipeline.hpp"
#include "../fs_helper.hpp"
#include "../sqlite_utils.hpp"
#include "hitch_stats.hpp"
#include "../webgpu/gpu.hpp"
#include <algorithm>
@@ -505,7 +506,10 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con
}
if (syncCreate) {
const uint64_t syncStart = hitch::now_ns();
const auto pipeline = cb();
hitch::g_pipelineSyncNs.fetch_add(hitch::now_ns() - syncStart, std::memory_order_relaxed);
hitch::g_pipelineSyncCount.fetch_add(1, std::memory_order_relaxed);
{
std::scoped_lock guard{g_pipelineMutex};
removedPending = remove_pending_pipeline(g_priorityPipelines, hash);
@@ -1423,6 +1427,7 @@ static bool wait_pipeline_impl(PipelineRef ref, wgpu::RenderPipeline& pipeline,
// A persistent resolve is the last chance to produce its texture, so timing out here corrupts it
// permanently. Only a pass marked requireReadyPipelines takes this path.
}
const hitch::ScopedNs waited(hitch::g_pipelineWaitNs);
g_pipelineCv.wait(lock, finished);
}
const auto it = g_pipelines.find(ref);
+14 -1
View File
@@ -375,6 +375,11 @@ static wgpu::Sampler g_nearestSampler;
static wgpu::Sampler g_linearSampler;
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
static wgpu::RenderPipeline g_blitPipeline;
// The blit's destination is either a render texture (surface format) or a conversion texture
// (RGBA8Unorm, e.g. the EFB RAM native readback). A pipeline must match the attachment's format
// (VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08910 when the surface is BGRA8Unorm), so the
// RGBA8Unorm destinations get their own pipeline.
static wgpu::RenderPipeline g_blitPipelineRgba8;
static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble,
const wgpu::BindGroupLayout& bindGroupLayout) {
@@ -491,6 +496,11 @@ void initialize() {
g_blitPipeline = create_pipeline(
{GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"},
ShaderPreamble, g_bindGroupLayout);
g_blitPipelineRgba8 =
webgpu::g_graphicsConfig.surfaceConfiguration.format == wgpu::TextureFormat::RGBA8Unorm
? g_blitPipeline
: create_pipeline({GX_TF_RGBA8, FragPassthrough, wgpu::TextureFormat::RGBA8Unorm, "TexCopyConv Blit RGBA8"},
ShaderPreamble, g_bindGroupLayout);
for (const auto& conv : ConvPipelines) {
g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout);
if (conv.outputFormat != to_wgpu(conv.fmt)) {
@@ -522,6 +532,7 @@ void initialize() {
void shutdown() {
g_pipelines.clear();
g_blitPipeline = {};
g_blitPipelineRgba8 = {};
g_bindGroupLayout = {};
g_depthBindGroupLayout = {};
g_nearestSampler = {};
@@ -604,6 +615,8 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
execute(cmd, req, it->second);
}
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { execute(cmd, req, g_blitPipeline); }
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
execute(cmd, req, req.dst->format == wgpu::TextureFormat::RGBA8Unorm ? g_blitPipelineRgba8 : g_blitPipeline);
}
} // namespace aurora::gfx::tex_copy_conv
+20
View File
@@ -1,4 +1,5 @@
#include "texture_convert.hpp"
#include "hitch_stats.hpp"
#include "../internal.hpp"
#include "../gx/gx_fmt.hpp"
@@ -554,7 +555,25 @@ static ByteBuffer BuildRGBA8FromCMPR(uint32_t width, uint32_t height, uint32_t m
return buf;
}
// convert_texture_palette calls convert_texture: only the outermost call is counted.
static thread_local int t_convertDepth = 0;
struct ConvertTimer {
uint64_t start = 0;
explicit ConvertTimer(size_t bytes) noexcept {
if (t_convertDepth++ == 0) {
hitch::g_texConvertBytes.fetch_add(bytes, std::memory_order_relaxed);
start = hitch::now_ns();
}
}
~ConvertTimer() {
if (--t_convertDepth == 0) {
hitch::g_texConvertNs.fetch_add(hitch::now_ns() - start, std::memory_order_relaxed);
}
}
};
ConvertedTexture convert_texture(u32 format, uint32_t width, uint32_t height, uint32_t mips, ArrayRef<uint8_t> data) {
const ConvertTimer hitchTimer(data.size());
const uint32_t maxMips = max_texture_mip_count(width, height);
const uint32_t safeMips = std::clamp(mips, 1u, maxMips);
if (safeMips != mips) {
@@ -679,6 +698,7 @@ GXTexFmt tlut_texture_format(GXTlutFmt format) noexcept {
ConvertedTexture convert_texture_palette(u32 textureFormat, uint32_t width, uint32_t height, uint32_t mips,
ArrayRef<uint8_t> textureData, GXTlutFmt tlutFormat, uint16_t tlutEntries,
ArrayRef<uint8_t> tlutData) {
const ConvertTimer hitchTimer(textureData.size());
const auto indices = convert_texture(textureFormat, width, height, mips, textureData);
if (indices.data.empty()) {
return {};
+70 -1
View File
@@ -18,6 +18,7 @@
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <optional>
@@ -2515,6 +2516,71 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
} else if (prim == GX_POINTS) {
instanceCount = vtxCount;
}
const auto drawScreenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride);
// Fades are blended over the picture (the respawn's black filter, wipes, the scene fader). Opaque and
// logic-op full-frame quads are not: the race draws such quads every frame (backgrounds, a logic-op
// pass), and one shown across the eye blacked it out. Nor is a textured one (lane I, 90 Hz headset
// session): a full-frame quad sampling a texture composites a picture (the Time Trial ghost from its
// offscreen copy, the player's character), which pinned across the eye from frame space doubled
// against the world on every head turn. Only untextured, constant-colour quads (no texgens) are fades.
const bool fadeCandidate =
g_gxState.colorUpdate && g_gxState.blendMode == GX_BM_BLEND && g_gxState.numTexGens == 0;
const auto invisible_blend = [] {
const auto& st = g_gxState;
if (st.blendMode != GX_BM_BLEND || st.blendFacSrc != GX_BL_SRCALPHA || st.blendFacDst != GX_BL_INVSRCALPHA ||
st.depthUpdate || st.numTexGens != 0 || st.numTevStages != 1 || st.vtxDesc[GX_VA_CLR0] != GX_NONE) {
return false;
}
const auto& stage = st.tevStages[0];
if (stage.alphaPass.a != GX_CA_ZERO || stage.alphaPass.b != GX_CA_ZERO || stage.alphaPass.c != GX_CA_ZERO ||
stage.alphaPass.d != GX_CA_RASA || stage.alphaOp.op != GX_TEV_ADD || stage.alphaOp.bias != GX_TB_ZERO ||
stage.alphaOp.outReg != GX_TEVPREV || stage.channelId != GX_COLOR0A0 || stage.tevSwapRas != GX_TEV_SWAP0) {
return false;
}
const auto& alphaChannel = st.colorChannelConfig[GX_ALPHA0];
return alphaChannel.matSrc == GX_SRC_REG && !alphaChannel.lightingEnabled &&
st.colorChannelState[GX_ALPHA0].matColor[3] < 2.f / 255.f;
};
const auto quad_signature = [] {
const auto& tex = get_texture(GX_TEXMAP0);
const uint32_t w = tex.ref ? std::min<uint32_t>(tex.texObj.width(), 2047) : 0;
const uint32_t h = tex.ref ? std::min<uint32_t>(tex.texObj.height(), 2047) : 0;
return uint64_t(g_gxState.blendMode & 3) | (uint64_t(g_gxState.blendOp & 15) << 2) |
(uint64_t(g_gxState.numTexGens & 15) << 6) | (uint64_t(w) << 10) | (uint64_t(h) << 21) |
(uint64_t(tex.ref && tex.ref->isEfbCopy) << 32);
};
// Large 2D rectangles in VR (lane H: which full-frame fades the game draws), a few distinct kinds a run.
if (drawScreenRect && stereo) {
const auto& vp = g_gxState.renderViewport;
if (drawScreenRect->width * drawScreenRect->height >= 0.5f * vp.width * vp.height) {
static std::mutex loggedMutex;
static std::vector<uint64_t> logged;
const auto& c = g_gxState.colorChannelState[0].matColor;
const uint64_t key = (uint64_t(g_gxState.blendMode) << 0) | (uint64_t(g_gxState.colorUpdate) << 4) |
(uint64_t(g_gxState.alphaUpdate) << 5) | (uint64_t(g_gxState.depthUpdate) << 6) |
(uint64_t(g_gxState.depthCompare) << 7) | (uint64_t(g_gxState.numTevStages) << 8) |
(uint64_t(g_gxState.numTexGens) << 16) | (uint64_t(drawScreenRect->width) << 24) |
(uint64_t(drawScreenRect->height) << 40) ^
(uint64_t(g_gxState.colorChannelState[GX_ALPHA0].matColor[3] * 255.f) << 56);
std::lock_guard lock(loggedMutex);
if (logged.size() < 200 && std::find(logged.begin(), logged.end(), key) == logged.end()) {
logged.push_back(key);
Log.info("VR large 2D rectangle: {:.0f}x{:.0f} at ({:.0f}, {:.0f}) of a {:.0f}x{:.0f} viewport; blend {} colour "
"{} alpha {} depth update {} compare {} func {}; {} TEV stages, {} texgens; material colour "
"{:.2f} {:.2f} {:.2f} {:.2f}; blend factors {}/{}; TEV0 alpha {} {} {} {} ka {} chan {}; alpha channel src {} "
"light {} {:.2f}; vertex colour {}; invisible {}",
drawScreenRect->width, drawScreenRect->height, drawScreenRect->left, drawScreenRect->top, vp.width,
vp.height, int(g_gxState.blendMode), g_gxState.colorUpdate, g_gxState.alphaUpdate,
g_gxState.depthUpdate, g_gxState.depthCompare, int(g_gxState.depthFunc), g_gxState.numTevStages,
g_gxState.numTexGens, c[0], c[1], c[2], c[3], int(g_gxState.blendFacSrc), int(g_gxState.blendFacDst),
int(g_gxState.tevStages[0].alphaPass.a), int(g_gxState.tevStages[0].alphaPass.b),
int(g_gxState.tevStages[0].alphaPass.c), int(g_gxState.tevStages[0].alphaPass.d),
int(g_gxState.tevStages[0].kaSel), int(g_gxState.tevStages[0].channelId),
int(g_gxState.colorChannelConfig[GX_ALPHA0].matSrc), g_gxState.colorChannelConfig[GX_ALPHA0].lightingEnabled,
g_gxState.colorChannelState[GX_ALPHA0].matColor[3], int(g_gxState.vtxDesc[GX_VA_CLR0]), invisible_blend());
}
}
}
gfx::push_draw_command(DrawData{
.pipeline = pipeline,
.exactScreenDepthPipeline = stereo && screen ? pipelineState.screenRef : 0,
@@ -2532,7 +2598,10 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
.instanceCount = instanceCount,
.bindGroups = bindGroups,
.dstAlpha = pipelineState.dstAlpha,
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
.screenRect = drawScreenRect,
.fadeCandidate = fadeCandidate,
.invisible = stereo && invisible_blend(),
.quadSignature = drawScreenRect ? quad_signature() : uint64_t{0},
});
// What the next draw must match to be allowed to fold into this one.
nativeWheelLastDrawCommand = gfx::get_last_draw_command<DrawData>();
+17
View File
@@ -70,6 +70,20 @@ constexpr u32 MaxPnMtx = (GX_PNMTX9 / 3) + 1;
constexpr u32 MaxPostexMtx = MaxPnMtx + MaxTexMtx;
constexpr u32 MaxIndexAttr = 12; // VA_POS -> VA_TEX7
constexpr u32 MaxUniformSize = 3840;
// Virtual-screen (exactScreenDepth) variants read a clip block after the draw's own uniform: three rows
// mapping an eye pixel to homogeneous frame NDC on the 2D layer's screen, and the rectangle (the draw's
// recorded scissor within the frame) its fragments must fall in. 0: the uniform has no room.
// Takes ShaderInfo::structSize, the WGSL struct's end, NOT uniformSize: that one is padded to the
// 64-byte offset alignment, and a block written there was read by the shader 16 to 48 bytes too
// early (padding), which discarded nearly every HUD draw on the Frame (second headset session).
// ScreenClipMarker in the w row's unused fourth component tells the shader the block is real; without
// it the shader does not clip (a layout slip then shows the HUD unclipped instead of hiding it).
constexpr u32 ScreenClipBytes = 64;
constexpr float ScreenClipMarker = 31337.0f;
constexpr u32 screen_clip_offset(u32 uniformSize) noexcept {
const u32 offset = (uniformSize + 15u) & ~15u;
return offset + ScreenClipBytes <= MaxUniformSize ? offset : 0u;
}
extern wgpu::BindGroup g_emptyTextureBindGroup;
@@ -757,6 +771,9 @@ struct ShaderInfo {
std::bitset<MaxIndTexMtxs> usedIndTexMtxs;
UniformMatrixLayout matrixLayout;
u32 uniformSize = 0;
// The WGSL Uniform struct's own size, before uniformSize is padded to the dynamic-offset
// alignment: where a virtual-screen variant's clip block fields start (screen_clip_offset).
u32 structSize = 0;
bool usesFog : 1 = false;
bool lightingEnabled : 1 = false;
u8 lineMode : 2 = 0;
+9
View File
@@ -30,6 +30,15 @@ struct DrawData {
// Recorded before merging so VR can omit desktop split masks without
// modifying GX.
std::optional<gfx::stereo_replay::SubviewRect> screenRect;
// Writes colour, blended or opaque (not a logic-op or depth-only quad): a candidate for a full-frame
// fade shown across the whole eye.
bool fadeCandidate = false;
// Leaves the picture unchanged: blended with the source alpha, untextured, one TEV stage passing a
// material alpha under 2/255 through, no depth write (an idle screen filter). The eye replay skips it.
bool invisible = false;
// Diagnostics (KARTFRAME_LOG_2D_QUADS): blend | logic << 2 | texgens << 6 | tex0 width << 10 | height << 21 |
// tex0 is an EFB copy << 32.
uint64_t quadSignature = 0;
};
constexpr uint32_t GXPipelineConfigVersion = 22;
+20
View File
@@ -1694,6 +1694,26 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
" return out;";
}
if (config.exactScreenDepth && screen_clip_offset(info.structSize) != 0) {
// A reprojected 2D draw uses the whole eye viewport and scissor, so the frame edge and the game's
// scissor no longer clip it (Mario Kart's item box sliding up out of the frame stayed visible
// above the screen). The clip block (gx.hpp, written per eye by gfx/common.cpp) maps the pixel
// back to frame NDC; outside the recorded rectangle the fragment is dropped. Checked last, after
// every texture sample, so it cannot affect uniformity.
uniBufAttrs +=
"\n screen_clip_u: vec4f,"
"\n screen_clip_v: vec4f,"
"\n screen_clip_w: vec4f,"
"\n screen_clip_rect: vec4f,";
fragmentFn +=
"\n let screen_clip_p = vec3f(in.pos.xy, 1.0);"
"\n let screen_clip_h = vec3f(dot(ubuf.screen_clip_u.xyz, screen_clip_p), "
"dot(ubuf.screen_clip_v.xyz, screen_clip_p), dot(ubuf.screen_clip_w.xyz, screen_clip_p));"
"\n let screen_clip_uv = screen_clip_h.xy / max(screen_clip_h.z, 1e-20);"
"\n if (ubuf.screen_clip_w.w == 31337.0 && (screen_clip_h.z <= 0.0 || "
"any(screen_clip_uv < ubuf.screen_clip_rect.xy) || any(screen_clip_uv > ubuf.screen_clip_rect.zw))) { discard; }";
}
const auto shaderSource = fmt::format(R"""(
fn bswap32(v: u32, le: bool) -> u32 {{
if (le) {{
+2
View File
@@ -503,6 +503,7 @@ ShaderInfo build_shader_info(const ShaderConfig& config) noexcept {
}
info.uniformSize += info.sampledTexCoords.count() * sizeof(Vec4<float>);
info.uniformSize += info.sampledTextures.count() * sizeof(Vec4<float>);
info.structSize = info.uniformSize;
info.uniformSize = gfx::align_uniform(info.uniformSize);
if (info.uniformSize > MaxUniformSize) {
Log.fatal("Uniform size exceeds maximum: {} > {}", info.uniformSize, MaxUniformSize);
@@ -769,6 +770,7 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
.perspective = perspective,
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
.nativeEfbEffect = !perspective && samplesRecentEfbCopy && (samplesReducedEfbCopy || blends),
.uniformSize = info.structSize,
};
if (!perspective || !frame_interpolation_active()) {
+3
View File
@@ -21,6 +21,9 @@ struct UniformReplayLayout {
float worldHudDepth = 0.0f;
float worldHudAnchorX = 0.0f;
float worldHudAnchorY = 0.0f;
// ShaderInfo::structSize (the WGSL struct's end, before the range is padded to the offset alignment):
// where a virtual-screen variant's clip block starts (gx::screen_clip_offset).
uint32_t uniformSize = 0;
};
struct UniformRanges {
+38
View File
@@ -16,11 +16,37 @@ struct EyeImage {
wgpu::TextureFormat format = wgpu::TextureFormat::Undefined;
};
// Lane L: the scene state one eye's draws used (see gfx::EyeSceneRecord), for the frame stamp.
struct EyeScene {
bool valid = false; // an immersive replay whose eye uniforms were filled for this set
bool late = false; // filled at the set's sample time (interpolation), else at the seal
bool anchored = false;
bool continuous = false; // a previous game frame was available
float weight = 1.f;
uint32_t previousFrame = 0, currentFrame = 0; // game (logical) frames of the transform pair
uint32_t set = 0; // sets encoded so far
uint16_t interpolated = 0, current = 0, recorded = 0, outside = 0;
// Lane P: the head pose this eye's uniform fill wrote (gfx::EyeSceneRecord::pose*): the packet's
// display time (steady ns, 0 none) and token, the eye's orientation recovered from the transform written
// (x, y, z, w; the inverse of the view rotation), and draws filled with another packet or transform.
uint64_t poseTime = 0;
uint64_t poseToken = 0;
std::array<float, 4> poseOrientation{0.f, 0.f, 0.f, 1.f};
// The tangents of the eye frustum that fill used: left, right, down, up.
std::array<float, 4> poseTangents{};
uint16_t poseMixed = 0;
};
struct SinkFrame {
uint64_t frameToken = 0;
uint32_t logicalFrame = 0;
AuroraStereoFrameMode mode = AURORA_STEREO_FRAME_IMMERSIVE_REPLAY;
// Rendered from the retained game frame (interpolation, projected menu), not a new one.
bool retained = false;
std::array<EyeImage, AURORA_STEREO_EYE_COUNT> eyes{};
// The packet's display time (steady clock ns): the time its eye poses were located for.
uint64_t displayTimeNanos = 0;
std::array<EyeScene, AURORA_STEREO_EYE_COUNT> scene{};
};
// Runs synchronously on the frame worker after both eye replays have been
@@ -40,4 +66,16 @@ using SubmitCallback = void (*)(const SinkFrame& frame, void* userdata) noexcept
void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept;
inline void set_sink(SinkCallback callback, void* userdata) noexcept { set_sink(callback, nullptr, userdata); }
// Optional, for a sink that can take the eyes as render targets (the Vulkan OpenXR bridge): the
// swapchain images acquired for `frameToken`, wrapped as textures of `format` and `width` x `height`,
// so the eyes render straight into them and the sink only stamps them instead of copying 2 x 12 MB a
// set (lane I: 1.3 ms of GPU a set at 1728). False when the set cannot take them (no pending set
// for that token, other sizes or formats); the eyes then render into Aurora's own targets and the sink
// copies as before. Once it returned true the set can no longer be canceled: the caller must hand
// the same textures to the sink (SinkFrame::eyes) for this token.
using DirectTargetsCallback = bool (*)(uint64_t frameToken, wgpu::TextureFormat format, uint32_t width,
uint32_t height, std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out,
void* userdata) noexcept;
void set_direct_targets(DirectTargetsCallback callback, void* userdata) noexcept;
} // namespace aurora::stereo
+36
View File
@@ -3,6 +3,8 @@
#include <algorithm>
#include <cstdint>
#include <cmath>
namespace aurora::stereo {
// The current scene belongs to the VI presentation boundary. Delaying scene
// motion by one guest interval lets every display sample fall between known
@@ -14,4 +16,38 @@ inline float interpolation_weight(uint64_t displayTime, uint64_t boundary, uint6
return 0.0f;
return static_cast<float>(std::min(static_cast<double>(displayTime - boundary) / static_cast<double>(interval), 1.0));
}
// interpolation_weight assumes the display time falls inside the current frame's interval. On a
// headset it does not: a set is located for a display time one or two refreshes ahead (more with
// pipelined pacing), so display minus boundary lay beyond one interval for almost every set and the
// weight stuck at 1: each game frame was shown on two refreshes at 120 Hz and moving scenery doubled.
// SceneClock samples the scene at the display time minus a delay it learns: over a game interval the
// sets' (display - boundary) sweep one interval, so their running mean minus half an interval puts
// every sample between the previous and the current frame. A slow mean (about 2 s at 120 Hz) keeps
// the delay steady, so the scene advances uniformly; a jump (a new display lead, a different game
// rate) restarts it. One instance per caller thread.
class SceneClock {
public:
float weight(uint64_t displayTime, uint64_t boundary, uint64_t interval) noexcept {
if (displayTime == 0 || boundary == 0 || interval == 0)
return 1.0f;
const double x = static_cast<double>(displayTime) - static_cast<double>(boundary);
const double period = static_cast<double>(interval);
if (!m_valid || std::abs(x - m_mean) > 2.0 * period || std::abs(period - m_interval) > 0.25 * m_interval) {
m_valid = true;
m_mean = x;
m_interval = period;
} else {
m_mean += (x - m_mean) * kRate;
}
return static_cast<float>(std::clamp(0.5 + (x - m_mean) / period, 0.0, 1.0));
}
void reset() noexcept { m_valid = false; }
private:
static constexpr double kRate = 1.0 / 256.0;
bool m_valid = false;
double m_mean = 0.0;
double m_interval = 0.0;
};
} // namespace aurora::stereo
+78 -10
View File
@@ -30,9 +30,16 @@ std::atomic<int> requestedQuality{3};
std::atomic<float> panelAspect{16.f/9.f};
int activeQuality=-1;
V point(const float* m,V p) { return {m[0]*p[0]+m[1]*p[1]+m[2]*p[2]+m[3],m[4]*p[0]+m[5]*p[1]+m[6]*p[2]+m[7],m[8]*p[0]+m[9]*p[1]+m[10]*p[2]+m[11]}; }
// The menus' environment layer (the Frame): one eye's room around the screen drawn into its part of a
// packed two-eye target. The screen itself is a separate quad layer, so its area stays transparent.
struct Environment { wgpu::TextureView view; uint32_t x=0,width=0,height=0; bool clear=false; };
void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,const AuroraStereoFrame& frame,
const AuroraVRUiGuide& layout,uint32_t eye) {
const auto& output=g_stereoEyeTargets[eye].output();
const AuroraVRUiGuide& layout,uint32_t eye,const Environment* env=nullptr) {
const auto& output=g_stereoEyeTargets[env?1:eye].output();
const wgpu::TextureView targetView=env?env->view:output.view;
const auto setViewport=[&](const wgpu::RenderPassEncoder& pass) {
if(env) pass.SetViewport(float(env->x),0,float(env->width),float(env->height),0,1);
};
panelAspect.store(float(source.size.width)/std::max(1u,source.size.height),std::memory_order_relaxed);
// Apply changes between stereo pairs, so both eyes use the same quality.
const int quality=(eye==0 || activeQuality<0)?requestedQuality.load(std::memory_order_relaxed):activeQuality;
@@ -58,6 +65,7 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
return vec4f(tanh(light.xyz+vec3f(0.1*length(light)))*0.65,1);
}
@fragment fn fs(i:O)->@location(0) vec4f {
if(i.t > 2.5) { return vec4f(0.0); }
if(i.t < -1.5) {
return dielectric(normalize(i.c),i.origin,-i.t-2.0);
}
@@ -101,6 +109,7 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
const wgpu::RenderPassColorAttachment ca{.view=view,.loadOp=load,.storeOp=wgpu::StoreOp::Store};
const wgpu::RenderPassDescriptor rd{.label="VR menu environment",.colorAttachmentCount=1,.colorAttachments=&ca};
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetBindGroup(0,binding);
if(view.Get()==targetView.Get()) setViewport(pass);
pass.SetVertexBuffer(0,buffer);pass.Draw(count);pass.End();
};
static const auto start=std::chrono::steady_clock::now();
@@ -117,13 +126,13 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
-(m[1]*m[3]+m[5]*m[7]+m[9]*m[11])/6,
-(m[2]*m[3]+m[6]*m[7]+m[10]*m[11])/6}};
}
if(quality) {
if(quality && !env) {
drawVertices(panoramaView,source.bindGroup,sky,3,wgpu::LoadOp::Clear);
for(auto& v:sky) v.textured=-1;
drawVertices(output.view,panoramaBinding,sky,3,wgpu::LoadOp::Clear);
drawVertices(targetView,panoramaBinding,sky,3,wgpu::LoadOp::Clear);
} else {
for(auto& v:sky) { v.textured=0;v.color[0]=.012f;v.color[1]=.018f;v.color[2]=.027f; }
drawVertices(output.view,source.bindGroup,sky,3,wgpu::LoadOp::Clear);
drawVertices(targetView,source.bindGroup,sky,3,!env || env->clear?wgpu::LoadOp::Clear:wgpu::LoadOp::Load);
}
std::vector<Vertex> vertices;
bool workshop=false;
@@ -144,7 +153,11 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
};
const float width=std::max(.25f,frame.ui.width),height=width*float(source.size.height)/std::max(1u,source.size.width);
const auto panelSlice=[&](float top,float bottom) { quad({0,(.5f-(top+bottom)/2)*height,-frame.ui.distance},width,(bottom-top)*height,0,top,1,bottom); };
if(!layout.active) panelSlice(0,1);
if(env) {
// The screen's own quad layer shows the picture: leave its area transparent black.
quad({0,0,-frame.ui.distance},width,height,0,0,1,1);
for(size_t i=vertices.size()-6;i<vertices.size();++i) vertices[i].textured=3;
} else if(!layout.active) panelSlice(0,1);
else {
panelSlice(0,layout.headerEnd);panelSlice(layout.footerStart,1);
}
@@ -174,6 +187,7 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
// perspective and ordering; no scene depth exists behind the tutorial.
struct Triangle { std::array<Vertex,3> v;float depth;uint32_t material; };
std::vector<Triangle> triangles;
triangles.reserve(9216);
{
std::lock_guard lock(mutex);
for(auto& [id,material]:controllerTextures) if(!material.pixels.empty()) {
@@ -211,11 +225,22 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
}
}
}
std::sort(triangles.begin(),triangles.end(),[](const auto& a,const auto& b){return a.depth>b.depth;});
// Sort small keys, not the triangles themselves: the controller models are ~9000
// triangles per eye and moving them dominated the frame worker at 120 Hz. Back to front
// in 5 mm slices, by material within a slice: a strict depth order switched material
// (a draw and a bind group each) almost every triangle.
struct Key { int32_t slice; uint32_t material; uint32_t index; };
std::vector<Key> order(triangles.size());
for(uint32_t i=0;i<order.size();++i)
order[i]={static_cast<int32_t>(std::floor(triangles[i].depth*(1.f/3.f)/.005f)),triangles[i].material,i};
std::sort(order.begin(),order.end(),[](const Key& a,const Key& b){
return a.slice!=b.slice?a.slice>b.slice:a.material<b.material;});
materialRanges.push_back({0,static_cast<uint32_t>(vertices.size()),source.bindGroup});
vertices.reserve(vertices.size()+triangles.size()*3+512);
{
std::lock_guard lock(mutex);
for(const auto& t:triangles) {
for(const auto& key:order) {
const auto& t=triangles[key.index];
const auto found=controllerTextures.find(t.material);
auto binding=found!=controllerTextures.end() && found->second.binding?found->second.binding:source.bindGroup;
if(materialRanges.back().binding.Get()==binding.Get()) materialRanges.back().count+=3;
@@ -255,12 +280,55 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
}
const wgpu::BufferDescriptor bd{.label="Anchored VR UI vertices",.usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,.size=std::max(size_t(4),vertices.size()*sizeof(Vertex))};
auto buffer=g_device.CreateBuffer(&bd);if(!vertices.empty()) g_queue.WriteBuffer(buffer,0,vertices.data(),vertices.size()*sizeof(Vertex));
const wgpu::RenderPassColorAttachment ca{.view=output.view,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
const wgpu::RenderPassColorAttachment ca{.view=targetView,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
const wgpu::RenderPassDescriptor rd{.label="Anchored VR UI stereo",.colorAttachmentCount=1,.colorAttachments=&ca};
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetVertexBuffer(0,buffer);
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);setViewport(pass);pass.SetVertexBuffer(0,buffer);
for(const auto& range:materialRanges) if(range.count) {
pass.SetBindGroup(0,range.binding);pass.Draw(range.count,1,range.first);
}
pass.End();
}
// The pointer's target marks drawn into the menus' screen image itself (eye target 0, the screen quad's
// picture): a mark on the quad's own surface is visible and stereo-correct whatever order the runtime
// composites the layers in (SteamVR on the Frame hid the environment layer's mark under the quad, H4.md).
// Call after render() built the pipeline for this format; `source` is the picture placed in the image.
void screen_marks(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,const AuroraStereoFrame& frame) {
const auto& output=g_stereoEyeTargets[0].output();
if(!pipeline || format!=output.format || !source.bindGroup || !source.size.width || !source.size.height) return;
const auto vp=webgpu::calculate_present_viewport(output.size.width,output.size.height,source.size.width,source.size.height);
const float width=std::max(.25f,frame.ui.width),height=width*float(source.size.height)/float(source.size.width);
std::vector<Vertex> vertices;
const auto at=[&](float x,float y,V color) {
const float px=vp.left+(x/width+.5f)*vp.width,py=vp.top+(.5f-y/height)*vp.height;
return Vertex{{px/float(output.size.width)*2-1,1-py/float(output.size.height)*2,0,1},{0,0},{color[0],color[1],color[2]},0};
};
for(int hand=0;hand<2;++hand) if(frame.ui.pointerTracked[hand]) {
const auto* ray=frame.ui.pointerRay[hand];
if(ray[5]>=-.001f) continue;
const float distance=(-frame.ui.distance-ray[2])/ray[5];
if(distance<=0 || distance>10) continue;
const float x=ray[0]+ray[3]*distance,y=ray[1]+ray[4]*distance;
if(std::abs(x)>width*.5f || std::abs(y)>height*.5f) continue;
const V color=hand?V{.96f,.74f,.31f}:V{.15f,.88f,.82f};
// A coloured ring with a dark edge and a white centre: readable on bright and dark menus alike.
const auto disc=[&](float radius,V inner,V outer) {
for(int segment=0;segment<20;++segment) {
const float angle=segment*6.2831853f/20,next=(segment+1)*6.2831853f/20;
vertices.push_back(at(x,y,inner));
vertices.push_back(at(x+radius*std::cos(angle),y+radius*std::sin(angle),outer));
vertices.push_back(at(x+radius*std::cos(next),y+radius*std::sin(next),outer));
}
};
disc(.018f,{.02f,.02f,.02f},{.02f,.02f,.02f});
disc(.015f,color,color);
disc(.006f,{1,1,1},{1,1,1});
}
if(vertices.empty()) return;
const wgpu::BufferDescriptor bd{.label="VR menu pointer marks",.usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,.size=vertices.size()*sizeof(Vertex)};
auto buffer=g_device.CreateBuffer(&bd);g_queue.WriteBuffer(buffer,0,vertices.data(),vertices.size()*sizeof(Vertex));
const wgpu::RenderPassColorAttachment ca{.view=output.view,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
const wgpu::RenderPassDescriptor rd{.label="VR menu pointer marks",.colorAttachmentCount=1,.colorAttachments=&ca};
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetBindGroup(0,source.bindGroup);
pass.SetVertexBuffer(0,buffer);pass.Draw(static_cast<uint32_t>(vertices.size()));pass.End();
}
}
+38 -1
View File
@@ -86,6 +86,7 @@ static wgpu::AdapterInfo g_adapterInfo;
static wgpu::SurfaceCapabilities g_surfaceCapabilities;
bool g_bcTexturesSupported;
bool g_timestampQueriesSupported = false;
bool g_timestampInsidePassesSupported = false;
// Written by Dawn's device-loss callback and consumed at ordered frame boundaries. Keep the
// callback free of logging, allocation, teardown and renderer state mutation.
static std::atomic_bool g_deviceLost{false};
@@ -539,6 +540,32 @@ static wgpu::BackendType to_wgpu_backend(AuroraBackend backend) {
}
}
bool headless() noexcept {
static const bool value = [] {
const char* env = std::getenv("KARTFRAME_HEADLESS");
return env != nullptr && *env != '\0' && *env != '0';
}();
return value;
}
wgpu::Texture headless_surface_texture() {
static wgpu::Texture texture;
static uint32_t width = 0, height = 0;
const auto& cfg = g_graphicsConfig.surfaceConfiguration;
if (!texture || width != cfg.width || height != cfg.height) {
const wgpu::TextureDescriptor descriptor{
.label = "Headless surface",
.usage = cfg.usage,
.size = {cfg.width, cfg.height, 1},
.format = cfg.format,
};
texture = g_device.CreateTexture(&descriptor);
width = cfg.width;
height = cfg.height;
}
return texture;
}
static bool create_surface() {
SDL_Window* window = window::get_sdl_window();
if (window == nullptr) {
@@ -727,6 +754,14 @@ bool initialize(AuroraBackend auroraBackend) {
g_timestampQueriesSupported = true;
requiredFeatures.push_back(feature);
}
// The per-segment GPU profile (KARTFRAME_FPSLOG=2) times parts of a render pass; asked for only then.
if (feature == wgpu::FeatureName::ChromiumExperimentalTimestampQueryInsidePasses) {
const char* env = std::getenv("KARTFRAME_FPSLOG");
if (env != nullptr && env[0] == '2') {
g_timestampInsidePassesSupported = true;
requiredFeatures.push_back(feature);
}
}
// The presenter calls device and queue methods while the frame worker encodes, which Dawn only
// supports with this feature; without it the two race inside the device's dynamic uploader.
if (feature == wgpu::FeatureName::ImplicitDeviceSynchronization) {
@@ -1090,7 +1125,9 @@ void resize_swapchain(uint32_t width, uint32_t height, uint32_t native_width, ui
g_graphicsConfig.surfaceConfiguration.height = native_height;
auto surfaceConfiguration = g_graphicsConfig.surfaceConfiguration;
surfaceConfiguration.device = g_device;
g_surface.Configure(&surfaceConfiguration);
if (!headless()) {
g_surface.Configure(&surfaceConfiguration);
}
if (!sizeChanged) {
// Forced reconfigure at an unchanged size (present-mode change or recreated surface). The
// offscreen targets are not swapchain images, so reallocating them would only stall the frame.
+7
View File
@@ -63,8 +63,15 @@ extern wgpu::Instance g_instance;
extern bool g_bcTexturesSupported;
// The device was created with TimestampQuery, so passes may carry timestamp writes (gfx::gpu_timing_*).
extern bool g_timestampQueriesSupported;
// Timestamps inside passes and on the encoder (Dawn experimental feature; KARTFRAME_FPSLOG=2 only).
extern bool g_timestampInsidePassesSupported;
bool initialize(AuroraBackend backend);
// KARTFRAME_HEADLESS=1: development runs without a presentable swapchain. The window and surface
// still exist (the adapter is chosen against it) but the swapchain is never configured; the desktop
// view renders into an offscreen texture that is never shown.
bool headless() noexcept;
wgpu::Texture headless_surface_texture();
void shutdown();
// Device-loss callbacks may run on a Dawn-managed thread and only publish loss state; frame
// threads call this at ordered boundaries to turn it into the one user-visible fatal error.
+311 -28
View File
@@ -4,16 +4,145 @@
#include "../stereo.hpp"
#include "../stereo_overlay.hpp"
#include "gpu.hpp"
#if defined(_WIN32) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN)
#if (defined(_WIN32) || (defined(__linux__) && !defined(__ANDROID__))) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN)
#if defined(_WIN32)
#include <windows.h>
#else
// The patched Dawn is linked statically on Linux; its Aurora Vulkan ABI is resolved at link time.
extern "C" {
uint32_t AuroraDawnVulkanVersion();
int AuroraDawnVulkanConfigure(const AuroraDawnVulkanHooks* hooks);
int AuroraDawnVulkanGetHandles(void* handle, AuroraDawnVulkanHandles* out);
void* AuroraDawnVulkanWrap(void* handle, const void* desc, uint64_t image);
int AuroraDawnVulkanRelease(void* handle, void* const* textures, uint32_t count);
void* AuroraDawnVulkanLock(void* handle);
void AuroraDawnVulkanUnlock(void* guard);
int AuroraDawnVulkanDrain(void* handle);
}
#endif
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string_view>
#include <vector>
namespace aurora::vulkan_win32 {
namespace {
Module Log("aurora::vulkan_win32");
// KARTFRAME_FRAME_STAMP=1 (tests only): each eye image gets a 64x15 pixel block in its top left
// corner naming the display time its poses were located for and the eye, written after the eye
// copy. The mock runtime (MOCK_XR_STAMP) reads it back from the image a projection layer submits
// and compares it with the locate the layer's poses came from. Layout, one pixel a cell, white = 1:
// row 0 the 64 bits of the display time (steady clock ns, bit 0 first), row 1 their complement,
// row 2: cell 0 the eye (1 = right), cell 1 its complement, cells 2-5 the marker 1 0 1 0.
// Lane L, the scene state this eye's draws used (stereo::EyeScene), rows 3 and 5 two 64-bit words, rows 4
// and 6 their complements:
// row 3: bits 0-15 weight x 65535, 16-31 previous game frame (low 16 bits), 32-47 current game frame,
// 48 valid, 49 late (filled at the set's sample time), 50 anchored, 51 continuous, 56-63 set (low 8);
// row 5: perspective draws filled from the pair (bits 0-15), filled current-only (16-31), drawn with the
// recorded uniform (32-47), drawn with a range outside this set's fill (48-63).
// Lane P, the head pose this eye's uniform fill wrote (stereo::EyeScene::pose*), rows 7, 9 and 11 three
// 64-bit words, rows 8, 10 and 12 their complements:
// row 7: the display time (steady ns) of the packet whose eye transform the fill wrote (0: none);
// row 9: the eye's orientation recovered from that transform, x, y, z, w as signed 16-bit (x 32767);
// row 11: bits 0-15 draws of this eye filled with another packet or transform, 16-47 the packet's token
// (low 32 bits), 48 a pose was recorded;
// row 13: the tangents of that fill's eye frustum, left, right, down, up as signed 16-bit (x 8192); row 14
// its complement.
// Lane P: one pixel a cell (the block is 64x15 pixels; it was 4x4-pixel cells, 256x28, before): the mock
// reads the image raw, and the smaller block keeps the test stamp's cost out of the frame's timing.
constexpr uint32_t kStampCell = 1;
constexpr uint32_t kStampRows = 15;
std::array<uint64_t, 2> SceneWords(const stereo::EyeScene& scene) {
if (!scene.valid) return {0, 0};
const uint64_t weight = static_cast<uint64_t>(std::lround(std::clamp(scene.weight, 0.f, 1.f) * 65535.f));
const uint64_t a = weight | (static_cast<uint64_t>(scene.previousFrame & 0xffffu) << 16) |
(static_cast<uint64_t>(scene.currentFrame & 0xffffu) << 32) | (1ull << 48) |
(scene.late ? 1ull << 49 : 0) | (scene.anchored ? 1ull << 50 : 0) |
(scene.continuous ? 1ull << 51 : 0) | (static_cast<uint64_t>(scene.set & 0xffu) << 56);
const uint64_t b = static_cast<uint64_t>(scene.interpolated) | (static_cast<uint64_t>(scene.current) << 16) |
(static_cast<uint64_t>(scene.recorded) << 32) | (static_cast<uint64_t>(scene.outside) << 48);
return {a, b};
}
std::array<uint64_t, 4> PoseWords(const stereo::EyeScene& scene) {
if (!scene.valid || scene.poseTime == 0) return {0, 0, 0, 0};
uint64_t q = 0;
for (uint32_t k = 0; k < 4; ++k) {
const auto v = static_cast<int16_t>(std::lround(std::clamp(scene.poseOrientation[k], -1.f, 1.f) * 32767.f));
q |= static_cast<uint64_t>(static_cast<uint16_t>(v)) << (16 * k);
}
const uint64_t e = static_cast<uint64_t>(scene.poseMixed) | ((scene.poseToken & 0xffffffffull) << 16) | (1ull << 48);
uint64_t t = 0;
for (uint32_t k = 0; k < 4; ++k) {
const auto v = static_cast<int16_t>(std::lround(std::clamp(scene.poseTangents[k], -3.99f, 3.99f) * 8192.f));
t |= static_cast<uint64_t>(static_cast<uint16_t>(v)) << (16 * k);
}
return {scene.poseTime, q, e, t};
}
bool StampEnabled() {
static const bool enabled = [] {
const char* env = std::getenv("KARTFRAME_FRAME_STAMP");
return env != nullptr && env[0] == '1';
}();
return enabled;
}
// The whole block is built on the CPU and written into a small texture per eye (queue.WriteTexture runs
// before this set's submission and after the last one's, so reusing it per eye is safe), then copied into
// the eye image with one copy. One pixel a bit (lane P; 4x4 cells before): 64x15 pixels, 3.8 KB an eye.
void WriteStamp(wgpu::CommandEncoder& encoder, const wgpu::Texture& target, wgpu::TextureFormat format, uint64_t value,
uint32_t eye, const stereo::EyeScene& scene) {
constexpr uint32_t kWidth = 64 * kStampCell, kHeight = kStampRows * kStampCell;
static std::array<wgpu::Texture, 2> blocks;
static std::array<wgpu::TextureFormat, 2> blockFormats{};
const uint32_t slot = eye == 1 ? 1 : 0;
const wgpu::Extent3D size{kWidth, kHeight, 1};
if (!blocks[slot] || blockFormats[slot] != format) {
const wgpu::TextureDescriptor descriptor{.label = "Frame stamp block",
.usage = wgpu::TextureUsage::CopySrc | wgpu::TextureUsage::CopyDst,
.dimension = wgpu::TextureDimension::e2D,
.size = size,
.format = format,
.mipLevelCount = 1,
.sampleCount = 1};
blocks[slot] = webgpu::g_device.CreateTexture(&descriptor);
blockFormats[slot] = format;
}
std::array<uint8_t, size_t{kWidth} * kHeight * 4> pixels;
const auto cell = [&](uint32_t column, uint32_t row, bool one) {
for (uint32_t y = 0; y < kStampCell; ++y)
for (uint32_t x = 0; x < kStampCell; ++x) {
auto* p = &pixels[((row * kStampCell + y) * kWidth + column * kStampCell + x) * 4];
p[0] = p[1] = p[2] = one ? 255 : 0;
p[3] = 255;
}
};
const auto word = [&](uint32_t row, uint64_t bits) {
for (uint32_t bit = 0; bit < 64; ++bit) {
const bool one = ((bits >> bit) & 1u) != 0;
cell(bit, row, one);
cell(bit, row + 1, !one);
}
};
word(0, value);
for (uint32_t column = 0; column < 64; ++column)
cell(column, 2, column == 0 ? eye == 1 : column == 1 ? eye != 1 : column < 6 ? ((column - 2) & 1u) == 0 : false);
const auto words = SceneWords(scene);
word(3, words[0]);
word(5, words[1]);
const auto pose = PoseWords(scene);
for (uint32_t k = 0; k < pose.size(); ++k) word(7 + 2 * k, pose[k]);
const wgpu::TexelCopyTextureInfo block{.texture = blocks[slot]};
const wgpu::TexelCopyBufferLayout layout{.bytesPerRow = kWidth * 4, .rowsPerImage = kHeight};
webgpu::g_queue.WriteTexture(&block, pixels.data(), pixels.size(), &layout, &size);
const wgpu::TexelCopyTextureInfo destination{.texture = target};
encoder.CopyTextureToTexture(&block, &destination, &size);
}
// Set on the frame worker around the submitted callback (see aurora_vulkan_win32_submitting_retained).
thread_local bool submittingRetained = false;
struct Api {
AuroraDawnVulkanConfigureFn configure = nullptr;
AuroraDawnVulkanHandlesFn handles = nullptr;
@@ -23,6 +152,7 @@ struct Api {
AuroraDawnVulkanUnlockFn unlock = nullptr;
AuroraDawnVulkanDrainFn drain = nullptr;
bool Load() {
#if defined(_WIN32)
HMODULE module = GetModuleHandleW(L"webgpu_dawn.dll");
if (!module) return false;
auto version = reinterpret_cast<AuroraDawnVulkanVersionFn>(GetProcAddress(module, "AuroraDawnVulkanVersion"));
@@ -37,6 +167,17 @@ struct Api {
LOAD(drain, AuroraDawnVulkanDrainFn, "AuroraDawnVulkanDrain");
#undef LOAD
return true;
#else
if (AuroraDawnVulkanVersion() != AURORA_DAWN_VULKAN_ABI) return false;
configure = &AuroraDawnVulkanConfigure;
handles = &AuroraDawnVulkanGetHandles;
wrap = &AuroraDawnVulkanWrap;
release = &AuroraDawnVulkanRelease;
lock = &AuroraDawnVulkanLock;
unlock = &AuroraDawnVulkanUnlock;
drain = &AuroraDawnVulkanDrain;
return true;
#endif
}
} api;
int64_t VkFormat(wgpu::TextureFormat format) {
@@ -68,6 +209,8 @@ public:
uint32_t images = 0;
bool panel = false;
bool encoded = false;
// The eyes render straight into this set's images (DirectTargets): it can no longer be canceled.
bool claimed = false;
AuroraD3D12StereoSubmittedCallback callback;
void* userdata;
Bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) : callback(cb), userdata(data) {}
@@ -89,9 +232,140 @@ public:
}
bool Cancel(uint64_t wanted) {
std::unique_lock guard(mutex, std::try_to_lock);
if (!guard.owns_lock() || encoded || !token || token != wanted) return false;
if (!guard.owns_lock() || encoded || claimed || !token || token != wanted) return false;
token = 0; return true;
}
// How the eyes reach the swapchain images (lane I). Default srgb: a full-screen draw that decodes the
// eye's sRGB-encoded bytes and writes them through an sRGB view of the sRGB image (the same bytes; the
// stamps read back exactly). The image then needs no MUTABLE_FORMAT, so the driver may compress it
// (Turnip: UBWC), and the eyes' way into it cost 0.47 ms a set instead of the copy's 2.3 ms (mock, GP
// race, compositor running; RACE120.md). KARTFRAME_SINK=copy is the old texture copy into a UNORM view
// of a MUTABLE_FORMAT image, =blit the same through a full-screen draw (the backend asks for
// MUTABLE_FORMAT for both). Not for a UNORM swapchain (no sRGB format offered): that takes the copy.
enum class Sink { Copy, Blit, Srgb };
static Sink SinkMode() {
static const Sink mode = [] {
const char* env = std::getenv("KARTFRAME_SINK");
const std::string_view v = env != nullptr ? env : "";
return v == "blit" ? Sink::Blit : v == "copy" ? Sink::Copy : Sink::Srgb;
}();
return mode;
}
static bool SinkBlit() { return SinkMode() != Sink::Copy; }
// The sRGB format of an sRGB swapchain image (VkFormat 43 or 50), for the draw that writes it.
static wgpu::TextureFormat TargetSrgb(int64_t vk) {
return vk == 43 ? wgpu::TextureFormat::RGBA8UnormSrgb : vk == 50 ? wgpu::TextureFormat::BGRA8UnormSrgb
: wgpu::TextureFormat::Undefined;
}
static wgpu::TextureFormat SrgbOf(wgpu::TextureFormat f) {
return f == wgpu::TextureFormat::BGRA8Unorm ? wgpu::TextureFormat::BGRA8UnormSrgb
: f == wgpu::TextureFormat::RGBA8Unorm ? wgpu::TextureFormat::RGBA8UnormSrgb
: f;
}
wgpu::RenderPipeline srgbPipeline;
wgpu::TextureFormat srgbPipelineFormat = wgpu::TextureFormat::Undefined;
const wgpu::RenderPipeline& SrgbPipeline(wgpu::TextureFormat format) {
if (srgbPipeline && srgbPipelineFormat == format) return srgbPipeline;
wgpu::ShaderSourceWGSL code{};
code.code = R"(
@group(0) @binding(0) var s: sampler;
@group(0) @binding(1) var t: texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
let p = array(vec2f(-1.0, 1.0), vec2f(-1.0, -3.0), vec2f(3.0, 1.0));
return vec4f(p[i], 0.0, 1.0);
}
fn decode(c: f32) -> f32 {
return select(pow((c + 0.055) / 1.055, 2.4), c / 12.92, c <= 0.04045);
}
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let c = textureLoad(t, vec2i(p.xy), 0);
return vec4f(decode(c.r), decode(c.g), decode(c.b), 1.0);
}
)";
wgpu::ShaderModuleDescriptor md{};
md.nextInChain = &code;
const auto module = webgpu::g_device.CreateShaderModule(&md);
const auto layout = webgpu::g_CopyPipeline.GetBindGroupLayout(0);
const wgpu::PipelineLayoutDescriptor ld{.bindGroupLayoutCount = 1, .bindGroupLayouts = &layout};
const wgpu::ColorTargetState target{.format = format};
const wgpu::FragmentState fs{.module = module, .entryPoint = "fs", .targetCount = 1, .targets = &target};
wgpu::RenderPipelineDescriptor pd{};
pd.layout = webgpu::g_device.CreatePipelineLayout(&ld);
pd.vertex = {.module = module, .entryPoint = "vs"};
pd.fragment = &fs;
srgbPipeline = webgpu::g_device.CreateRenderPipeline(&pd);
srgbPipelineFormat = format;
return srgbPipeline;
}
std::vector<std::pair<WGPUTextureView, wgpu::BindGroup>> blitSources;
std::vector<std::pair<WGPUTexture, wgpu::TextureView>> blitTargets;
wgpu::Sampler blitSampler;
void Blit(wgpu::CommandEncoder& encoder, const wgpu::TextureView& source, const wgpu::Texture& target) {
if (!blitSampler) {
const wgpu::SamplerDescriptor sd{.magFilter = wgpu::FilterMode::Nearest, .minFilter = wgpu::FilterMode::Nearest};
blitSampler = webgpu::g_device.CreateSampler(&sd);
}
auto src = std::find_if(blitSources.begin(), blitSources.end(), [&](const auto& e) { return e.first == source.Get(); });
if (src == blitSources.end()) {
if (blitSources.size() >= 8) blitSources.erase(blitSources.begin());
blitSources.emplace_back(source.Get(), webgpu::create_copy_bind_group(source, blitSampler));
src = blitSources.end() - 1;
}
auto dst = std::find_if(blitTargets.begin(), blitTargets.end(), [&](const auto& e) { return e.first == target.Get(); });
if (dst == blitTargets.end()) {
if (blitTargets.size() >= 16) blitTargets.erase(blitTargets.begin());
blitTargets.emplace_back(target.Get(), target.CreateView());
dst = blitTargets.end() - 1;
}
const wgpu::RenderPassColorAttachment ca{.view = dst->second, .loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Store};
const wgpu::RenderPassDescriptor rd{.label = "VR eye to swapchain", .colorAttachmentCount = 1, .colorAttachments = &ca};
auto pass = encoder.BeginRenderPass(&rd);
pass.SetPipeline(SinkMode() == Sink::Srgb ? SrgbPipeline(target.GetFormat()) : webgpu::g_CopyPipeline);
pass.SetBindGroup(0, src->second, 0, nullptr);
pass.Draw(3);
pass.End();
}
// The wrapped texture of target i as `format`, imported on first use (mutex held).
wgpu::Texture* ImportTarget(uint32_t i, wgpu::TextureFormat format) {
const auto& target = targets[i];
const uint64_t image = reinterpret_cast<uintptr_t>(target.resource);
auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; });
if (it != imports.end() && (it->width != target.width || it->height != target.height || it->format != format)) {
// The runtime reuses VkImage handles across swapchain recreation, and Aurora's eye format can
// change with the surface: re-wrap.
imports.erase(it);
it = imports.end();
}
if (it == imports.end()) {
wgpu::TextureDescriptor descriptor;
descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment;
descriptor.size = {target.width, target.height, 1};
descriptor.format = format;
void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image);
if (!wrapped) return nullptr;
imports.push_back({image, target.width, target.height, format,
wgpu::Texture::Acquire(static_cast<WGPUTexture>(wrapped))});
it = imports.end() - 1;
}
return &it->texture;
}
bool DirectTargets(uint64_t wanted, wgpu::TextureFormat format, uint32_t width, uint32_t height,
std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out) {
std::lock_guard guard(mutex);
if (!token || token != wanted || encoded || count != AURORA_STEREO_EYE_COUNT || SinkMode() == Sink::Srgb) return false;
for (uint32_t i = 0; i < count; ++i) {
if (targets[i].width != width || targets[i].height != height ||
!CopyCompatible(VkFormat(format), targets[i].dxgiFormat)) return false;
}
for (uint32_t i = 0; i < count; ++i) {
auto* texture = ImportTarget(i, format);
if (!texture) return false;
out[i] = *texture;
}
claimed = true;
return true;
}
bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) {
std::lock_guard guard(mutex);
if (!token || encoded || frame.frameToken != token) return false;
@@ -103,37 +377,36 @@ public:
for (uint32_t i = 0; i < images; ++i) {
const auto& eye = sources[i];
const auto& target = targets[i];
if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height ||
!CopyCompatible(VkFormat(eye.format), target.dxgiFormat)) return false;
const uint64_t image = reinterpret_cast<uintptr_t>(target.resource);
auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; });
if (it == imports.end()) {
wgpu::TextureDescriptor descriptor;
descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment;
descriptor.size = {target.width, target.height, 1};
descriptor.format = eye.format;
void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image);
if (!wrapped) return false;
imports.push_back({image, target.width, target.height, eye.format,
wgpu::Texture::Acquire(static_cast<WGPUTexture>(wrapped))});
it = imports.end() - 1;
}
if (it->width != target.width || it->height != target.height || it->format != eye.format) {
// The runtime reuses VkImage handles across swapchain recreation, and
// Aurora's eye format can change with the surface. Re-wrap rather than
// rejecting every future frame for this image.
imports.erase(it);
--i;
continue;
}
active[i] = it->texture;
if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height) return false;
// Lane O: an eye swapchain in the other channel order (R8G8B8A8_SRGB against Aurora's BGRA, as
// GalaxyFrame's) can only be written by the sRGB draw, which writes any order; that includes the
// menus' screen image (the left image of a pair), which the copy wrote before.
const bool swizzle = !CopyCompatible(VkFormat(eye.format), target.dxgiFormat);
const bool srgbTarget = TargetSrgb(target.dxgiFormat) != wgpu::TextureFormat::Undefined;
const bool srgbEye = SinkMode() == Sink::Srgb && srgbTarget && SrgbOf(eye.format) != eye.format &&
((i < count && frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) || swizzle);
if (swizzle && !(srgbEye && eye.view != nullptr && *eye.view)) return false;
auto* texture = ImportTarget(i, srgbEye ? TargetSrgb(target.dxgiFormat) : eye.format);
if (!texture) return false;
active[i] = *texture;
}
for (uint32_t i = 0; i < images; ++i) {
wgpu::TexelCopyTextureInfo source, destination;
source.texture = *sources[i].texture;
destination.texture = active[i];
wgpu::Extent3D size{targets[i].width, targets[i].height, 1};
encoder.CopyTextureToTexture(&source, &destination, &size);
// An eye rendered straight into its swapchain image (DirectTargets) needs no copy.
if (source.texture.Get() == destination.texture.Get()) {
} else if (SinkBlit() && sources[i].view != nullptr && *sources[i].view &&
(SinkMode() == Sink::Blit || active[i].GetFormat() != sources[i].format)) {
Blit(encoder, *sources[i].view, active[i]);
} else {
encoder.CopyTextureToTexture(&source, &destination, &size);
}
if (StampEnabled() && frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && i < count && count == 2 &&
targets[i].width >= 64 * kStampCell &&
targets[i].height >= kStampRows * kStampCell)
WriteStamp(encoder, active[i], active[i].GetFormat(), frame.displayTimeNanos, i, frame.scene[i]);
}
encoded = true;
return true;
@@ -147,8 +420,10 @@ public:
// flush them under its device guard, then allow the XR thread to release.
const bool success = api.release(webgpu::g_device.Get(), textures.data(), images) != 0;
const auto completed = token;
token = 0; encoded = false;
token = 0; encoded = false; claimed = false;
submittingRetained = frame.retained;
callback(completed, success, userdata);
submittingRetained = false;
}
};
std::unique_ptr<Bridge> bridge;
@@ -172,6 +447,11 @@ bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback cb, void* dat
[](wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void* self) noexcept {
return static_cast<Bridge*>(self)->Encode(encoder, frame);
}, [](const aurora::stereo::SinkFrame& frame, void* self) noexcept { static_cast<Bridge*>(self)->Submitted(frame); }, bridge.get());
aurora::stereo::set_direct_targets(
[](uint64_t token, wgpu::TextureFormat format, uint32_t width, uint32_t height,
std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out, void* self) noexcept {
return static_cast<Bridge*>(self)->DirectTargets(token, format, width, height, out);
}, bridge.get());
return true;
}
bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count) {
@@ -191,10 +471,12 @@ bool aurora_vulkan_win32_disable() {
using namespace aurora::vulkan_win32;
if (!bridge) return true;
aurora::stereo::set_sink(nullptr, nullptr, nullptr);
aurora::stereo::set_direct_targets(nullptr, nullptr);
if (!api.drain(aurora::webgpu::g_device.Get())) { bridge.release(); return false; }
bridge.reset();
return true;
}
bool aurora_vulkan_win32_submitting_retained() { return aurora::vulkan_win32::submittingRetained; }
void* aurora_vulkan_win32_lock_queue() {
return aurora::vulkan_win32::api.lock(aurora::webgpu::g_device.Get());
}
@@ -215,6 +497,7 @@ bool aurora_vulkan_win32_set_targets_with_panel(uint64_t, const AuroraD3D12Stere
const AuroraD3D12StereoTarget*) { return false; }
bool aurora_vulkan_win32_cancel(uint64_t) { return false; }
bool aurora_vulkan_win32_disable() { return true; }
bool aurora_vulkan_win32_submitting_retained() { return false; }
void* aurora_vulkan_win32_lock_queue() { return nullptr; }
void aurora_vulkan_win32_unlock_queue(void*) {}
#endif
+17 -1
View File
@@ -33,6 +33,7 @@
#include <algorithm>
#include <atomic>
#include <cstdlib>
#include <mutex>
#include <vector>
@@ -53,6 +54,12 @@ AuroraWindowSize g_windowSize;
std::vector<AuroraEvent> g_events;
std::atomic_bool g_backgrounded = false;
std::atomic_bool g_nativeResizePending = false;
// SDL_SyncWindow is for after a window request; pump_events used to call it every game frame. On X11,
// when the window's geometry differs from what SDL expects (Xvfb without a window manager, or a WM that
// sizes the window itself, as gamescope does) and nothing is pending, it ends in SDL_Delay(10): 10 ms of
// every game frame on the game thread, which took the demo race from 60 to 39 game frames a second in
// headless mock runs. Now it runs once after each window request (KARTFRAME_SYNC_WINDOW=1: every frame).
std::atomic_bool g_windowSyncWanted = true;
std::atomic<AuroraDisplayMode> g_displayMode{AURORA_DISPLAY_MODE_WINDOWED};
#if defined(SDL_PLATFORM_ANDROID)
// SDL's own activity mutex (Android_LockActivityMutex) is internal and not
@@ -478,6 +485,7 @@ void destroy_window() {
void show_window() {
if (g_window != nullptr) {
TRY_WARN(SDL_ShowWindow(g_window), "Failed to show window: {}", SDL_GetError());
g_windowSyncWanted.store(true, std::memory_order_release);
}
}
@@ -594,7 +602,11 @@ void pump_events() noexcept {
// runtime calls on the scheduler's own stack (KartPad found this on device).
return;
#else
if (g_window != nullptr) {
static const bool syncEveryFrame = [] {
const char* env = std::getenv("KARTFRAME_SYNC_WINDOW");
return env != nullptr && env[0] == '1';
}();
if (g_window != nullptr && (g_windowSyncWanted.exchange(false, std::memory_order_acq_rel) || syncEveryFrame)) {
SDL_SyncWindow(g_window);
}
SDL_PumpEvents();
@@ -726,6 +738,7 @@ void set_display_mode(AuroraDisplayMode mode) {
}
g_displayMode.store(mode, std::memory_order_release);
g_nativeResizePending.store(true, std::memory_order_release);
g_windowSyncWanted.store(true, std::memory_order_release);
TRY_WARN(push_custom_event(CustomEvent::FutureResize), "Failed to queue fullscreen resize event: {}",
SDL_GetError());
}
@@ -735,15 +748,18 @@ AuroraDisplayMode get_display_mode() { return g_displayMode.load(std::memory_ord
void set_window_size(uint32_t width, uint32_t height) {
TRY_WARN(SDL_RestoreWindow(g_window), "Failed to un-maximize window: {}", SDL_GetError());
TRY_WARN(SDL_SetWindowSize(g_window, width, height), "Failed to set window size: {}", SDL_GetError());
g_windowSyncWanted.store(true, std::memory_order_release);
}
void set_window_position(uint32_t x, uint32_t y) {
TRY_WARN(SDL_SetWindowPosition(g_window, x, y), "Failed to set window position: {}", SDL_GetError());
g_windowSyncWanted.store(true, std::memory_order_release);
}
void center_window() {
TRY_WARN(SDL_SetWindowPosition(g_window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED),
"Failed to center window: {}", SDL_GetError());
g_windowSyncWanted.store(true, std::memory_order_release);
}
void sync_frame_buffer_size() noexcept {
@@ -60,3 +60,32 @@ TEST(StereoInterpolation, MissingTimingAndStallsDoNotExtrapolate) {
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 10), 0.5);
EXPECT_FLOAT_EQ(interpolation_weight(500, 100, 10), 1);
}
// A headset set's display time lies one to three refreshes after the newest game frame's boundary;
// the scene clock must still interpolate (not stick at 1) and advance uniformly.
TEST(StereoInterpolation, SceneClockInterpolatesAheadOfTheBoundary) {
constexpr uint64_t interval = 16'666'667;
for (uint64_t hz : {90u, 120u}) {
for (uint64_t lead : {16'666'667ull, 25'000'000ull, 33'000'000ull}) {
aurora::stereo::SceneClock clock;
double previousPosition = 0;
uint32_t between = 0, samples = 0;
for (uint64_t sample = 1; sample <= 20 * hz; ++sample) {
const uint64_t render = 1'000'000'000 + sample * 1'000'000'000 / hz;
const uint64_t display = render + lead;
// The newest frame at render time: n, sealed in its own interval, due at the next retrace.
const uint64_t n = (render - 1'000'000'000) / interval;
const uint64_t boundary = 1'000'000'000 + (n + 1) * interval;
const float weight = clock.weight(display, boundary, interval);
const double position = static_cast<double>(n) - 1.0 + weight;
if (sample > 4 * hz) { // after the mean settled
++samples;
between += weight > 0.f && weight < 1.f;
EXPECT_NEAR(position - previousPosition, 1'000'000'000.0 / hz / interval, 0.02);
}
previousPosition = position;
}
EXPECT_GT(between, samples * 9 / 10) << hz << " Hz, lead " << lead;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
# Changelog
## 0.9.0-alpha.1 (October 2026), pre-release
The first public build of the native port. Everything below has been played on a Steam Frame unless marked
otherwise. One known issue remains open, the double image in races (see the README); this build carries
the mitigation that removes it.
### Confirmed in the headset
- Runs natively on the Frame in SteamVR at 120 Hz, 1728 pixels per eye, both eyes rendered on every refresh: races hold 119-120 sets a second with under 1 % late.
- The game itself runs at its full 60 frames a second (the first session's "half speed" was a window-sync call sleeping 10 ms every game frame under gamescope; fixed).
- Menus and the intro screens as a flat quad layer in front of you, sharp and steady; the race HUD on the virtual screen with correct clipping; the thin line beside the map removed.
- The Frame controller layout, hand steering with the grips, the simplified Frame controller models in the hands.
- First run from your own disc in about 6 minutes: extract, translate, compile with the bundled compiler, link against the kit.
- The installer, the Steam library entry, the launch wrapper with SteamVR's per-app settings and the session logs.
### Rendering and pacing work
- Dawn's Vulkan backend on SteamVR's own device; eye images released only after a GPU fence (timeline semaphore under Dawn's lock).
- Pipelined pacing: each set of eyes rendered two refreshes ahead, every frame handed over at a fixed phase against SteamVR's compositor (the double-image mitigation, `KARTFRAME_END_PHASE`), released only when finished; retained repeat when late; half-rate never by default.
- An sRGB sink and a cut of the native render pass: eyes to swapchain 2.3 ms -> 0.47 ms.
- Turnip's sysmem mode by default (7-8 % faster for this draw-bound game).
- Thread priority (RealtimeKit) and a utilisation clamp so the game thread and the frame worker stay on the big cores.
- Input kept through SteamVR's short focus losses; refresh rate requested only when offered.
### Tools (for developers)
- A mock OpenXR runtime (`frame/tools/xrmock`) with exact checks: finished releases, pose stamps, end times, layer lists, per-eye probes, image dumps.
- `xrprobe --replay`: SteamVR shown the game's own recorded eye images through a minimal loop.
- Compositor timing per frame (`tools/vrtiming.py`), `frame/tools/phase.py`, panel recordings and their analysis.
### Known issues
- The double image in races: see the README. This build removes it by timing the hand-over; the right eye still runs one set ahead of the left, which reads as extra sensitivity to head movement.
- The Time Trial ghost is flat. Full-view 2D effects (Blooper ink) do not cover the whole view. The laser pointer's dot is not visible on the 2D menu screen.
## 1.0.0 (planned)
The items of the 1.0.0 plan that are not yet confirmed are listed in [frame/UNTESTED.md](../frame/UNTESTED.md).
+39
View File
@@ -0,0 +1,39 @@
# KartFrame 0.9.0-alpha.1 (pre-release)
Mario Kart Wii in VR, running natively on the Valve Steam Frame. KartFrame is a fork of heurazy's Wiicompiled VR PLUS with a native Linux Arm64 build, Vulkan on SteamVR's own device, and frame pacing written for SteamVR on the Frame. No Lepton, no APK, no PC.
This is a pre-release. It plays, at 120 Hz and 1728 pixels per eye, and has been through twenty headset sessions, but one problem is still being worked on (below), and a few smaller things are listed as known issues. Version numbers stay below 1.0.0 until that is done.
You need your own PAL Mario Kart Wii disc image. No game data is in this repository or its releases.
## What you get
- The game built on your Frame from your own disc, once, in about 6 minutes.
- A Steam library entry that starts it in SteamVR with the right per-app settings every time.
- Both eyes rendered on every refresh of the 120 Hz display from that refresh's head pose; races hold 119-120 sets a second with under 1 % late.
- The 2D menus on a flat, steady screen in front of you; the race HUD on the virtual screen.
- The Frame controllers, with the grips holding the wheel in the cockpit view.
## The known issue: the double image
In races, turning your head could show the world twice, one copy a little ahead of the other, worse in one eye. Twenty sessions of measurement found the mechanism: SteamVR's compositor on the Frame takes each eye's newest handed-over image at its own point inside the refresh, the right eye after the left, and a hand-over landing near those points made consecutive refreshes disagree. This build hands every frame over at a fixed point against the compositor's own clock (`KARTFRAME_END_PHASE=after`, the launch wrapper's default), which removes the doubling. What remains: the right eye runs one set ahead of the left, which reads as the right eye being a touch more sensitive to head movement, and the compositor now and then shows a frame twice. The README explains it in full; the cause behind the cause is still being chased.
## Other known issues
- The Time Trial ghost is drawn flat.
- Full-view 2D effects (Blooper ink, the black of a respawn) do not cover the whole view.
- The laser pointer's dot is not visible on the 2D menu screen; the ray is.
- Two and four player races are heavier and not yet measured in the headset.
## Download
- `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz`
- `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz.sha256`
## Install
Clone the repository, run `frame/install.sh` with the tarball as its argument, then `~/Games/KartFrame/app/setup.sh` with your disc image. The [README](https://github.com/simplysanatori/KartFrame#install) has the steps. Updating is the same two commands; your game files, saves and settings stay where they are.
## Reporting problems
Open an issue with what you did, what you expected and what happened, and the archive from `~/Games/KartFrame/app/tools/collect-logs.sh`. It masks addresses, names and ids before anything leaves your Frame.
+204
View File
@@ -0,0 +1,204 @@
# KartFrame: technical notes
KartFrame is a fork of [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS) by heurazy. In that project the Wii game is statically translated to C++, drawn by the aurora renderer on Dawn (WebGPU) and shown through OpenXR. KartFrame builds the same game as a Linux Arm64 program that runs in the Frame's own SteamVR, without Lepton, an APK or adb. This document is for version 1.0.0.
Install, first run, controls, settings and how to report problems are in the [README](../README.md). This one says how the port works, how to build it, and what I learned about SteamVR on the Frame. Where a claim needs a verdict from the headset it carries `[to confirm in the headset]`, and the claim is listed in [frame/UNTESTED.md](../frame/UNTESTED.md).
Not affiliated with Valve, Nintendo or the upstream author.
## AI disclosure
I did not write this code by hand. The port, its scripts and these notes were written with Anthropic's Claude (Fable 5.1, Opus 5.5 and Sonnet 5.5, through Claude Code), running on my Steam Frame. I chose what to attempt, supplied the disc, reviewed the code and the scripts as they were written, tested in the headset and reported what I saw. Claude read the logs and changed the code. The upstream author has not reviewed this port. Read the code before trusting it.
## Status
0.9.0-alpha.1, pre-release. The build has been through twenty headset sessions. Confirmed: native
Linux Arm64 on SteamVR at 120 Hz and 1728 per eye, 119-120 sets a second in races with under 1 % late,
the game at its full 60 fps, quad-layer menus, the HUD on the virtual screen, the Frame controls and
models, the first run from a disc in about 6 minutes. Open: the double image (below) is removed by a
timing mitigation, not yet by a fix of its cause; the Time Trial ghost is flat; full-view 2D effects and
the pointer dot on the menu screen are pending. The per-session records are in `frame/perf/`.
## How it works
### Platform and process
The build is a plain Linux Arm64 executable, `kartframe`, linked against glibc and libstdc++ only. The libraries that SteamOS does not ship in its base image (abseil, zlib, libpng, FreeType) are built static from pinned sources (`AURORA_VENDOR_THIRD_PARTY`, on by default on Linux). SDL3 is built from source and loads its audio and video drivers at run time. The game takes no command line arguments.
The upstream desktop code is kept. The Linux changes are small: the OpenXR path is on by default, the shared device Vulkan bridge that upstream wrote for the PC is used on Linux, the executable directory comes from `/proc/self/exe`, and the blob assembly of the translated data is written in ELF syntax.
### Install layout
`KARTFRAME_HOME` (default `~/.local/share/KartFrame`) holds:
- `portable.txt`, an empty marker that makes the game use `KARTFRAME_HOME/UserData` as its root instead of `~/.local/share/WiiCompiled`.
- `UserData/`: the game's `Config.toml`, `NAND/`, `Cache/`, `Logs/`, and `controller_models/frame`.
- `game/`: the extracted disc (`main.dol`, `StaticR.rel`, `DATA/`).
- `build/`: the translation and the objects made by setup.
- `bin/`: `kartframe`, its resources (`dsp_coef.bin`, `initial_pipeline_cache.db`, `wii_bootstrap/`, `resources/`) and `kartframe.fingerprint`.
- `sessions/<date-time>/`: the wrapper's logs.
The program directory, `~/Games/KartFrame/app`, holds the wrapper, the tools and, from a release, the kit, the toolchain, the translator and `setup.sh`.
### Data pipeline
Nothing derived from the disc is committed or released. A release carries:
- a **kit**: the prebuilt runtime (aurora, patched Dawn, SDL3, OpenXR loader and the static third party libraries), the compile flags and link recipe read from the real build graph, a minimal sysroot made from the build Frame's own packages (glibc and libstdc++ headers, kernel headers, crt files and libgcc), and a manifest with a sha256 per file and a fingerprint;
- the **translator**, a self contained single file publish of upstream's `Translator.Cli` for linux-arm64;
- a **portable toolchain** made by upstream's own `prepare-portable-tools.sh`: LLVM 22 (clang, lld, llvm-ar), CMake and Ninja.
`setup.sh <iso>` then runs four steps, once, resumable:
1. **Extract.** nodtool (fetched once and checked against a pinned sha256) reads the game id, requires RMCP01, checks `main.dol` and `StaticR.rel` against the sha256 pins in `projects/mkwii/recomp.yml`, and extracts to `game/`.
2. **Translate.** `translate-recursive`, `emit-base-manifest`, `generate-data-init --target-os linux` and `emit-build-shards`, the same sequence as upstream's `local-build.sh`.
3. **Build.** The translated shards are compiled with the kit's flags and the bundled compiler, three at a time (about 2.5 GB of memory each), and linked against the kit with lld.
4. **Install.** The binary and its resources go to `bin/`, with the kit fingerprint beside it, and `Config.toml` is seeded from `Config.frame.toml` if missing.
The whole run took about 6 minutes on my Frame. The wrapper compares the installed kit's fingerprint with the one recorded beside the binary and refuses to start if they differ. `frame/boundary-check.sh` checks a directory or tarball for anything disc derived, and files over 1 MB that `frame/kit-allowlist.txt` does not allow. Details for maintainers are in [frame/SETUP.md](../frame/SETUP.md).
### OpenXR session and renderer
Dawn's Vulkan backend is patched with upstream's own patch set at upstream's pinned revision (Aurora Dawn Vulkan ABI 1). With it the OpenXR runtime creates Dawn's `VkInstance` and `VkDevice` through `xrCreateVulkanInstanceKHR` and `xrCreateVulkanDeviceKHR`, borrowed swapchain images are wrapped as Dawn textures, and the eyes are rendered straight into the acquired images with no extra copy. The patched Dawn is built once on the Frame as a static monolithic library for linux-aarch64 (`frame/build-dawn.sh`), and aurora links it as a package.
I did not change the patch. What the Frame needs on top, I did in the bridge (`openxr_vulkan_win32.cpp`, which upstream wrote for the PC and which Linux now uses):
- The release barrier submission that follows aurora's submit is followed by an empty queue submission that signals a timeline semaphore, under Dawn's device lock. The pacing thread waits for it before it releases the swapchain image.
- Single level swapchains, array size 1, an sRGB sibling format with mutable format.
- The Vulkan instance is kept at 1.2 or lower.
- Dawn's device lock is held around exactly `xrBeginFrame`, `xrEndFrame`, `xrAcquireSwapchainImage` and `xrReleaseSwapchainImage`, because SteamVR's client submits on the one queue inside those calls.
- Two sets of eye swapchains, so that two frames can be in flight.
### Frame loop
Every refresh (8.33 ms at 120 Hz):
1. `xrWaitFrame` and `xrBeginFrame`.
2. One `xrLocateViews`. Both eyes, the HUD screen, the settings panel and the pointer use those poses and no others.
3. The pose packet goes to aurora's frame worker, which interpolates the retained game frame to that refresh's display time, renders both eyes into the acquired set and submits on Dawn's queue.
4. In **inline** mode the pacing thread waits for the GPU to finish that set, releases it and ends the frame with the same poses. In **pipelined** mode the set stays acquired into the next refresh: the previous set is waited for, released and ended with its own poses, whose display time is this refresh's by construction. The price is one refresh of latency; the gain is CPU and GPU work that overlap.
5. If a set is not finished by the deadline, the previous layer is shown again with its own poses, and the miss is counted. An unfinished image is never released.
`[vr] frame_pacing` is `auto`, `inline` or `pipelined`; `auto` is pipelined, because inline did not fit encode plus GPU of both eyes into one refresh in the mock measurements. `KARTFRAME_PACING` overrides it. An optional fallback to new eyes every second refresh exists (`KARTFRAME_HALF_RATE`). Its default is being settled after the first headset session. [to confirm in the headset]
The game's clock is converted to `XrTime` with `XR_KHR_convert_timespec_time`, which SteamVR offers. The first frame's `CLOCK_MONOTONIC` anchor is kept as a fallback. Input keeps its state through a loss of focus shorter than a second, because SteamVR takes focus for a few milliseconds now and then.
Once a second the game logs one line: `vr pacing: t= hz= mode= ticks= rendered= interpolated= late= missed= repeats= gpu_ms= encode_ms=`. `frame/tools/session-report.py` reads it together with `vrtiming.log` and writes `report.txt` in the session folder.
### Interpolation
The game's own VR frame interpolation (aurora's stereo replay with matched previous and current transform uniforms, up to three interpolated frames) with `[vr] frame_interpolation_fps = 1`. It renders on every compositor tick, so a 120 Hz display gets 120 stereo presentations a second from the game's 60 frames a second, each interpolated to that tick's display time and drawn from that tick's head pose. Frame synthesis (`XR_EXT_frame_synthesis`) and `XR_FB_space_warp` are accepted by SteamVR on the Frame and ignored, so they are not used.
### SteamVR per-app settings
SteamVR drops its per-app settings now and then, so the wrapper sets them at every launch through `vrcmd` and reads them back from `steamvr.vrsettings`, logging each difference:
```
preferredRefreshRate=120 framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=1728
```
They only apply to a launch from the Steam library, where `SteamAppId` is set. 90 Hz felt wrong for a 60 frame a second game. [to confirm in the headset] The wrapper also sets `TU_DEBUG=sysmem` unless `KARTFRAME_SYSMEM=0`: on this GPU it was 7 to 8 percent faster for a draw bound game in the mock measurements. Valve's implicit layer `XR_APILAYER_VALVE_fdm_injection` sits in the Vulkan chain; `KARTFRAME_FDM_LAYER=0` sets `DISABLE_VULKAN_FDM_INJECTION_LAYER=1` so the two can be compared. Eye tracked foveation saved almost nothing and is not used.
### Resolution
1728 pixels per eye is SteamVR's 100 percent on the Frame, and it is the floor for the shipped default (`render_scale = 1.0`). I measure lower values for the record but do not ship them.
### Controls
The layout is in code, in `runtime/include/vr/frame_controls.h`, as pure functions that a table test checks without OpenXR. The runtime enables `XR_VALVE_frame_controller_interaction`, suggests bindings for `/interaction_profiles/valve/frame_controller`, and latches the layout when `xrGetCurrentInteractionProfile` reports that profile. There is no SteamVR binding file. The controls table in the README is the layout as coded. [to confirm in the headset]
The View button has a small state machine: a tap goes back on release, a half second hold opens the VR settings panel, and a press while the panel is open closes it without also going back.
### Controller models
SteamVR's own Frame controller models have about 93,000 vertices a hand, and animating them on the CPU every frame made menus slow in earlier tests. `frame/frame-controller-models.py` therefore reads them from SteamVR's `frame_controller` driver on the user's Frame at install, simplifies them to about 4,000 triangles a hand, bakes the ambient occlusion into the base colour texture and writes the game's own model format into `UserData/controller_models/frame`. Valve's files are only read locally, never copied into the repository or a release. If the converted models are missing, the game draws its procedural controllers. [to confirm in the headset]
### Menus and the HUD
The game's 2D screen (menus, intro screens, the race HUD) is shown as a flat quad layer in front of the player instead of being drawn into the eyes, placed by `hud_distance_meters` and `hud_width_meters`. This is being finished. [to confirm in the headset]
### Packaging
`frame/release.sh` builds `kartframe-1.0.0-linux-arm64.tar.gz` from a kit directory (`frame/make-kit.sh`), the toolchain tarball directory, the published translator, the `frame/` scripts, `Config.frame.toml`, the controller model converter, the Steam shortcut helper, the two runtime tools (`vrtiming.py`, `session-report.py`), the README, the docs and the licences. It then runs `frame/boundary-check.sh` and `frame/tools/privacy-check.sh` on the tarball and writes its sha256.
## First headset session
The first session on a development build showed judder, half rate flapping and flickering menus. In more detail, from the logs and what I saw:
- Motion looked like 60 frames a second on a 120 Hz display, with ghosting when I turned my head.
- The 2D screen flickered together with the VR menu.
- The controller guide looked bad.
- The compositor itself was healthy: it reused almost no frames and its own time was small.
- The menus were drawn into the eyes with a projected surface. The intro and controller guide screens, together with the controllers, cost much more GPU time than the race path I had measured in the mock, and the fallback to every second refresh switched on and off repeatedly. A large share of the layers were repeats of an earlier set.
- The game asked for 90 Hz through `XR_FB_display_refresh_rate`, the only rate the runtime offers there, before the per-app setting gave 120 Hz.
Fixes made or in progress:
- Menus move to a flat quad layer. [to confirm in the headset]
- The every second refresh fallback is no longer taken as a default. [to confirm in the headset]
- The game stops asking for a refresh rate that the runtime does not offer. [to confirm in the headset]
- `TU_DEBUG=sysmem` is checked against translucent overlays. [to confirm in the headset]
- The item box (the roulette) is drawn in the wrong place; reprojected 2D draws are being clipped to their frame rectangle. [to confirm in the headset]
## The double image: what twenty sessions found
The symptom: in races, on head turns, the image sat on two positions alternating every refresh, worse
in one eye, clean in the menus (a quad layer) and clean in GalaxyFrame on the same SteamVR.
Eliminated, each by a direct test (the records are `frame/perf/session-h*/README.md` and `LANE_*.md`):
pacing and late sets, retained repeats, end times, pose stamps, per-eye scene state, interpolation
evenness, eye poses and FOV, swapchain format and usage, pair counts 2 to 8, a quad beside the projection,
GalaxyFrame's first-write barrier, the prediction distance of the poses (fresh poses for the exact display
time did not help), the rendered images themselves (240 consecutive sets dumped from the mock: no jump),
the desktop window's presents, the release ordering against the GPU (a strict mock check), the size of the
GPU work, the refresh rate, and the thread priority boost.
What SteamVR itself showed: the game's own recorded race images, handed over by a minimal probe loop
(`frame/tools/xrprobe --replay`) in six variations including the game's own order, were clean.
The mechanism, from logging every compositor frame beside the game's own timeline (`tools/vrtiming.py`
frame mode, `frame/tools/phase.py`): the compositor's update runs at about vsync + 3.0 to + 5.3 ms and
takes each eye's newest released image when it reaches that eye; the game handed its frame over near those
points, and whether a hand-over fell before or after an eye's pick-up changed from refresh to refresh. A
launch whose hand-over kept one side every frame was clean; one whose compositor updates started early in
episodes was doubled. Ending each frame at a fixed phase against the published grid (`KARTFRAME_END_PHASE`,
lane X) keeps one side and removes the doubling. It leaves the right eye one set ahead of the left and the
compositor reusing a frame now and then, so it is a mitigation. The probe ends its frames at the same phase
as the doubled default and is clean, so the phase is not the cause; the remaining difference is the 4-7 ms
of GPU work the game submits right after each hand-over on the GPU the compositor composes on. That is the
next experiment (the probe with synthetic GPU load; lane Y, unfinished).
## Building
Needs an Arm64 Linux machine with a compiler, CMake and Ninja (the Frame needs them installed; the release does not). The steps for developers, in short, are in [frame/DEV.md](../frame/DEV.md):
1. `frame/build-dawn.sh`, once: the patched static Dawn goes to `~/.cache/kartframe/dawn/<revision>-<patch hash>/`. The first build took about 12 minutes here.
2. Translate the game from your own disc (`frame/setup.sh translate`) to get a `generated` tree, then configure and build `runtime/` in Release mode with `-DMKW_DAWN_PACKAGE_DIR` pointing at that Dawn. The binary is `build-linux/kartframe`.
3. `frame/install.sh --dev` copies a build into the data folder and adds a separate shortcut, for testing.
4. `frame/make-kit.sh <dir>` makes the kit, `frame/toolchain.sh` the toolchain, `frame/publish-translator.sh <dir>` the translator, and `frame/release.sh` the tarball.
Games are run only against the mock runtime during development: `frame/tools/mockrun.sh` uses a private Xvfb, a dummy audio driver and aborts if the mock runtime file is missing.
## SteamVR on the Frame: settled facts
These came from probes and runs on the Frame, and I build on them:
- SteamVR shows a swapchain image the moment it is released, finished or not, in Vulkan too. Release only after a fence or timeline wait shows the GPU is done. It costs no latency.
- One waited, unreleased image per swapchain. Pipelining two frames needs two sets of eye swapchains.
- SteamVR allocates only mip 0 of a Vulkan swapchain, so swapchains have a single level.
- Locate the views once per refresh and render both eyes from exactly those poses; release and end the frame with those same poses. A right eye rendered a refresh later got random placement error.
- The Vulkan instance must be 1.0 to 1.2; 1.3 features come as extensions. There is one queue, and the SteamVR client submits on it inside the four calls above.
- `XR_EXT_frame_synthesis` and `XR_FB_space_warp` are accepted and ignored. Depth submission works.
- SteamVR takes focus for a few milliseconds now and then. That is not a pause.
- The runtime offers `XR_KHR_vulkan_enable2`, `XR_KHR_convert_timespec_time`, `XR_FB_display_refresh_rate`, `XR_KHR_composition_layer_depth`, `XR_EXT_eye_gaze_interaction` and `XR_VALVE_frame_controller_interaction`.
- At 120 Hz the GPU budget for both eyes including the composite is about 7.1 ms; SteamVR's compositor takes about 0.8 ms.
The probes and the mock runtime come from my GalaxyFrame work with bigmak94's GalaxyQuest (`xrmock`, and `xrprobe`, which is GPLv3 and not part of the game).
## How this was made
See "How this was made" in the [README](../README.md).
## Licence
GPLv3, as upstream. Third party parts keep their licences: see [THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md).
+36
View File
@@ -0,0 +1,36 @@
# KartFrame defaults for the Steam Frame, seeded into Config.toml on the first run (setup.sh and the
# launch wrapper copy this file only when there is no Config.toml). The in-game VR settings panel
# edits Config.toml in place afterwards; this file is never read again.
[paths]
# The extracted game's DATA folder (the one holding sys/fst.bin and files/). The placeholder is
# replaced with the real path when the file is seeded. TODO(lane B): confirm the placeholder name.
dvd_root = "@DVD_ROOT@"
[video]
widescreen = true
resolution_multiplier = 1.0
[vr]
enabled = true
required = false
# 1.0 renders the eyes at the size SteamVR's resolutionOverride (1728, set by the launch wrapper) asks for.
render_scale = 1.000000
# 1 = render at the headset's refresh rate (120 Hz), interpolated from the game's 60 fps.
frame_interpolation_fps = 1
frame_pacing = "auto"
refresh_rate = 120
# Grips hold the wheel in the first-person cockpit (the right bumper drifts there); elsewhere they drift.
hand_steering = true
# 0 = plain dark background behind the menus (the raymarched one is far too heavy).
menu_shader_quality = 0
hand_hud = false
# The virtual screen for menus and the race HUD, as the upstream defaults.
hud_virtual_screen = true
hud_distance_meters = 2.0
hud_width_meters = 2.4
foveation = "off"
passthrough = false
adaptive_resolution = false
[diagnostics]
openxr_logging = false
+242
View File
@@ -0,0 +1,242 @@
# KartFrame development notes (lane 0)
## Build
frame/build-dawn.sh # once: patched static Dawn into ~/.cache/kartframe/dawn/<rev>-<patch hash>/
~/Games/KartFrame/kfbuild.sh <worktree> WiiCompiled # the game, binary build-linux/kartframe
KF_CONFIGURE_ONLY=1 ~/Games/KartFrame/kfbuild.sh <worktree> # configure only (no lock)
`kfbuild.sh` takes the shared build lock, uses ccache (~/.cache/ccache-kf), links the worktree's
`generated` to the development translation (`~/Games/KartFrame/data/generated-dev`, never committed),
and passes the newest Dawn package (or `KARTFRAME_DAWN_DIR`) as `-DMKW_DAWN_PACKAGE_DIR`. The project
only accepts `CMAKE_BUILD_TYPE=Release`. The translated shards are the heavy part;
`MKW_TRANSLATED_COMPILE_JOBS` (default 3 in kfbuild) caps how many compile at once.
The CMake wiring for Linux: OpenXR on by default, pinned Vulkan headers and the pinned static OpenXR
loader (vendored jsoncpp) are fetched, the patched Dawn package is linked statically (its
`AuroraDawnVulkan*` ABI resolves at link time), SDL3 is built from source (audio: ALSA, JACK, PipeWire,
PulseAudio loaded at run time, plus dummy; video: Wayland, X11, KMS/DRM, offscreen, dummy). The shard
manifest and the blob assembly of a translation made elsewhere are relocated at configure time.
## Where the runtime looks for things
Executable `kartframe` (target `WiiCompiled`). Its resources (`dsp_coef.bin`, `initial_pipeline_cache.db`,
`wii_bootstrap/`, `resources/`) are copied next to it in the build directory and are found next to the
executable.
User state, from `runtime_config.h`:
- Normal install: `$XDG_DATA_HOME/WiiCompiled` (default `~/.local/share/WiiCompiled`), holding
`Config.toml`, `NAND/`, `Cache/`, `Logs/`.
- Portable: a file `portable.txt` in the executable's directory or up to a few parents makes
`<that dir>/UserData` the root instead.
- `Config.toml` is created with defaults on first start. The game files are not found automatically:
`[paths] dvd_root` must name the extracted DATA directory (the one holding `sys/fst.bin` and
`files/`); a relative value is relative to the Config.toml. `[paths] nand_root` is optional; when it
is absent the managed NAND is `<state dir>/NAND` (the licence is made in-game on first boot).
- `[vr] enabled = true, required = false` (defaults) try OpenXR and fall back to the desktop renderer.
The OpenXR loader reads `~/.config/openxr/1/active_runtime.json` (SteamVR) unless `XR_RUNTIME_JSON`
is set. `[vr] force_steamvr` does nothing on Linux.
- The program takes no command line arguments.
## Tools and runs
`frame/tools/mockrun.sh [--flat] [--seconds N]` runs the game against the mock runtime (xrmock) or flat,
headless: a private Xvfb for the window (Dawn needs a window surface to pick the adapter),
`SDL_AUDIO_DRIVER=dummy`, and `KARTFRAME_HEADLESS=1`, which makes aurora skip configuring and
presenting the desktop swapchain (it renders into an offscreen texture and logs a frame count). The
scratch user state is `~/.cache/kf-mockrun/data/WiiCompiled`. It aborts when the mock runtime file is
missing or empty and when a game is already running. Never run the game with the real runtime from an
agent. Known facts: Valve's implicit OpenXR layer `XrApiLayer_VALVE_fdm_injection` is loaded in front of
the mock; the mock offers no `XR_KHR_convert_timespec_time` yet, so the game logs "no clock
conversion"; abseil, zlib, libpng and freetype are built static from pinned sources (AURORA_VENDOR_THIRD_PARTY); the binary needs only glibc, libstdc++ and libgcc_s.
## Pacing knobs (lane A)
Config `[vr] frame_pacing = auto|inline|pipelined` (auto = pipelined, measured) and `refresh_rate`
(120; requested in-app only when SteamVR offers exactly that rate, else the wrapper's per-app key
decides). Menus are a quad layer fixed in the room (menu_shader_quality 0), the first-run guide is
projected. Env: KARTFRAME_PACING overrides the mode; KARTFRAME_REFRESH_HZ the rate;
KARTFRAME_PACING_MARGIN_US (1000) is kept free before the deadline for release + xrEndFrame;
every refresh always by default (a late set repeats the retained layer, which SteamVR reprojects;
half rate is 60 Hz content shown twice, the judder H1 rejected); KARTFRAME_HALF_RATE=1 always every
second refresh (measurements), =auto drops after more than 5 % late over 30 s and stays there for
the session; KARTFRAME_PACING_DEBUG=1 per-set timelines;
KARTFRAME_NO_GPU_WAIT=1 negative test only. KARTFRAME_RELEASE=idle (lane U, headset A/B) drains Dawn's
queue (vkQueueWaitIdle under the queue guard) right before each set's releases and its xrEndFrame, so
SteamVR's client submissions there never wait behind our next set's GPU work; logs `release idle
drain` every 10 s (frame/perf/LANE_U.md). The wrapper sets TU_DEBUG=sysmem unless
KARTFRAME_SYSMEM=0 (the race holds 120 Hz at 1728 only with it). Log line per second:
`vr pacing: t= hz= mode= ticks= rendered= interpolated= late= missed= repeats= gpu_ms=p50/p99 encode_ms=p50/p99`
(format in runtime/include/vr/openxr_diagnostics.h); session-report.py reads it with vrtiming.log.
Mock: MOCK_XR_BUSY_MS (exact released-unfinished check; lane U: plus the strict check, bytes at the
release against the next acquire, and `release queue scN` lines: how long a submission made inside
xrReleaseSwapchainImage waits behind the app's queued GPU work, per swapchain and frame parity), MOCK_XR_FOCUS_BLIP=<ms>[@<s>],
MOCK_XR_PROFILE, MOCK_XR_EYE, MOCK_XR_TIME_OFFSET_S, MOCK_XR_DUMP=<dir> (released images as PPM at
MOCK_XR_DUMP_AT seconds); mockrun seeds welcome/tutorial done so the demo race starts by itself
(KF_MOCKRUN_INTRO=1 shows them). KARTFRAME_FPSLOG=1 logs the game frame rate every 5 s;
KARTFRAME_SYNC_WINDOW=1 restores aurora's per-frame SDL_SyncWindow (10 ms a game frame on X11);
KARTFRAME_HUD_CLIP=0 stops clipping the race's 2D layer to the frame and the game's scissor.
## Lane G switches
KARTFRAME_SCENE_CLOCK=0 restores the old interpolation rule (scene sampled at the display time;
with pipelined pacing that pinned the weight to the newest frame). KARTFRAME_END_TIME=cycle ends
layers at the cycle's predicted time instead of their poses' locate time. KARTFRAME_MENU_ENV=over
puts the menu environment layer (controllers, lasers) over the screen quad; =0 turns it off.
KARTFRAME_FRAME_STAMP=1 (tests) stamps each eye image with its locate time. Mock: MOCK_XR_STAMP,
MOCK_XR_PREDICT_JITTER, MOCK_XR_FOV (default Frame-like asymmetric), MOCK_XR_HANDS, MOCK_XR_PRESS
(scripted presses; frame/perf/hudrace.sh drives a real Grand Prix race). Log lines: "vr display
times" and "VR interpolation weights" every 10 s, eye fields of view once. Details in frame/perf/H2.md.
## Lane H switches
KARTFRAME_END_TIME=locate (lane G's rule; default now: each frame's own predicted time).
KARTFRAME_THREADPRIO=high|nice|off (game, pacing, frame worker, GX and GPU watcher threads at up to
nice -15 through RealtimeKit), KARTFRAME_UCLAMP=0 (no utilization clamp; by default the game thread
runs on the big core). KARTFRAME_FULL_VIEW_FADES=0 keeps full-frame fades on the screen (default:
across the whole eye; about 0.3 ms GPU a set). KARTFRAME_COPY_TRIM=0 keeps the minimap copy's last
columns (default trims them: removes the thin line by the map). KARTFRAME_MENU_ENV defaults to
over (controllers and laser over the screen quad, alpha blended); =under is lane G's order.
KARTFRAME_LOG_2D_QUADS logs large 2D rectangles (to find the Blooper ink later). Mock: MOCK_XR_LEAD
(display lead in refreshes, default 3 like SteamVR), MOCK_XR_DUMP_STEP, MOCK_XR_DUMP_ALPHA,
MOCK_XR_HANDS=high. Details in frame/perf/H3.md.
## Lane I switches
KARTFRAME_SINK=srgb|copy|blit: how eyes reach the swapchain (default srgb: an sRGB-decoding draw
through an sRGB view, so the swapchain needs no MUTABLE_FORMAT and the driver can compress it; copy is
the old path). KARTFRAME_DIRECT_EYES=0|1 forces rendering straight into the swapchain images (by default
measured per session; never applies with the srgb sink). KARTFRAME_MONO_FULL=1 and
KARTFRAME_NATIVE_CLIP=0 restore the full native race render (by default it stops after the last copy
the eyes sample and is clipped to it; the desktop window is black during races). KARTFRAME_FPSLOG=2
logs a per-segment GPU profile (frame/perf/gpuprof.py tabulates it). Mock: MOCK_XR_MUTABLE=0.
Details and the profile in frame/perf/RACE120.md; A/B pairs with frame/perf/abgp.sh.
## Lane J switches
KARTFRAME_EYE_PAIRS=2..8 (lane J set 6; back to 2 for the old loop, 3 for sets ahead): eye swapchain pairs the per-refresh sets rotate through; a pair is
acquired again only after the frames that showed it are well past SteamVR's composition (the sixth
session's recording showed one eye at a time re-showing an image 3 to 4 refreshes old with two pairs).
KARTFRAME_FRAME_LOG=1 writes one XRF line per frame (wake-up, end after wake-up, pair, image per eye,
display step). Mock: MOCK_XR_COMPOSITE_MS and MOCK_XR_COMPOSITE_SELECT=spec|lazyrel|lazyacq model when
SteamVR picks each eye's image. frame/perf/panels/analyse.sh SESSION_DIR analyses a KARTFRAME_RECORD=raw
recording per eye (stale and held refreshes, eyes out of step) in about 4 minutes; see frame/perf/PANELS.md.
The wrapper takes one-launch settings from KARTFRAME_HOME/session.queue/*.env in name order when no
session.env is waiting.
## Lane K switches
The default `pipelined` loop renders each set two refreshes ahead and ends every frame at SteamVR's
wake-up with the newest finished set (else the retained layer), never waiting inside the cycle.
KARTFRAME_PIPELINED=wait restores the old one-set-at-a-time loop. KARTFRAME_EYE_PAIRS defaults to 3 with
sets ahead (3 to 8) and 2 with wait. KARTFRAME_AHEAD_START_US delays each set's start after the wake-up.
KARTFRAME_END_DELAY_US (test only, default 0, max 8000) sleeps that long after the wake-up before the frame is ended (same
set; the next set starts at the wake-up, before the sleep); the XRF e= field and end_late= then show the lateness by design.
The pacing line ends with dropped= (finished sets released unshown) and end_late= (ends more than 1 ms
after the wake-up). Mock: MOCK_XR_LATE_END_MS (1.5) models late ends; KF_MOCKRUN_END_GATE=<percent>
fails a run with more late ends. Details: frame/perf/H7.md.
## Lane L and M switches
KARTFRAME_FRAME_LOG=1 now also writes an XRS line per set (the scene state each eye's draws used:
weight, game-frame pair, draw counts; the mock compares the eyes) and an XRE line per locate (each
eye's offset from the head and its change since the last locate), plus a per-second "vr eyes" summary.
KARTFRAME_EYE_OFFSETS=located|fixed (default located): fixed renders and submits each eye at the head
pose with the median eye-to-head offset and field of view of the first 120 focused locates, so
per-refresh eye motion from the runtime cannot reach the images. KARTFRAME_AHEAD_REFRESHES=1|2
(default 2): locate sets one refresh ahead (sets then do not fit a refresh in races: about half late).
Session plan: frame/perf/session-h9/README.md.
## Lane O switches (the hand-over GalaxyFrame's way)
KARTFRAME_XR_FORMAT=rgba|bgra (default rgba: eye swapchains VkFormat 43, the sRGB draw swizzles),
KARTFRAME_XR_USAGE=galaxy|kart (default galaxy: COLOR_ATTACHMENT | SAMPLED), KARTFRAME_END_LOCATE=1|0
(default 1: one more locate for the frame's own time before ending it, race frames), and
KARTFRAME_XR_EXTENSIONS=galaxy (GalaxyFrame's extension list: drops hand steering, other headsets'
profiles and the clock conversion; default stays KartFrame's). Mock: MOCK_XR_TRACE_DETAIL=1.
Comparison and remaining candidates in frame/perf/H9.md.
## Lane P instrumentation
With KARTFRAME_FRAME_LOG=1 the XRS line carries XRP fields (per eye: the packet used, the pose
written, the frustum, draws filled from another packet; same=1 when the eyes agree), "VR eye poses
(10 s)" and "VR eye uniform probe (10 s)" summaries (KARTFRAME_UNIFORM_PROBE=1 for the probe alone);
the stamp is a 64x15 one-pixel-per-bit block; the mock reads back and decodes before its xrWaitFrame
sleep so end-after-wake-up is not inflated. In the mock both eyes always used the same packet and pose.
## Lane Q switches
KARTFRAME_RACE_QUAD=1|cycle|0 (default 1: a transparent 16x16 quad listed after every projection-only
layer; cycle also acquires and releases one of its images every frame), KARTFRAME_FIRST_WRITE_BARRIER=
all|dawn (default all: GalaxyFrame's ALL_COMMANDS / MEMORY_WRITE to MEMORY_READ|WRITE barrier on every
image of a set before its writes, submitted by the bridge). XRF lines end in n=<layers>; xrmock prints
"layer lists per xrEndFrame". Plan: frame/perf/session-h11/README.md.
## Lane R switches (the pose check, and the two levers)
GalaxyFrame's pose check (notePoses) runs at every xrEndFrame with located views: lane O's end locate
(one xrLocateViews for the frame's own display time, in the layer's space, after the release) is now
that check's locate, made in race and menu frames alike (lane O: race only); KARTFRAME_END_LOCATE=0
still leaves it out. Every 5 s, and when the scene changes, in GalaxyFrame's words plus the scene and
the locate's own cost: `vr: views of N sets: M for the display time they went out at (off X ms avg, Y
max); against a locate then: left eye A mm B deg avg, C mm D deg max; right eye ...; eye distance
E-F mm; race|menus, S s; that locate G ms avg H max` (frames that show a new set, each once, as
GalaxyFrame counts them). `vr: poses predicted ahead (N sets, S s, race|menus): display time minus the
set's locate p50 X ms, max Y` (every set, at its start; sets ahead: two refreshes plus SteamVR's lead).
XRF lines end in aL=/aR= (degrees between each eye's rendered pose and the check's locate, every frame;
-1 not checked). session-report.py shows both per scene. KARTFRAME_AHEAD_REFRESHES=1 now starts the
next set at the wake-up before the frame is ended (GalaxyFrame's order) without waiting for Aurora
(a set still being encoded: the start waits after the end as before), with 4 eye pairs by default.
KARTFRAME_SUBMIT_POSE=fresh|rendered (default rendered): fresh submits race frames that show a new set
with the check's poses (the images stay as rendered; repeats of the retained layer keep their own).
Mock: MOCK_XR_STAMP=fresh checks such layers (poses from a locate for their own display time; each
image against the locate of its own stamp: "fresh-pose stamps"). Plan: frame/perf/session-h12/README.md.
## Lane S switches (mock only)
MOCK_XR_SWAY=<deg>,<period s>[,<from s>] sizes the head's sway (default 17.19,2) and holds the head still
until <from>. MOCK_XR_PRESS=<path>@<start>/<every>/<hold s>/<until s> holds a press longer (1000/1000:
held from <start> on, e.g. A to drive) and stops it at <until>. MOCK_XR_DUMP_QUEUE=<slots> dumps eye
images without stalling (blitted down to 1/MOCK_XR_DUMP_STEP, written when the dumps pause for 0.3 s);
every MOCK_XR_DUMP writes <dir>/index.txt (file, swapchain, frame, and with MOCK_XR_STAMP the eye, the
display time and the scene words). frame/perf/mockdump/dumprace.sh NAME records 240-set windows of a race
(head still, standing with the sway, driving); frame/perf/mockdump/zigzag.py DIR applies H7's zigzag
rule, a betweenness test and a lagdiff per eye (ZZ_INJECT=2: positive control, ZZ_TRACK=1: positions).
Results: frame/perf/LANE_S.md; next: frame/perf/session-h13/README.md.
## Lane T (session 13: the recorded race images through xrprobe)
`frame/tools/xrprobe/xrprobe.c --replay DIR` plays a dumprace.sh recording's eye images at their recorded
poses and fov (one set per refresh by display time; `--replay-window standing|driving`, `--replay-offset`,
`--replay-pairs 3` KartFrame's sets ahead, `--replay-quad` the race quad, `--replay-hold 2`). Build:
`cmake -S frame/tools/xrprobe -B ~/.cache/kf-xrprobe -G Ninja -DCMAKE_C_COMPILER=clang` (OpenXR headers from
GalaxyFrame's tree, or -DOPENXR_INCLUDE=...), then the build under the KartFrame lock. `replay-case.sh N`
runs case N (1..6); `frame/perf/session-h13/queue.sh BINARY RECORDING` queues the six as KartFrame launches
(KARTFRAME_EXE in a session.queue file, recorded). Headless: `PROBE_MOCK=1 PROBE_SET=replay run-probes.sh`
(the mock runtime, detailed trace per case), then `frame/perf/session-h13/replaycheck.py DIR`.
## Lane W (the compositor's phase beside the app's timeline)
KARTFRAME_VRTIMING_FRAMES=1 (wrapper session.env) makes frame/tools/vrtiming.py also write vrframes.log in the
session folder: one F line per SteamVR compositor frame (index, m_flSystemTimeInSeconds, presents, mispresented,
dropped, reprojection flags, then waitgetposes / posesready / frameready / update start / update end / render
start in ms after that frame's system time, compositor cpu and gpu ms), a P line per poll (CLOCK_MONOTONIC,
_RAW, BOOTTIME read around IVRSystem::GetTimeSinceLastVsync, its counter, the newest frame), G lines for frames
missed between polls (polls every 0.25 s, 128 frames kept by SteamVR: none expected). About 45 MB an hour.
XRF lines now end in m=<wake-up, CLOCK_MONOTONIC ms> r=<release after the wake-up, ms, -1 none> t=<predicted
display time, CLOCK_MONOTONIC ms via XR_KHR_convert_timespec_time>; the end is m + e. One "XRF clock:" line
says how t= was converted. `python3 -I frame/tools/phase.py SESSION_DIR...` prints, per launch, (A) SteamVR's
own app timings against its vsync reference (frame ready, poses ready, the margin to the compositor's update
start) and (B) the app's wake-up, end, release and display time minus the nearest compositor anchor (vsync,
update start, render start), with histograms in 0.25 ms bins and a drift table (--every S, --all, --hist all).
Headless check of the logger: a stub libopenvr_api.so via KARTFRAME_STEAMVR_DIR (frame/perf/LANE_W.md).
The lock design and its risks: frame/perf/LANE_W.md; next session: frame/perf/session-h19/README.md.
## Lane X (the end locked to the compositor's grid, test only)
KARTFRAME_END_PHASE=after[:us]|before[:us] (default off; in the wrapper's session.env, which also reaches
frame/tools/vrtiming.py) times each frame's end against SteamVR's compositor instead of the wake-up: the helper polls
every 0.1 s and publishes the newest vsync (CLOCK_MONOTONIC), the period and the update window's start p5/p50 and end
p50/p95 as a 64-byte record (atomic rename) at KARTFRAME_PHASE_FILE (default $XDG_RUNTIME_DIR/kartframe-phase.bin);
the pacing thread re-reads it at most every 50 ms (about 1 us a read) and ends at vsync + update end p95 + us (after,
300) or vsync + update start p50 - us (before, 1500), bounded by the cycle's deadline, keeping lane V's order (next
set started at the wake-up, 4 pairs). No fresh grid (missing, older than 0.5 s, period 1 % off): KARTFRAME_END_DELAY_US,
or 4000 us. Log lines "end phase:" (mode, every switch, the achieved phase every 10 s). phase.py view C classifies
every end against its refresh's update window (before / inside / after, SteamVR's presents per class) and view D
prints the game's lines. Mock: mockrun.sh runs the helper against frame/tools/openvr-stub.c (STUB_UPDATE_MS,
KF_MOCKRUN_STUB_UPDATE_MS, KF_MOCKRUN_PHASE_SECONDS) with MOCK_XR_ALIGN_US. Analysis: frame/perf/LANE_X.md; next
session: frame/perf/session-h20/README.md.
## Wrapper recorder (KARTFRAME_RECORD)
The recorder starts 8 s after the game and only if the game is still alive then; it runs in its own
session (setsid) and the wrapper stops it on every exit path (game exit, Steam's TERM/INT/HUP): SIGINT
and up to 30 s for the file, then TERM, then KILL, then a pkill on anything writing into the session's
panels.mkv. The log says "recorder stopped" or "recorder was never started"; a leftover
panels.mkv.part is logged with its size and left for the owner. frame/test-wrapper-recorder.sh checks
the four paths with a fake game and a fake framecorder (about 45 s).
+159
View File
@@ -0,0 +1,159 @@
# Game kit, first-run setup and toolchain: developer notes
Nothing disc-derived is ever committed, put in the kit, or released. What ships is a **kit**; the
game itself is built on the user's Frame from the user's own disc. Timings are from the Frame
(5 CPUs, 15 GB, page cache warm).
## What a user runs
`frame/setup.sh <iso>` (the app dir's `setup.sh`), once, idempotent and resumable. It needs only the
stock system tools (bash, coreutils, awk, sed, grep, sha256sum, curl for nodtool) plus what ships
with the release: `kit/`, `toolchain/`, `translator/`. No system compiler, cmake, ninja or python is used.
| Step | What | Time (scratch run, 5 CPUs) |
| --- | --- | --- |
| verify kit | `kit.json` fingerprint recomputed, every kit file checked against `kit.sha256` | under 1 s |
| extract | nodtool checks RMCP01 and the `main.dol` / `StaticR.rel` pins, extracts to `$KARTFRAME_HOME/game` | 2 s warm (a minute or two cold) |
| translate | translator -> `$KARTFRAME_HOME/build/{generated,build}` | 49 s |
| build | `kitbuild.cmake` writes `build/kit-build/build.ninja`; the toolchain's ninja compiles 72 translated + 17 product shards, 2 data sources, 1 assembly (3 parallel) and links `kartframe` | 305 s (PCH 2x, shards, link 14 s) |
| install | `bin/kartframe`, resources beside it, `kartframe.fingerprint`, `kartframe.build.txt`, `portable.txt`, `UserData/Config.toml` if missing | under 1 s |
| **total** | | **356 s** (about 6 minutes; the 20 minute budget has room for slower disks and a cold extract) |
A second run does nothing (0.3 s). Deleting objects or the binary and rerunning rebuilds only those
(ninja); the relinked binary is byte-identical (sha256 equal), the build is reproducible.
`setup.sh status` shows what is done. `--force` redoes everything.
Layout (`LANE_RULES.md` "Install layout"): `$KARTFRAME_HOME` (default `~/.local/share/KartFrame`)
holds `portable.txt`, `UserData/`, `game/`, `build/`, `bin/`. `portable.txt` is found from the
executable's directory (`bin/`) one level up, so the game's `ApplicationDataDirectory()` is
`$KARTFRAME_HOME/UserData` (Config.toml, NAND, Cache, Logs; checked in the acceptance run: the
game wrote there and `~/.local/share/WiiCompiled` was not created). `Config.toml` is seeded only if
missing: from `Config.frame.toml` (token `@DVD_ROOT@` -> `$KARTFRAME_HOME/game/DATA`), else just
`[paths] dvd_root`.
Environment: `KARTFRAME_HOME`, `KARTFRAME_KIT` (default `<app>/kit`), `KARTFRAME_TOOLCHAIN`
(default `<app>/toolchain`, then the cache, then `toolchain.sh` makes it), `KARTFRAME_TRANSLATOR`,
`KARTFRAME_CACHE`, `KARTFRAME_JOBS` (default 3, fewer if RAM is short: about 2.5 GB per compile),
`KARTFRAME_THREADS`.
The launch wrapper compares `<app>/kit/kit.json` `"fingerprint"` with `bin/kartframe.fingerprint`
(plain fingerprint string) and refuses to start on a mismatch; rerunning setup rebuilds against the
new kit (the translation is redone too, because it depends on the kit's runtime sources).
## The kit (`frame/make-kit.sh <dir>` then `frame/export-kit.py`, maintainer only)
`make-kit.sh` configures `build-kit` with the portable toolchain, builds the target
`kartframe_kit_probe` (WiiCompiled without any translated code, `runtime/cmake/PublicProducts.cmake`),
and runs `export-kit.py`, which reads everything from the real build graph (`ninja -t commands`,
`-t compdb`) instead of hand-written flags:
```
kit/kit.json compile flags per source kind (translated shards, product/registration shards,
runtime data sources, blob assembly), source slots (which translator list fills
which slot), link recipe with {kit}/{sysroot}/{workspace}, fingerprint
kit/kitbuild.cmake the first-run build: writes build.ninja from kit.json + the translator's shard lists
kit/objects, libs prebuilt runtime objects; aurora, Dawn (patched, static), SDL3, OpenXR loader,
abseil, zlib, libpng, freetype, fmt, imgui, zstd, sqlite, tracy ...: all static
kit/include runtime/include (everything the translated code includes: only this, checked)
kit/sysroot see below
kit/workspace projects/mkwii/{recomp.yml,MAP.txt} and runtime/src: the translator reads them
(native overrides), so the translation always matches the kit's runtime
kit/resources dsp_coef.bin, initial_pipeline_cache.db, wii_bootstrap/, resources/quest_touch_plus/:
copied beside the executable (taken from the product's own POST_BUILD copies)
kit/licenses repo and third-party licence texts
kit/kit.sha256 sha256 of every other file; fingerprint = sha256(sha256(kit.json without its
fingerprint line) + sha256(kit.sha256))
```
Resources, none disc-derived: `dsp_coef.bin` (4 KiB DSP coefficient ROM, already in the repo, hash
pinned in CMake), `initial_pipeline_cache.db` (shader recipe database), `wii_bootstrap/` (first-run
WiiConnect24 stubs), Touch Plus models (MIT, WebXR input profiles).
`export-kit.py` fails when a link input is outside the build or the Dawn package, is not a static
archive, or a `-l` library is outside the glibc stub set; it then compiles one sample of every
source kind with exactly the recorded flags and the kit's sysroot and fails if a header outside the
kit, sysroot, compiler resource dir or the sample's own directory is read.
`export-kit.py` needs python3 (maintainer machine only). Compile decisions worth knowing:
`-mcpu=native` is kept as in the build (all Frames are the same SoC; it is resolved on the user's
machine); PCHs of `mkw_pch.h` are made at setup (a PCH only works with the compiler that wrote it);
the translated slot links as `--start-lib ... --end-lib`, like the Quest kit.
Sizes: kit 184 MiB unpacked (libs 74, objects 18, resources 40, sysroot 55), 23.8 MiB as `.tar.xz`;
toolchain 581 MiB unpacked, 113 MiB as `.tar.xz`; sysroot 55 MiB (2306 files, included in the kit).
## Self-contained runtime: DT_NEEDED of `kartframe`
Rule: packages installed after the OS base image (`filesystem`, `glibc` and most others: 2026-09-15;
the owner's hand-installed dev packages: 2026-10-07) are not stock SteamOS. `pacman -Qo` / `-Qi`:
| DT_NEEDED (dev build, lane 0) | Package | Installed | Decision |
| --- | --- | --- | --- |
| libpng16.so.16 | libpng 1.6.43 | 10-07, explicit | static: aurora's pinned libpng 1.6.58 from source |
| libabsl_*.so.2508 (38 libs) | abseil-cpp 20250814 | 10-07 | static: aurora's pinned abseil 20240722.0 from source |
| libfreetype.so.6 | freetype2 2.13.2 | 10-07 | static: pinned freetype 2.14.3, harfbuzz / brotli / bzip2 / png off |
| (via freetype) libharfbuzz, libgraphite2 | harfbuzz 8.4.0, graphite | 10-07 | gone with the static freetype |
| (via freetype) libglib-2.0, libpcre2-8, libbrotli*, libbz2 | glib2, pcre2, brotli, bzip2 | 09-15 | gone (stock anyway) |
| libz.so.1 | zlib 1.3.1 | 09-15, stock | static anyway (libpng needs it; zlib 1.3.2 pinned source) |
| libm.so.6, libc.so.6, ld-linux-aarch64.so.1 | glibc 2.39 | 09-15, stock | dynamic |
| libstdc++.so.6, libgcc_s.so.1 | gcc-libs 15.1.1 | 09-15, stock | dynamic |
The switch is `AURORA_VENDOR_THIRD_PARTY` (aurora-main/extern/CMakeLists.txt), on by default on
Linux. Result, `readelf -d` / `ldd` of the binary built by `setup.sh`: `libm`, `libstdc++`,
`libgcc_s`, `libc`, `ld-linux-aarch64` only. Libraries the program loads at run time with `dlopen`
(SDL3's ALSA / PulseAudio / PipeWire / JACK / X11 / Wayland / DRM drivers, libdbus for the music
attenuation, libvulkan through Dawn) are not link dependencies; their package dates here are
10-07 (`alsa-lib`, `libx11`, `wayland`, `vulkan-headers` ...), which looks like owner installs or
upgrades. They are optional for SDL (it falls back) and Valve's own SteamVR needs libvulkan, X11 or
Wayland; not verified on a second Frame.
## Toolchain and sysroot
Toolchain: upstream's `Launcher/prepare-portable-tools.sh --arch aarch64` through `toolchain.sh`
(patched LLVM 22.1.8: clang, clang++, ld.lld, llvm-ar, llvm-ranlib; CMake 4.3.3; Ninja 1.13.2; all
sha256-pinned there). Fetched once (about 1.8 GB of downloads, 75 s) or shipped as
`toolchain-aarch64.tar.xz`.
The portable LLVM has no sysroot. With no `--sysroot` it reads the host's `/usr/include`, its libstdc++
headers (`/usr/include/c++/15.1.1`), `crt*.o`, libgcc and libs. Same package/date method:
| Used by the first-run build | Package | Installed | Stock? |
| --- | --- | --- | --- |
| glibc headers, `Scrt1.o crti.o crtn.o`, `libc.so`, `libm.so`, `libc_nonshared.a` | glibc 2.39 | 09-15 | yes |
| `libstdc++.so`, `libgcc_s.so` | gcc-libs 15.1.1 | 09-15 | yes |
| libstdc++ headers `/usr/include/c++/15.1.1` | gcc 15.1.1 | 10-07 | **no** (dev package) |
| `crtbeginS.o crtendS.o libgcc.a libgcc_eh.a` | gcc 15.1.1 | 10-07 | **no** |
| kernel headers (`linux/`, `asm/`, ...) | linux-api-headers 6.18 | 10-07 | **no** (dated after the image) |
So the kit carries a minimal sysroot (`frame/make-sysroot.sh`, 55 MiB, 2306 files) built from this
Frame's own packages, unmodified: glibc headers and crt objects, libc/libm/libstdc++/libgcc_s and
the dynamic loader (link-time copies, the binary still loads the user's own), libstdc++ headers,
kernel headers, `libgcc.a`, `libgcc_eh.a`, `crtbegin*/crtend*`. Licences are in
`sysroot/LICENSES` (glibc LGPL-2.1+, GCC GPL-3 with the Runtime Library Exception, kernel headers
GPL-2 with the syscall note). Every compile and the link use `--sysroot={sysroot}`; checked: the
include search list and `-H` output of a shard compile, and `-Wl,-t` of the link, name only files
under the kit sysroot, the toolchain's own resource dir (`stddef.h` and friends), and the kit.
Objects of the kit itself were built without `--sysroot` (SDL3 needs the host's X11/Wayland/ALSA
headers at configure time); the sysroot is a copy of the same files, so they are what it would produce.
## Extraction, translation (unchanged from phase 1)
- Disc: PAL `RMCP01`; `setup.sh` reads the game id with `nodtool info`, then checks `main.dol` and
`StaticR.rel` against the sha256 pins in `recomp.yml`. nodtool `v2.0.0-alpha.10` (the version
upstream pins), linux-aarch64 asset, sha256 `b0d94617ed2393334845669cc1f74f1e4fce0d6acb24e8be28c479689a1b907b`,
cached in `~/.cache/kartframe/nodtool/<version>/`.
- Extracted layout (as upstream's `DiscTool.cs`): `main.dol`, `StaticR.rel` (translator inputs),
`DATA/` (the runtime's `[paths] dvd_root`), `.complete` (written last).
- Translator: `frame/publish-translator.sh <outdir>` (maintainer, once, .NET 8 SDK in
`~/.local/opt/dotnet`) makes a 34 MiB self-contained single-file `Translator.Cli`.
- `setup.sh translate` is the base-product sequence of `Launcher/local-build.sh`: `translate-recursive`,
`emit-base-manifest`, `generate-data-init --target-os linux`, `emit-build-shards`. `<outdir>` is a
workspace root (`projects`, `Assets`, `runtime` are links; here `projects` and `runtime` point at the
kit's `workspace/`, in a source tree at the repo). Compared with the Windows installer's translation,
`generated/` is byte-identical except the blob assembly's section directive and absolute paths.
- Boundary check: `frame/boundary-check.sh <dir|tarball> [allowlist]` fails on disc-derived file types,
translator output names and directories, and files over 1 MB that `frame/kit-allowlist.txt` (globs)
does not allow. `frame/tools/privacy-check.sh <dir|tarball>` needs these entries in
`frame/tools/privacy-allow.txt` for the kit's third-party headers and licence texts (false positives:
author addresses in glibc and kernel headers, example IPs): `^(kit/)?sysroot/usr/include/`,
`^(kit/)?licenses/third-party/`, `^(kit/)?workspace/runtime/src/hle/net/network_socket\.cpp$`, each with
text regex `.*`. With them: 0 findings on the kit dir and the tarball.
+21
View File
@@ -0,0 +1,21 @@
# Built but not confirmed in the headset
One line per claim. When the owner has tested one, delete its line here and complete the text in README.md
and the release notes. Twenty headset sessions have confirmed most of the build; what is left:
## Rendering
- The item box (roulette) drawing in the right place during a race (the clip fix is in; the demo race has no HUD).
- Full-view 2D effects (Blooper ink, the black of a respawn) across the whole view. Deferred.
- The Time Trial ghost drawn in 3D (it is flat today, a known issue).
- Two and four player races at 120 Hz (heavier; measured in the mock only: 2p 113 sets/s, 6 % late).
## Screen and menus
- The laser pointer's dot on the 2D menu screen (the ray shows, the dot on the screen does not yet).
- hud_distance_meters and hud_width_meters away from their defaults.
## Install and first run
- setup.sh from the release tarball on a clean Frame, start to finish, by someone other than the owner.
- uninstall.sh on a real install.
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Fail if a directory or tarball holds anything derived from the game disc.
#
# boundary-check.sh <dir|.tar|.tar.gz|.tgz|.tar.xz> [allowlist-file]
#
# The allowlist holds one shell glob per line (relative path as listed in the tree/tarball, without
# any leading "./" or top-level folder; * also matches "/") for files over 1 MB that are allowed
# (the kit's libraries). frame/kit-allowlist.txt is the kit's. Default: that file when it exists.
set -euo pipefail
[[ $# -ge 1 ]] || { echo "usage: boundary-check.sh <dir|tarball> [allowlist]" >&2; exit 2; }
input=$1 allow=${2:-$(dirname "${BASH_SOURCE[0]}")/kit-allowlist.txt}
[[ -f $allow ]] || allow=/dev/null
work=
if [[ -d $input ]]; then
root=$input
else
work=$(mktemp -d "${TMPDIR:-$HOME/.cache}/kf-boundary.XXXXXX")
trap 'rm -rf "$work"' EXIT
tar -xf "$input" -C "$work"
root=$work
fi
# A path (or the path without its top-level folder, for tarballs) matching a glob of the allowlist.
allowed() {
local rel=$1 pat
while IFS= read -r pat; do
[[ -z $pat || $pat == \#* ]] && continue
# shellcheck disable=SC2053
[[ $rel == $pat || ${rel#*/} == $pat ]] && return 0
done < "$allow"
return 1
}
bad=0
flag() { echo "boundary-check: $1: $2" >&2; bad=1; }
while IFS= read -r -d '' f; do
rel=${f#"$root"/}
base=${rel##*/}; lower=${base,,}
case $lower in
*.dol|*.rel|*.szs|*.brres|*.brstm|*.thp|*.arc|*.u8|*.iso|*.wbfs|*.rvz|*.ciso|*.wia|*.bin.lz|rksys.dat|game.json|*game*.json)
flag "disc-derived file type" "$rel" ;;
data_sections_init*|base_translation_*|mkwii_base_manifest*|*_translation_sources.bin|translation-provenance*)
flag "translator output" "$rel" ;;
esac
# Anything under generated/ or functions/, or a translated shard.
if [[ /$rel == */generated/* || /$rel == */functions/* || /$rel == */build_shards/* ]]; then
flag "translator output directory" "$rel"
fi
size=$(stat -c %s "$f")
if (( size > 1048576 )) && ! allowed "$rel"; then
flag "over 1 MB and not allowlisted" "$rel"
fi
done < <(find "$root" \( -type f -o -type l \) -print0)
if (( bad )); then echo "boundary-check: FAILED" >&2; exit 1; fi
echo "boundary-check: ok ($input)"
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# Builds Aurora's patched Dawn for linux-aarch64 (static, monolithic, Vulkan + Null) and installs it
# as a package that aurora's Dawn provider consumes (AURORA_DAWN_PACKAGE_URL = package directory).
#
# frame/build-dawn.sh [--force]
#
# Same pinned Dawn revision, patches and CMake flags as android/Build-QuestDawn.ps1, minus the Android
# ones. The package lands in $KARTFRAME_CACHE/dawn/<rev>-<patch hash 12>/ (default
# ~/.cache/kartframe) with aurora-dawn.json, and is reused when the inputs match. Heavy: holds the
# shared build lock (~/Games/KartFrame/.build.lock when present), -j4, nice 10.
set -euo pipefail
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO=$(cd "$HERE/.." && pwd)
CACHE=${KARTFRAME_CACHE:-$HOME/.cache/kartframe}
JOBS=${JOBS:-4}
FORCE=0; [ "${1:-}" = "--force" ] && FORCE=1
REVISION=13abc3bc8ea2d3c2050f9e77a12d012108ceee24
ARCHIVE_SHA256=713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba
PATCH_DIR=$REPO/aurora-main/patches/dawn
FLAGS=(
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
-DDAWN_FETCH_DEPENDENCIES=ON
-DDAWN_BUILD_MONOLITHIC_LIBRARY=STATIC
-DBUILD_SHARED_LIBS=OFF
-DDAWN_ENABLE_INSTALL=ON
-DDAWN_BUILD_SAMPLES=OFF
-DDAWN_BUILD_TESTS=OFF
-DDAWN_BUILD_BENCHMARKS=OFF
-DDAWN_USE_GLFW=OFF
-DDAWN_BUILD_PROTOBUF=OFF
-DTINT_BUILD_TESTS=OFF
-DTINT_BUILD_CMD_TOOLS=OFF
-DTINT_BUILD_IR_BINARY=OFF
-DDAWN_ENABLE_VULKAN=ON
-DDAWN_ENABLE_NULL=ON
-DDAWN_ENABLE_DESKTOP_GL=OFF
-DDAWN_ENABLE_OPENGLES=OFF
# The flat mirror window needs a surface: keep the window system backends (headers are in SteamOS).
-DDAWN_USE_X11=ON
-DDAWN_USE_WAYLAND=ON
)
# Patch hash, as Build-QuestDawn.ps1: file name + LF-normalised text of every patch input.
PATCH_HASH=$(python3 -I - "$PATCH_DIR" "$REPO" <<'PY'
import hashlib, sys
patch, repo = sys.argv[1], sys.argv[2]
files = [patch + "/apply.py", patch + "/aurora_vulkan_hooks.h", patch + "/aurora_vulkan_interop.inc",
patch + "/aurora_fdm.h", patch + "/aurora_fdm.inc",
repo + "/aurora-main/include/aurora/dawn_vulkan_abi.h",
repo + "/aurora-main/include/aurora/dawn_fdm_abi.h"]
parts = []
for f in files:
text = open(f, encoding="utf-8", newline="").read().replace("\r\n", "\n")
parts.append(f.rsplit("/", 1)[1] + "\n" + text)
print(hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest())
PY
)
CC_ID=$(clang --version | head -1)
CACHE_KEY=$(printf '%s' "$REVISION|$ARCHIVE_SHA256|$PATCH_HASH|$CC_ID|${FLAGS[*]}" | sha256sum | cut -d' ' -f1)
PACKAGE=$CACHE/dawn/$REVISION-${PATCH_HASH:0:12}
LIB=$PACKAGE/lib/libwebgpu_dawn.a
if [ "$FORCE" = 0 ] && [ -f "$PACKAGE/aurora-dawn.json" ] && [ -f "$LIB" ] &&
grep -q "\"CacheKey\": \"$CACHE_KEY\"" "$PACKAGE/aurora-dawn.json"; then
echo "Patched Dawn is up to date: $PACKAGE"; exit 0
fi
LOCK=${KARTFRAME_BUILD_LOCK:-$HOME/Games/KartFrame/.build.lock}
mkdir -p "$(dirname "$LOCK")"
exec 9>"$LOCK"; echo "build-dawn: waiting for the build lock"; flock 9; echo "build-dawn: building"
WORK=$CACHE/dawn-work
mkdir -p "$WORK"
ARCHIVE=$WORK/dawn-source.tar.gz
if [ ! -f "$ARCHIVE" ]; then
curl -fL --retry 3 -o "$ARCHIVE.part" "https://github.com/google/dawn/archive/$REVISION.tar.gz"
mv "$ARCHIVE.part" "$ARCHIVE"
fi
echo "$ARCHIVE_SHA256 $ARCHIVE" | sha256sum -c - || { echo "Dawn archive does not match the pinned SHA-256" >&2; exit 1; }
# Patches go on pristine sources: src/ is extracted again when the patches changed; third_party/
# (filled by DAWN_FETCH_DEPENDENCIES) is kept.
SRC=$WORK/dawn-$REVISION
MARKER=$SRC/aurora-patches.sha256
if [ ! -f "$MARKER" ] || [ "$(cat "$MARKER")" != "$PATCH_HASH" ]; then
if [ -d "$SRC" ]; then
rm -rf "$SRC/src"
python3 -I -c 'import sys,tarfile
a,d=sys.argv[1:3]
with tarfile.open(a) as t:
m=[x for x in t.getmembers() if x.name.split("/",2)[1:2]==["src"]]
t.extractall(d,members=m,filter="data")' "$ARCHIVE" "$WORK"
else
python3 -I -c 'import sys,tarfile
a,d=sys.argv[1:3]
with tarfile.open(a) as t: t.extractall(d,filter="data")' "$ARCHIVE" "$WORK"
fi
python3 -I "$PATCH_DIR/apply.py" "$SRC"
echo -n "$PATCH_HASH" > "$MARKER"
fi
BUILD=$WORK/build-$CACHE_KEY
export CCACHE_BASEDIR=$HOME CCACHE_DIR=${CCACHE_DIR:-$HOME/.cache/ccache-kf}
L=(); command -v ccache >/dev/null && L=(-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache)
cmake -S "$SRC" -B "$BUILD" -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld -DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld \
-DPython3_EXECUTABLE="$(command -v python3)" "${L[@]}" "${FLAGS[@]}" -DCMAKE_INSTALL_PREFIX="$PACKAGE"
rm -rf "$PACKAGE"
nice -n 10 cmake --build "$BUILD" --parallel "$JOBS"
cmake --install "$BUILD"
[ -f "$PACKAGE/lib/libwebgpu_dawn.a" ] || { echo "Dawn archive missing" >&2; exit 1; }
command -v llvm-strip >/dev/null && llvm-strip --strip-debug "$PACKAGE/lib/libwebgpu_dawn.a"
LIB_SHA=$(sha256sum "$PACKAGE/lib/libwebgpu_dawn.a" | cut -d' ' -f1)
cat > "$PACKAGE/aurora-dawn.json" <<JSON
{
"SourceRevision": "$REVISION",
"SourceSha256": "$ARCHIVE_SHA256",
"PatchSha256": "$PATCH_HASH",
"AuroraVulkanAbi": 1,
"AuroraFdmAbi": 1,
"Target": "linux-aarch64",
"Compiler": "$CC_ID",
"Flags": "${FLAGS[*]}",
"CacheKey": "$CACHE_KEY",
"ArchiveSha256": "$LIB_SHA"
}
JSON
echo "Patched Dawn ready: $PACKAGE"
@@ -1,54 +1,37 @@
#!/bin/bash
# Collects the logs needed for a bug report into one archive, with personal data masked.
#
# steam-frame/collect-logs.sh [appid]
# frame/collect-logs.sh [SESSION_DIR]
#
# Without an appid it uses the newest Lepton prefix that has the WiiCompiled app in it.
# Collected: Lepton's launch log, the launch wrapper's log, Config.toml, the newest run's
# console.log, the newest build log, and a summary (SteamOS, APK and kit versions).
# Masked: IP and MAC addresses, UUIDs, hostnames, Wi-Fi network names, serial numbers,
# Steam IDs, email addresses, tokens, certificate and key blocks, and user names in /home paths.
# Without an argument the newest session is used ($KARTFRAME_HOME/sessions/latest). Collected: the
# session's launch.log, stdout.log, game.log, vrtiming.log, report.txt and session.env, the newest
# build log of setup, Config.toml, and a summary (SteamOS and KartFrame versions).
# Masked: IP and MAC addresses, UUIDs, hostnames, Wi-Fi network names, serial numbers, Steam IDs,
# email addresses, tokens, certificate and key blocks, and user names in /home paths.
# The masking is a best effort. Read the files before you post them.
set -euo pipefail
PKG=org.wiicompiled.quest
STEAM=$HOME/.local/share/Steam
COMPAT=$STEAM/steamapps/compatdata
if [ -n "${1:-}" ]; then
APPID=$1
else
APPID=$(ls -td "$COMPAT"/*/external/Android/data/$PKG 2>/dev/null | head -1 | sed "s|^$COMPAT/||; s|/.*||")
fi
[ -n "$APPID" ] && [ -d "$COMPAT/$APPID" ] || { echo "no WiiCompiled prefix found; pass the appid that install.sh printed"; exit 1; }
DATA=$COMPAT/$APPID/external/Android/data/$PKG/files/WiiCompiledOpenXRVR
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
STATE=$KFHOME/UserData
SESSION=${1:-$KFHOME/sessions/latest}
[ -d "$SESSION" ] || { echo "no session folder at $SESSION (launch the game once, or pass a session folder)"; exit 1; }
SESSION=$(readlink -f "$SESSION")
STAMP=$(date +%Y%m%d-%H%M%S)
WORK=$(mktemp -d)
RAW=$WORK/raw; OUT=$WORK/wiicompiled-frame-logs-$STAMP
RAW=$WORK/raw; OUT=$WORK/kartframe-logs-$STAMP
mkdir -p "$RAW" "$OUT"
grab() { [ -f "$1" ] && cp "$1" "$RAW/$2" || true; }
grab "$STEAM/logs/lepton-steamlaunch-$APPID.log" lepton.log
grab "$STEAM/logs/lepton-steamlaunch-$APPID.prev.log" lepton.prev.log
grab /tmp/wiicompiled-launch.log wiicompiled-launch.log
grab "$DATA/Config.toml" Config.toml
RUN=$(ls -td "$DATA"/Logs/*/ 2>/dev/null | head -1 || true)
[ -n "$RUN" ] && grab "$RUN/console.log" console.log
grab "$(ls -t "$DATA"/Logs/build_*.log 2>/dev/null | head -1 || true)" build.log
grab "$DATA/VR-diagnostics.txt" VR-diagnostics.txt
for f in launch.log stdout.log game.log vrtiming.log report.txt session.env; do grab "$SESSION/$f" "$f"; done
grab "$STATE/Config.toml" Config.toml
grab "$(ls -t "$KFHOME"/build/*.log "$KFHOME"/logs/*.log 2>/dev/null | head -1 || true)" setup.log
{
echo "collected: $(date -u +%Y-%m-%dT%H:%MZ)"
echo "session: $(basename "$SESSION")"
grep -E '^(VERSION_ID|BUILD_ID)=' /etc/os-release 2>/dev/null | sed 's/^/steamos /' || true
APK=$(ls "$HOME"/Games/WiiCompiledVR-SteamFrame/app/*.apk 2>/dev/null | head -1 || true)
if [ -n "$APK" ]; then
echo "apk sha256: $(sha256sum "$APK" | cut -d' ' -f1)"
echo "apk kit: $(unzip -p "$APK" assets/game_kit/kit.json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["fingerprint"])' 2>/dev/null || echo unknown)"
fi
GAME=$COMPAT/$APPID/internal/$PKG/files/game/base/game.json
[ -f "$GAME" ] && echo "built game kit: $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("kitFingerprint", ""))' "$GAME" 2>/dev/null)"
[ -f "$RAW/lepton.log" ] && grep -m1 -o 'Lepton v[^ ]* ([^)]*)' "$RAW/lepton.log" || true
[ -f "$RAW/console.log" ] && grep -m1 -c 'Steam Frame button layout active' "$RAW/console.log" | sed 's/^/frame layout active: /' || true
[ -x "$KFHOME/bin/kartframe" ] && echo "binary sha256: $(sha256sum "$KFHOME/bin/kartframe" | cut -d' ' -f1)"
[ -f "$KFHOME/bin/kartframe.fingerprint" ] && echo "built kit: $(head -c 64 "$KFHOME/bin/kartframe.fingerprint")"
} > "$RAW/summary.txt"
python3 -I - "$RAW" "$OUT" "$(hostname 2>/dev/null || true)" <<'EOF'
@@ -80,7 +63,7 @@ for name in sorted(os.listdir(src)):
open(os.path.join(dst, name), 'w').write(text)
EOF
DEST=${WCF_LOG_DIR:-$HOME}/wiicompiled-frame-logs-$STAMP.tar.gz
DEST=${KARTFRAME_LOG_DIR:-$HOME}/kartframe-logs-$STAMP.tar.gz
tar -czf "$DEST" -C "$WORK" "$(basename "$OUT")"
rm -rf "$WORK"
echo "wrote $DEST"
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Export the KartFrame game kit from a configured and built build dir.
The kit is everything the first-run build links except the translated game, plus the recipe that
compiles and links the player's own translation against it (modelled on upstream's Quest kit,
android/QuestGameKit.psm1). Nothing in it is derived from the disc. Compile flags and the link
line are read back out of the real CMake/ninja build graph (`ninja -t commands`, `-t compdb`), so
they cannot drift from a normal build.
export-kit.py --build <build dir with kartframe_kit_probe built> --sysroot <make-sysroot.sh dir>
--out <kit dir>
Placeholders in kit.json: {kit} the kit directory, {sysroot} the kit's sysroot, {workspace} the
directory that holds the translator's generated/ tree. frame/make-kit.sh drives this.
"""
import argparse
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
SCHEMA = 1
ALLOWED_SYSTEM_LIBS = {"-ldl", "-lrt", "-lm", "-lpthread", "-lutil", "-pthread"} # glibc stubs, stock
HERE = Path(__file__).resolve().parent
def die(msg):
sys.exit(f"export-kit: error: {msg}")
def run(cmd, **kw):
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kw).stdout
def sha256_file(p):
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def cache_var(build, name):
for line in (build / "CMakeCache.txt").read_text().splitlines():
if line.startswith(name + ":"):
return line.split("=", 1)[1]
return None
def ninja_commands(build, target):
return run(["ninja", "-C", str(build), "-t", "commands", target]).splitlines()
# ---- compile flags -------------------------------------------------------------------------
def reduce_flags(tokens, repo, kit_include_src, dropped):
"""Compile command tokens (compiler first) -> (kit flags, uses_pch)."""
i = 1
if tokens[0].endswith("ccache"):
i = 2
flags, pch = [], False
toks = tokens
while i < len(toks):
a = toks[i]
if a in ("-o", "-c", "-MT", "-MF"):
i += 2
continue
if a in ("-MD", "-MMD", "-Winvalid-pch") or a == "-g":
i += 1
continue
if a == "-isystem":
dropped.add(toks[i + 1])
i += 2
continue
if a == "-Xclang":
if toks[i + 1] == "-include-pch":
pch = True
i += 4
continue
if toks[i + 1] == "-include":
i += 4
continue
flags.append(a)
i += 1
continue
if a.startswith("-I"):
path = os.path.normpath(a[2:])
if path == kit_include_src:
flags.append("-I{kit}/include")
elif path == str(repo):
flags.append("-I{workspace}")
else:
dropped.add(path)
i += 1
continue
if a.startswith("--sysroot="):
i += 1
continue
if not a.startswith("-") and (a.endswith(".cpp") or a.endswith(".S") or a.endswith(".c")):
i += 1 # the source itself
continue
flags.append(a)
i += 1
return flags, pch
def compile_kinds(build, repo):
db = [e for e in json.loads(run(["ninja", "-C", str(build), "-t", "compdb"]))
if e["file"].endswith((".cpp", ".S"))]
patterns = {
"translated": lambda f: "/build_shards/base_common/" in f or "/build_shards/base_sensitive/" in f,
"product": lambda f: "/build_shards/base_registration/" in f or "/build_shards/base_dispatch/" in f,
"runtime": lambda f: f.endswith("/data_sections_init.cpp") or f.endswith("/guest_symbol_table.cpp"),
"asm": lambda f: f.endswith("data_sections_init_blobs_elf.S"),
}
kit_include_src = os.path.normpath(str(repo / "runtime/include"))
out = {}
for kind, match in patterns.items():
entries = [e for e in db if match(e["file"])]
if not entries:
if kind == "translated":
die("compile database has no translated shard; configure against a translation")
die(f"compile database has no {kind} source")
reduced = []
for e in entries:
dropped = set()
flags, pch = reduce_flags(shlex.split(e["command"]), repo, kit_include_src, dropped)
reduced.append((tuple(flags), pch, dropped, e))
first = reduced[0]
for r in reduced[1:]:
if (r[0], r[1]) != (first[0], first[1]):
die(f"{kind} sources do not share one flag set ({first[3]['file']} vs {r[3]['file']})")
out[kind] = {"flags": list(first[0]), "pch": first[1], "sample": first[3]["file"],
"compiler": shlex.split(first[3]["command"])[0 if not first[3]["command"].startswith("ccache") else 1],
"dropped": sorted(first[2]), "count": len(entries)}
return out
# ---- link recipe ---------------------------------------------------------------------------
def link_recipe(build, out, dawn_dir):
lines = [l for l in ninja_commands(build, "kartframe_kit_probe") if " -o kartframe_kit_probe" in l]
if len(lines) != 1:
die("could not find the kartframe_kit_probe link command (build the target first)")
line = lines[0]
line = line.split(" && ", 1)[1] if line.startswith(": && ") else line
line = line.split(" && ")[0]
toks = shlex.split(line)
toks = toks[1:] # compiler
flags, i = [], 0
while i < len(toks) and not toks[i].endswith(".o"):
t = toks[i]
if t == "-Xlinker" and toks[i + 1].startswith("--dependency-file"):
i += 2
continue
if t == "-Wl,--unresolved-symbols=ignore-all":
i += 1
continue
flags.append(t)
i += 1
flags.insert(0, "--sysroot={sysroot}")
copied = {}
counter = [0]
def vendor(path, sub):
p = Path(path)
if not p.is_absolute():
p = build / p
p = p.resolve()
if p in copied:
return copied[p]
inside = str(p).startswith(str(build.resolve()) + "/") or (dawn_dir and str(p).startswith(str(dawn_dir) + "/"))
if not inside:
die(f"link input outside the build and the Dawn package (not self-contained): {p}")
if p.suffix not in (".a", ".o"):
die(f"link input is not a static archive or object: {p}")
counter[0] += 1
name = f"{counter[0]:03d}_{p.name}"
dst = out / sub / name
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, dst)
copied[p] = f"{{kit}}/{sub}/{name}"
return copied[p]
objects, libs = [], []
skip = False
j = i
while j < len(toks):
t = toks[j]
if t == "-o":
j += 2
continue
if t.endswith(".o"):
objects.append(vendor(t, "objects"))
elif t.startswith("-l") or t == "-pthread":
if t not in ALLOWED_SYSTEM_LIBS:
die(f"link names a system library outside the stock set: {t}")
libs.append(t)
elif t.startswith("-"):
libs.append(t)
else:
libs.append(vendor(t, "libs"))
j += 1
# Game slots: runtime-generated objects and product shards sit with the runtime objects, the
# translated shards as an archive-like group before the libraries they call into.
inputs = objects[:-1] + ["{game:runtime}", objects[-1], "{game:product}", "{game:translated}"] + libs
return flags, inputs
# ---- resources ------------------------------------------------------------------------------
def collect_resources(build, out):
"""The files the game wants beside its executable: the POST_BUILD copies of the real product."""
resdir = out / "resources"
resdir.mkdir(parents=True, exist_ok=True)
pat = re.compile(r"cmake -E (copy_directory|copy_if_different)\s+(\S+)\s+(\S+)\s*(?:&&|$)")
seen = {}
for line in ninja_commands(build, "WiiCompiled"):
for kind, src, dst in pat.findall(line):
dst = os.path.normpath(dst)
if not dst.startswith(str(build.resolve()) + "/"):
continue
rel = os.path.relpath(dst, build.resolve())
if rel.startswith("..") or rel == "kartframe":
continue
seen[rel] = (kind, src)
if not seen:
die("no POST_BUILD resource copies found for WiiCompiled")
for rel, (kind, src) in sorted(seen.items()):
d = resdir / rel
if kind == "copy_directory":
shutil.copytree(src, d, dirs_exist_ok=True, symlinks=True)
else:
d.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, d)
return sorted(seen)
def collect_licences(build, repo, out):
lic = out / "licenses"
lic.mkdir(parents=True, exist_ok=True)
for name in ("LICENSE", "THIRD-PARTY-NOTICES.md"):
if (repo / name).exists():
shutil.copy2(repo / name, lic / name)
if (repo / "licenses").is_dir():
shutil.copytree(repo / "licenses", lic / "repo-licenses", dirs_exist_ok=True)
pat = re.compile(r"^(LICEN[CS]E|COPYING|COPYRIGHT|NOTICE|UNLICENSE|FTL|GPLv2)", re.I)
for src in sorted((build / "_deps").glob("*-src")):
dst = lic / "third-party" / src.name[:-4]
for cand in list(src.glob("*")) + list(src.glob("docs/*")) + list(src.glob("LICENSES/*")):
if cand.is_file() and pat.match(cand.name):
dst.mkdir(parents=True, exist_ok=True)
shutil.copy2(cand, dst / cand.name)
# ---- fingerprint ----------------------------------------------------------------------------
def manifest(out):
"""sha256 lines for every kit file except kit.json and kit.sha256 itself (sorted by path)."""
lines = []
for p in sorted(out.rglob("*"), key=lambda x: str(x.relative_to(out)).encode()):
rel = str(p.relative_to(out))
if rel in ("kit.json", "kit.sha256"):
continue
if p.is_symlink():
lines.append(f"{hashlib.sha256(os.readlink(p).encode()).hexdigest()} {rel} -> {os.readlink(p)}")
elif p.is_file():
lines.append(f"{sha256_file(p)} {rel}")
return "\n".join(lines) + "\n"
def fingerprint(kit_json_text, manifest_text):
"""sha256 over kit.json without its fingerprint line, then the file manifest. setup.sh
recomputes it with grep/sha256sum, so keep the two in step."""
body = "".join(l for l in kit_json_text.splitlines(True) if not l.startswith(' "fingerprint"'))
h = hashlib.sha256()
h.update(hashlib.sha256(body.encode()).hexdigest().encode() + b"\n")
h.update(hashlib.sha256(manifest_text.encode()).hexdigest().encode() + b"\n")
return h.hexdigest()
# ---- validation -----------------------------------------------------------------------------
def validate(kinds, out, repo, sysroot_rel="sysroot"):
"""Compile one sample of every kind with exactly the flags the kit records. Fails if a flag set
needs a header the kit does not carry, and if anything outside the kit, the sysroot, the
workspace or the compiler's resource directory is read."""
sysroot = out / sysroot_rel
for kind, k in kinds.items():
flags = [f.replace("{kit}", str(out)).replace("{workspace}", str(repo)) for f in k["flags"]]
flags = ["--sysroot=" + str(sysroot)] + flags
if k["pch"] or kind == "runtime":
flags += ["-include", "mkw_pch.h"]
comp = k["compiler"]
if kind == "asm":
cmd = [comp] + flags + ["-c", k["sample"], "-o", os.devnull]
else:
cmd = [comp] + flags + ["-H", "-fsyntax-only", k["sample"]]
r = subprocess.run(cmd, text=True, capture_output=True)
if r.returncode != 0:
die(f"kit flags for '{kind}' do not compile {k['sample']}:\n{r.stderr[-1500:]}")
if kind == "asm":
continue
resource = run([comp, "-print-resource-dir"]).strip()
bad = set()
for line in r.stderr.splitlines():
m = re.match(r"^\.+ (\S+)$", line)
if not m:
continue
h = os.path.realpath(m.group(1))
ok = (str(out) in h or h.startswith(os.path.realpath(sysroot)) or h.startswith(resource)
or h.startswith(str(repo)) or h.startswith(os.path.realpath(str(repo)))
or h.startswith(os.path.realpath(os.path.dirname(k["sample"])) + "/"))
if not ok:
bad.add(h)
if bad:
die(f"'{kind}' compile reads files outside the kit: {sorted(bad)[:5]}")
print(f"export-kit: validated {kind}: {Path(k['sample']).name}")
# ---- main -----------------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--build", required=True)
ap.add_argument("--sysroot", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--no-validate", action="store_true")
a = ap.parse_args()
build = Path(a.build).resolve()
out = Path(a.out).resolve()
sysroot = Path(a.sysroot).resolve()
home = cache_var(build, "CMAKE_HOME_DIRECTORY")
if not home:
die("not a configured CMake build dir")
repo = Path(home).resolve().parent
dawn = cache_var(build, "MKW_DAWN_PACKAGE_DIR")
dawn_dir = Path(dawn).resolve() if dawn else None
if not (build / "kartframe_kit_probe").exists():
die("kartframe_kit_probe is not built (ninja kartframe_kit_probe)")
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
kinds = compile_kinds(build, repo)
flags, inputs = link_recipe(build, out, dawn_dir)
shutil.copytree(repo / "runtime/include", out / "include", symlinks=True)
shutil.copytree(sysroot, out / "sysroot", symlinks=True)
resources = collect_resources(build, out)
collect_licences(build, repo, out)
# What the translator reads besides the disc: the project description and symbol map, and the
# runtime sources it scans for native overrides. The translation must match the runtime the kit
# was built from, so the kit carries them.
ws = out / "workspace"
(ws / "projects/mkwii").mkdir(parents=True)
for f in ("recomp.yml", "MAP.txt"):
shutil.copy2(repo / "projects/mkwii" / f, ws / "projects/mkwii" / f)
shutil.copytree(repo / "runtime/src", ws / "runtime/src", symlinks=True)
shutil.copy2(HERE / "kitbuild.cmake", out / "kitbuild.cmake")
clang_version = run([kinds["translated"]["compiler"], "--version"]).splitlines()[0]
if not a.no_validate:
validate(kinds, out, repo)
tree = hashlib.sha256()
for p in sorted((out / "include").rglob("*"), key=lambda x: str(x).encode()):
if p.is_file():
tree.update(str(p.relative_to(out / "include")).encode() + b" " + sha256_file(p).encode() + b"\n")
def kind_json(k, extra=None):
d = {"flags": k["flags"] + (["-Wa,--noexecstack"] if extra == "asm" else []), "pch": k["pch"]}
return d
kit = {
"schema": SCHEMA,
"target": "aarch64-unknown-linux-gnu",
"compiler": clang_version,
"runtimeIncludeFingerprint": tree.hexdigest(),
"sysroot": "{kit}/sysroot",
"workspace": "{kit}/workspace",
"compile": {
"translated": kind_json(kinds["translated"]),
"product": kind_json(kinds["product"]),
"runtime": kind_json(kinds["runtime"]),
"asm": kind_json(kinds["asm"], "asm"),
},
"sources": {
"runtime": ["{workspace}/generated/data_sections_init.cpp",
"{workspace}/generated/guest_symbol_table.cpp",
"{workspace}/generated/data_sections_init_blobs.S"],
"product": ["@MKW_BASE_REGISTRATION_SOURCES"],
"translated": ["@MKW_BASE_COMMON_SHARDS", "@MKW_BASE_PORTABLE_SENSITIVE_SHARDS"],
},
"link": {"output": "kartframe", "flags": flags, "inputs": inputs},
"resources": resources,
}
# Report what the reduction dropped, so a surprise shows up in the log.
for kind, k in kinds.items():
print(f"export-kit: {kind}: {len(k['flags'])} flags, {k['count']} sources in the build, "
f"pch={k['pch']}, dropped {len(k['dropped'])} include paths")
man = manifest(out)
(out / "kit.sha256").write_text(man)
text = json.dumps(kit, indent=2).rstrip("\n")
assert text.endswith("}")
# the fingerprint is the last top-level key, on a line of its own
text = text[:-1].rstrip() + ',\n "fingerprint": "PLACEHOLDER"\n}\n'
fp = fingerprint(text, man)
text = text.replace("PLACEHOLDER", fp)
assert fingerprint(text, man) == fp
(out / "kit.json").write_text(text)
size = sum(p.stat().st_size for p in out.rglob("*") if p.is_file() and not p.is_symlink())
print(f"export-kit: kit {out} ({size / 1048576:.0f} MiB) fingerprint {fp}")
if __name__ == "__main__":
main()
@@ -1,27 +1,29 @@
#!/usr/bin/env python3
"""Converts SteamVR's Steam Frame controller render models into the game's controller model format.
The game draws a controller model in each hand in its VR menus and tutorials. Upstream ships Meta's
Touch Plus models (runtime/assets/quest_touch_plus, from WebXR Input Profiles) and, on Windows, asks
SteamVR for the real controller's model; under Lepton that SteamVR interface is not available, so
on the Frame you get Quest controllers. This reads the Frame controller models that SteamVR's own
frame_controller driver keeps on every Frame and writes them in the same format, `WCC1`, so the
launch wrapper can put them in place of the Touch Plus files. Valve's models are only read from
your own SteamVR install, never redistributed.
The native Linux build draws a controller model in each hand in its VR menus and tutorials. It never
asks OpenVR for Valve's full render models (about 93k vertices re-animated on the CPU every menu
frame dropped menus to 45 fps). Instead this script reads the Frame controller models that SteamVR's
own frame_controller driver keeps on every Frame, simplifies them, and writes them in the game's
`WCC1` format into the user data directory; the game loads them from there when present and draws
procedural controllers otherwise. Valve's models are only read from your own SteamVR install, never
redistributed or committed.
What is converted, per hand:
- every visible component mesh (body, sticks, buttons, trigger, grips, bumper), triangulated, in
the OpenXR grip space the game draws controller models in (the model's `openxr_grip` component),
simplified toward 4000 triangles per hand (WCF_FRAME_MODEL_TRIANGLES; texture seams leave the
right hand at about 4800, Touch Plus has 4470);
simplified toward 4000 triangles per hand (KF_FRAME_MODEL_TRIANGLES; texture seams leave the
right hand at about 4800);
- the base colour texture with its ambient occlusion baked in, at 1024x1024;
- the nine motions the game animates, as SteamVR's model describes them: thumbstick click,
primary and secondary button (A and B on the right; the left Frame controller has none), trigger,
grip, and the thumbstick tilting left, right, back and forward;
- the nine motions the game animates: thumbstick click, primary and secondary button (A and B on
the right; the left Frame controller has none), trigger, grip, and the thumbstick tilting left,
right, back and forward;
- five landmarks the tutorials point at: thumbstick, primary, secondary, trigger, grip (the D-pad
and View button stand in for primary and secondary on the left).
usage: frame-controller-models.py OUTDIR [SteamVR rendermodels dir]
usage: frame-controller-models.py [OUTDIR [SteamVR rendermodels dir]]
OUTDIR defaults to $KARTFRAME_HOME/UserData/controller_models/frame (KARTFRAME_HOME defaults to
~/.local/share/KartFrame), where the game looks for them.
Needs Python 3 and GdkPixbuf (python3-gobject), both on SteamOS. No numpy or PIL.
"""
import json
@@ -32,7 +34,7 @@ import sys
RENDERMODELS = '/opt/steamvr/drivers/frame_controller/resources/rendermodels'
TEXTURE_SIZE = 1024
TRIANGLES = int(os.environ.get('WCF_FRAME_MODEL_TRIANGLES', '4000')) # per hand; Touch Plus has 4470
TRIANGLES = int(os.environ.get('KF_FRAME_MODEL_TRIANGLES', '4000')) # per hand
# --- small vector maths -------------------------------------------------------------------------
@@ -371,9 +373,12 @@ def convert(hand, rendermodels, outdir):
if __name__ == '__main__':
if len(sys.argv) < 2:
if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
out = sys.argv[1]
home = os.environ.get('KARTFRAME_HOME') or os.path.expanduser('~/.local/share/KartFrame')
data = os.path.join(home, 'UserData')
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(data, 'controller_models', 'frame')
os.makedirs(out, exist_ok=True)
src = sys.argv[2] if len(sys.argv) > 2 else RENDERMODELS
if not os.path.isdir(src):
sys.exit(f'{src}: no Steam Frame controller models here (is SteamVR installed?)')
+153
View File
@@ -0,0 +1,153 @@
#!/bin/bash
# Installs KartFrame on the Steam Frame: the program into ~/Games/KartFrame/app, the launch wrapper
# and tools beside it, and a non-Steam shortcut in Steam's library (through Steam's own client API,
# so Steam must be running; no editing of shortcuts.vdf behind its back).
#
# frame/install.sh [options] [kartframe-*-linux-arm64.tar.gz | BUILD_DIR]
# frame/install.sh --dev [BUILD_DIR] a separate "KartFrame (dev)" shortcut that runs the build
# build's binary into KARTFRAME_HOME/bin (default <repo>/build-linux)
#
# --name NAME shortcut name (default "KartFrame", with --dev "KartFrame (dev)")
# --art DIR optional library art: p.png (portrait), wide.png, hero.png, logo.png, icon.png
# --no-models do not convert the Frame controller models
# -h, --help
#
# Without a source: the newest release tarball in ./release, else <repo>/build-linux.
# A BUILD_DIR is a CMake build directory: kartframe plus the resources next to it.
#
# Environment:
# KARTFRAME_APP=DIR where the program goes (default ~/Games/KartFrame/app; dev: app-dev)
# KARTFRAME_HOME=DIR data folder (default ~/.local/share/KartFrame); only created here
# KARTFRAME_SKIP_STEAM=1 no Steam shortcut at all (Steam need not be running)
# KARTFRAME_STEAM_DIR=DIR edit the shortcuts.vdf files of this (copied) Steam folder offline
# instead of the live one: DIR/userdata/*/config/shortcuts.vdf, grid art
# beside it. Used for dry runs; nothing of the real Steam is touched.
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
REPO=$(cd "$HERE/.." && pwd)
DEV=0; NAME=; ART=; MODELS=1; SRC=
while [ $# -gt 0 ]; do
case $1 in
--dev) DEV=1 ;;
--name) NAME=$2; shift ;;
--art) ART=$2; shift ;;
--no-models) MODELS=0 ;;
-h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) SRC=$1 ;;
esac
shift
done
if [ "$DEV" = 1 ]; then
NAME=${NAME:-KartFrame (dev)}
APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app-dev}
BUILD=$(cd "${SRC:-$REPO/build-linux}" 2> /dev/null && pwd) || { echo "no build tree at ${SRC:-$REPO/build-linux}" >&2; exit 1; }
[ -x "$BUILD/kartframe" ] || { echo "no kartframe in $BUILD (build with kfbuild.sh first)" >&2; exit 1; }
else
NAME=${NAME:-KartFrame}
APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app}
[ -n "$SRC" ] || SRC=$(ls "$REPO"/release/kartframe-*-linux-arm64.tar.gz 2> /dev/null | sort -V | tail -1)
[ -n "$SRC" ] || { [ -x "$REPO/build-linux/kartframe" ] && SRC=$REPO/build-linux; }
[ -n "$SRC" ] && [ -e "$SRC" ] || { echo "usage: $0 [--dev] kartframe-*-linux-arm64.tar.gz | BUILD_DIR" >&2; exit 1; }
fi
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
EXE=$KFHOME/bin/kartframe
case $NAME in *\'* | *\\*) echo "the shortcut name cannot hold a quote or backslash" >&2; exit 2 ;; esac
STEAM=${KARTFRAME_STEAM_DIR:-$HOME/.local/share/Steam}
if [ -z "$KARTFRAME_SKIP_STEAM" ] && [ -z "$KARTFRAME_STEAM_DIR" ]; then
curl -s --max-time 3 127.0.0.1:8080/json > /dev/null || {
echo "Steam's debugging port is not reachable. Make sure Steam is running (on SteamOS it is on by default)." >&2
exit 1
}
fi
# Layout (LANE_RULES.md "Install layout"): the app dir holds the wrapper, tools and, from a release
# tarball, kit/ toolchain/ translator/ setup.sh. The game's own files live in $KFHOME: bin/kartframe
# plus its resources, portable.txt, UserData/. A release install does not make bin/ (setup.sh builds
# it from the user's disc); --dev and a build-tree install copy the build's binary there.
mkdir -p "$APP/tools" "$KFHOME/bin"
: > "$KFHOME/portable.txt"
copy_build() { # BUILD_DIR: the binary and the resources the game wants beside its executable
local B=$1 r
[ -x "$B/kartframe" ] || { echo "no kartframe in $B" >&2; exit 1; }
cp "$B/kartframe" "$KFHOME/bin/kartframe.new" && mv "$KFHOME/bin/kartframe.new" "$KFHOME/bin/kartframe" # the game may be running
for r in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap resources kartframe.fingerprint; do
[ -e "$B/$r" ] && { rm -rf "${KFHOME:?}/bin/$r"; cp -r "$B/$r" "$KFHOME/bin/$r"; }
done
return 0
}
if [ "$DEV" = 1 ]; then
echo "dev install: copying the build in $BUILD to $KFHOME/bin (run again after a rebuild)"
copy_build "$BUILD"
elif [ -d "$SRC" ]; then
echo "installing the build in $SRC"
copy_build "$SRC"
else
echo "installing $SRC into $APP"
TOPS=$(tar -tzf "$SRC" | sed 's|^\./||; s|/.*||' | sort -u | wc -l)
STRIP=0; [ "$TOPS" = 1 ] && [ -z "$(tar -tzf "$SRC" | sed 's|^\./||' | grep -m1 '^kartframe.sh$')" ] && STRIP=1
tar -xzf "$SRC" -C "$APP" --strip-components=$STRIP
fi
# Seed the game's Config.toml if there is none.
mkdir -p "$KFHOME/UserData"
[ -f "$KFHOME/UserData/Config.toml" ] || sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$KFHOME/UserData/Config.toml"
cp "$HERE/kartframe.sh" "$APP/kartframe.sh"
cp "$HERE/Config.frame.toml" "$APP/Config.frame.toml"
for t in vrtiming.py session-report.py; do cp "$HERE/tools/$t" "$APP/tools/$t"; done
cp "$HERE/steam-cef.py" "$APP/tools/steam-cef.py"
chmod +x "$APP/kartframe.sh" "$APP/tools/steam-cef.py"
for f in collect-logs.sh uninstall.sh; do cp "$HERE/$f" "$APP/tools/$f" 2> /dev/null && chmod +x "$APP/tools/$f"; done
# The Frame's own controller models, converted from this Frame's SteamVR (lane C's script, if present).
if [ "$MODELS" = 1 ] && [ -f "$HERE/frame-controller-models.py" ]; then
python3 "$HERE/frame-controller-models.py" "$KFHOME/UserData/controller_models/frame" ||
echo "warning: could not convert SteamVR's Frame controller models"
fi
APPID=none
LAUNCH="\"$APP/kartframe.sh\" %command%"
if [ -n "$KARTFRAME_SKIP_STEAM" ]; then
echo "KARTFRAME_SKIP_STEAM set: no Steam shortcut"
elif [ -n "$KARTFRAME_STEAM_DIR" ]; then
# Offline: every userdata/*/config/shortcuts.vdf of the (copied) Steam folder.
for CFG in "$STEAM"/userdata/*/config; do
[ -d "$CFG" ] || continue
APPID=$(python3 "$HERE/steam-cef.py" --vdf "$CFG/shortcuts.vdf" --name "$NAME" --exe "\"$EXE\"" \
--startdir "\"$KFHOME/bin\"" --opts "$LAUNCH")
echo "shortcut '$NAME' written to $CFG/shortcuts.vdf (appid $APPID)"
GRID=$CFG/grid
done
[ "$APPID" != none ] || echo "warning: no userdata/*/config in $STEAM; no shortcut written"
else
CEF="python3 $HERE/steam-cef.py"
EXISTING=$($CEF "(() => { for (const a of (appStore.allApps || [])) { if (a.display_name === '$NAME' && a.app_type === 1073741824) return a.appid; } return 0; })()" 2> /dev/null || echo 0)
if [ "$EXISTING" != "0" ] && [ -n "$EXISTING" ]; then
APPID=$EXISTING
echo "Steam already has a '$NAME' shortcut (appid $APPID): updating it"
$CEF "(() => { SteamClient.Apps.SetShortcutExe($APPID, '\"$EXE\"'); SteamClient.Apps.SetShortcutStartDir($APPID, '\"$KFHOME/bin\"'); return 1; })()" > /dev/null
else
APPID=$($CEF "(async () => { const id = await SteamClient.Apps.AddShortcut('$NAME', '$EXE', '$KFHOME/bin', ''); SteamClient.Apps.SetShortcutName(id, '$NAME'); return id; })()")
echo "added the '$NAME' shortcut (appid $APPID)"
fi
$CEF "(() => { SteamClient.Apps.SetShortcutLaunchOptions($APPID, '$LAUNCH'); return 1; })()" > /dev/null
GRID=$(ls -d "$STEAM"/userdata/*/config 2> /dev/null | head -1)/grid
fi
# Optional library art; Steam finds it by the shortcut's appid.
if [ -n "$ART" ] && [ "$APPID" != none ] && [ -n "$GRID" ]; then
mkdir -p "$GRID"
for kind in p:p wide: hero:_hero logo:_logo icon:_icon; do
f=$ART/${kind%%:*}.png
[ -f "$f" ] && cp "$f" "$GRID/$APPID${kind#*:}.png" && echo "art: $GRID/$APPID${kind#*:}.png"
done
fi
cat << MSG
Installed in $APP.
$([ "$APPID" != none ] && echo "Steam library entry \"$NAME\" (appid $APPID) runs $EXE through $APP/kartframe.sh.")
Game data: frame/setup.sh <your Mario Kart Wii disc image> (once)
Then launch it from the library with SteamVR running.
Logs: $KFHOME/sessions/latest/ (launch.log, game.log, vrtiming.log, report.txt)
MSG
+233
View File
@@ -0,0 +1,233 @@
#!/bin/bash
# KartFrame launch wrapper, run by the Steam shortcut ("<this script> %command%"). There is no screen
# for messages in VR: everything goes to the session log, and a refusal is a non-zero exit.
#
# Every launch: re-applies SteamVR's per-app settings (it drops them now and then), starts the
# compositor timing logger, runs the game, then writes a session folder
# $KARTFRAME_HOME/sessions/<date-time>/ with launch.log, stdout.log, game.log, vrtiming.log, report.txt.
# $KARTFRAME_HOME/sessions/latest points at the newest one.
#
# Environment (all optional; session.env below is the way to set them from the Steam library):
# KARTFRAME_HOME data folder, default ~/.local/share/KartFrame (game/, build/, sessions/)
# KARTFRAME_REFRESH_HZ refresh rate asked of SteamVR, 90 or 120 (default 120)
# KARTFRAME_EYE_PX per-eye resolution override asked of SteamVR (default 1728; tests only, 1728 is the floor)
# KARTFRAME_FDM_LAYER=0 sets DISABLE_VULKAN_FDM_INJECTION_LAYER=1 (A/B: Valve's FDM layer off)
# KARTFRAME_SYSMEM=0 leaves Turnip in tiled mode; default 1 sets TU_DEBUG=sysmem (measured: the
# race holds 120 Hz at 1728 only with it)
# KARTFRAME_PACING passed through to the game (auto | inline | pipelined)
# KARTFRAME_END_PHASE when in the refresh each frame is handed to SteamVR (default "after": just
# after the compositor's update, timed from the grid tools/vrtiming.py
# publishes; "off" = the wake-up, which doubles the image, see README)
# KARTFRAME_VRTIMING=0 no compositor timing log
# KARTFRAME_SESSION_ENV_ONCE=1 (inside session.env) delete session.env after this launch
# KARTFRAME_KEEP_SESSIONS number of session folders kept (default 30)
# Test hooks: KARTFRAME_VRCMD, KARTFRAME_STEAMVR_DIR, KARTFRAME_VRSETTINGS, KARTFRAME_VRTIMING_PY,
# KARTFRAME_REPORT_PY, KARTFRAME_EXE.
HERE=$(dirname "$(readlink -f "$0")")
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
STAMP=$(date +%Y%m%d-%H%M%S)
SESSION=$KFHOME/sessions/$STAMP
mkdir -p "$SESSION" || exit 1
ln -sfn "$STAMP" "$KFHOME/sessions/latest"
exec > "$SESSION/launch.log" 2>&1
log() { echo "$(date +%T) $*"; }
log "KartFrame wrapper, session $STAMP, steam appid '${SteamAppId:-unset}'"
# One launch's test environment: VAR=value lines (KARTFRAME_*, DISABLE_VULKAN_FDM_INJECTION_LAYER, ...).
# A queue of one-launch test settings (KFHOME/session.queue/*.env, taken in name order): when no
# session.env is waiting, the next queued file becomes this launch's session.env.
if [ ! -f "$KFHOME/session.env" ] && [ -d "$KFHOME/session.queue" ]; then
NEXT=$(ls "$KFHOME/session.queue"/*.env 2> /dev/null | head -1)
[ -n "$NEXT" ] && { mv "$NEXT" "$KFHOME/session.env"; log "session.queue: using $(basename "$NEXT")"; }
fi
if [ -f "$KFHOME/session.env" ]; then
log "session.env:"; cat "$KFHOME/session.env"; cp "$KFHOME/session.env" "$SESSION/session.env"
set -a; . "$KFHOME/session.env"; set +a
[ "$KARTFRAME_SESSION_ENV_ONCE" = 1 ] && { rm -f "$KFHOME/session.env"; log "session.env deleted (KARTFRAME_SESSION_ENV_ONCE)"; }
fi
[ "$KARTFRAME_FDM_LAYER" = 0 ] && { export DISABLE_VULKAN_FDM_INJECTION_LAYER=1; log "A/B: Valve FDM layer disabled"; }
[ "${KARTFRAME_SYSMEM:-1}" = 1 ] && { export TU_DEBUG=sysmem; log "TU_DEBUG=sysmem (KARTFRAME_SYSMEM=0 turns it off)"; }
[ -n "$KARTFRAME_PACING" ] && { export KARTFRAME_PACING; log "KARTFRAME_PACING=$KARTFRAME_PACING"; }
EP=${KARTFRAME_END_PHASE:-after}
if [ "$EP" = off ] || [ "$EP" = 0 ]; then unset KARTFRAME_END_PHASE; log "KARTFRAME_END_PHASE=off: frames handed over at the wake-up"; else export KARTFRAME_END_PHASE=$EP; log "KARTFRAME_END_PHASE=$EP"; fi
# The game: Steam's %command% (the binary the shortcut names), else the installed one.
EXE=${KARTFRAME_EXE:-${1:-$KFHOME/bin/kartframe}}
[ $# -gt 0 ] && shift
[ -x "$EXE" ] || { log "REFUSING TO START: no game binary at $EXE (run frame/setup.sh)"; exit 1; }
# Kit check: the installed binary must have been built against the kit that is installed now.
# Installed kit: <app>/kit/kit.json "fingerprint"; the built game's record: $KFHOME/bin/kartframe.fingerprint.
# kit_fingerprint FILE prints the "fingerprint" of a kit.json, or the first line of a plain file.
kit_fingerprint() {
python3 -I -c '
import json, sys
t = open(sys.argv[1]).read()
try:
print(json.loads(t)["fingerprint"])
except Exception:
print(t.split("\n")[0].strip())' "$1" 2> /dev/null
}
kit_check() { # prints the reason and returns 1 when the binary and the kit disagree
local kit= built=
[ -f "$HERE/kit/kit.json" ] && kit=$HERE/kit/kit.json
[ -f "$KFHOME/bin/kartframe.fingerprint" ] && built=$KFHOME/bin/kartframe.fingerprint
[ -n "$kit" ] || { log "kit check: no kit fingerprint installed (development build), skipped"; return 0; }
[ -n "$built" ] || { echo "no record of the kit this binary was built against; run frame/setup.sh again"; return 1; }
local k b
k=$(kit_fingerprint "$kit"); b=$(kit_fingerprint "$built")
[ -n "$k" ] && [ "$k" = "$b" ] && { log "kit check: ok (${k:0:12})"; return 0; }
echo "the game was built for another kit (${b:0:12} vs installed ${k:0:12}); run frame/setup.sh again"; return 1
}
if ! why=$(kit_check); then log "REFUSING TO START: $why"; exit 78; fi
[ -n "$why" ] && echo "$why"
# Portable mode: portable.txt in KFHOME (a parent of bin/) makes the game's root $KFHOME/UserData.
STATE=$KFHOME/UserData
mkdir -p "$STATE"; [ -f "$KFHOME/portable.txt" ] || : > "$KFHOME/portable.txt"
if [ ! -f "$STATE/Config.toml" ] && [ -f "$HERE/Config.frame.toml" ]; then
sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$STATE/Config.toml" &&
log "seeded $STATE/Config.toml"
fi
# SteamVR's per-app settings, every launch. SteamAppId is set by Steam for a library launch.
HZ=${KARTFRAME_REFRESH_HZ:-120} # TODO(lane A): follow Config's refresh choice if one is added
case "$HZ" in 90 | 120) ;; *) log "KARTFRAME_REFRESH_HZ=$HZ is not 90 or 120, using 120"; HZ=120 ;; esac
VRCMD=${KARTFRAME_VRCMD:-$(command -v vrcmd || echo /opt/steamvr/bin/linuxarm64/vrcmd)}
VRSETTINGS=${KARTFRAME_VRSETTINGS:-$HOME/.config/openvr/config/steamvr.vrsettings}
STEAMVR_DIR=${KARTFRAME_STEAMVR_DIR:-/opt/steamvr/bin/linuxarm64}
SETTINGS="preferredRefreshRate=$HZ framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=${KARTFRAME_EYE_PX:-1728}"
if [ -z "$SteamAppId" ]; then
log "no SteamAppId: SteamVR per-app settings NOT applied (not a Steam library launch)"
elif [ ! -x "$VRCMD" ]; then
log "no vrcmd at $VRCMD: SteamVR per-app settings NOT applied"
else
for kv in $SETTINGS; do
"$VRCMD" --set-settings-int "steam.app.$SteamAppId.${kv%=*}" "${kv#*=}" > /dev/null 2>&1 ||
log "WARNING: vrcmd could not set ${kv%=*} (is SteamVR running?)"
done
sleep 0.3 # SteamVR writes its settings file shortly after a change
# Read back from SteamVR's settings file and log every difference.
python3 -I - "$VRSETTINGS" "steam.app.$SteamAppId" $SETTINGS << 'PY' || log "settings read-back failed"
import json, sys, time
path, section, pairs = sys.argv[1], sys.argv[2], sys.argv[3:]
try:
got = json.load(open(path)).get(section, {})
except (OSError, ValueError) as e:
print(f"{time.strftime('%T')} read-back: cannot read {path} ({e.__class__.__name__}); settings unverified")
sys.exit(0)
bad = 0
for p in pairs:
k, v = p.split("=")
if str(got.get(k)) != v:
bad += 1
print(f"{time.strftime('%T')} MISMATCH {section}.{k}: wanted {v}, SteamVR has {got.get(k)}")
print(f"{time.strftime('%T')} read-back: {len(pairs) - bad} of {len(pairs)} per-app settings as asked")
PY
fi
# Compositor timing for the whole session; it cannot stop the game from starting.
VRT_PID=
VRT=${KARTFRAME_VRTIMING_PY:-$HERE/tools/vrtiming.py}
if [ "${KARTFRAME_VRTIMING:-1}" != 0 ] && [ -f "$VRT" ] && [ -f "$STEAMVR_DIR/libopenvr_api.so" ]; then
env -u LD_LIBRARY_PATH python3 -I "$VRT" 86400 "$SESSION/vrtiming.log" > /dev/null 2>&1 &
VRT_PID=$!
else
log "no vrtiming.log (disabled, or vrtiming.py / libopenvr_api.so missing)"
fi
# Not exec: the wrapper stays to stop the logger and write the report. A signal for the wrapper
# (Steam stopping the app) goes on to the game.
# The headset is in use: development builds and headless GPU runs on this machine wait while this
# file exists (KartFrame's build script does; other projects can check the same file).
HEADSET_MARK=${XDG_CACHE_HOME:-$HOME/.cache}/headset-in-use
log "starting $EXE"
touch "$SESSION/.start"
"$EXE" "$@" > "$SESSION/stdout.log" 2>&1 &
GAME_PID=$!
echo "kartframe $GAME_PID" > "$HEADSET_MARK" 2> /dev/null
# KARTFRAME_RECORD=raw records both panels as scanned out (framecorder; about 1.6 ms of GPU a frame)
# into the session folder, for questions the logs cannot settle (per-eye ghosting, flicker).
# The recorder starts 8 s in and only if the game is still there then (it once started after a game
# that had died at 3 s and recorded the headset for hours). It runs in its own session (setsid), so
# stopping it can reach everything it started (the framecorder-grab helper too). .rec-started marks
# that it really began.
REC_PID=
# Steam starts games with a short PATH (no ~/.local/bin), so look for the recorder by its full path too.
FRAMECORDER=$(command -v framecorder 2> /dev/null || ls "$HOME/.local/bin/framecorder" 2> /dev/null)
if [ -n "$KARTFRAME_RECORD" ] && [ -z "$FRAMECORDER" ]; then log "RECORDING NOT POSSIBLE: framecorder not found"; fi
if [ -n "$KARTFRAME_RECORD" ] && [ -n "$FRAMECORDER" ]; then
rm -f "$SESSION/.rec-started"
setsid bash -c 'for ((i = 0; i < 80; i++)); do kill -0 "$1" 2> /dev/null || exit 0; sleep 0.1; done
touch "$2/.rec-started"; shift 2; exec "$@"' _ "$GAME_PID" "$SESSION" \
"$FRAMECORDER" --view "${KARTFRAME_RECORD}" --fps "${KARTFRAME_RECORD_FPS:-120}" --qp 18 "$SESSION/panels.mkv" \
> "$SESSION/framecorder.log" 2>&1 &
REC_PID=$!
log "recording the panels (framecorder --view $KARTFRAME_RECORD) into panels.mkv"
fi
# Anything of the recorder still alive (a finished process that nobody has waited for does not count).
rec_alive() {
ps -o stat= --sid "$REC_PGID" 2> /dev/null | grep -q '^[^Z]' ||
pgrep -f "framecorder .* $SESSION/panels.mkv" > /dev/null
}
# Stops the recorder in every case: not started yet (the waiting subshell is killed and never starts it),
# running (SIGINT to finish the file, then a bounded wait, then TERM and KILL). Runs once.
stop_recorder() {
[ -n "$REC_PID" ] || return 0
REC_PGID=$REC_PID; REC_PID=
local n how=stopped
if [ -f "$SESSION/.rec-started" ]; then
kill -INT "$REC_PGID" 2> /dev/null
for ((n = 0; n < 300; n++)); do rec_alive || break; sleep 0.1; done # up to 30 s to finish the file
if rec_alive; then
how="stopped after TERM (SIGINT did not finish it)"
kill -TERM -- "-$REC_PGID" 2> /dev/null; pkill -TERM -s "$REC_PGID" 2> /dev/null
pkill -TERM -f "framecorder .* $SESSION/panels.mkv" 2> /dev/null
for ((n = 0; n < 50; n++)); do rec_alive || break; sleep 0.1; done
fi
if rec_alive; then
how="killed (SIGINT and TERM did not stop it)"
kill -KILL -- "-$REC_PGID" 2> /dev/null; pkill -KILL -s "$REC_PGID" 2> /dev/null
pkill -KILL -f "framecorder .* $SESSION/panels.mkv" 2> /dev/null
sleep 0.5
fi
wait "$REC_PGID" 2> /dev/null
rec_alive && log "WARNING: a recorder process is still alive for $SESSION/panels.mkv"
log "recorder $how"
else
kill -TERM "$REC_PGID" 2> /dev/null; kill -TERM -- "-$REC_PGID" 2> /dev/null
pkill -TERM -s "$REC_PGID" 2> /dev/null
wait "$REC_PGID" 2> /dev/null
log "recorder was never started (the game was gone before the 8 s were up)"
fi
# A leftover part file is the owner's to judge: logged, never deleted.
if [ -f "$SESSION/panels.mkv.part" ] && ! rec_alive; then
log "panels.mkv.part left in the session folder, $(stat -c %s "$SESSION/panels.mkv.part") bytes, no recorder alive"
fi
}
trap 'kill -TERM "$GAME_PID" 2> /dev/null; stop_recorder' TERM INT HUP
rc=0
while kill -0 "$GAME_PID" 2> /dev/null; do
wait "$GAME_PID"; rc=$?
done
trap - TERM INT HUP
rm -f "$HEADSET_MARK"
stop_recorder
log "game exited with status $rc"
[ -n "$VRT_PID" ] && { kill "$VRT_PID" 2> /dev/null; wait "$VRT_PID" 2> /dev/null; }
# The game's own log: the newest console.log of this session, else what it printed.
GL=$(find "$STATE/Logs" -name console.log -newer "$SESSION/.start" 2> /dev/null | xargs -r ls -t 2> /dev/null | head -1)
if [ -n "$GL" ]; then cp "$GL" "$SESSION/game.log"; else log "no console.log from this run; the report reads stdout.log"; cp "$SESSION/stdout.log" "$SESSION/game.log"; fi
rm -f "$SESSION/.start"
REPORT=${KARTFRAME_REPORT_PY:-$HERE/tools/session-report.py}
if [ -f "$REPORT" ]; then
set -- "$SESSION/game.log"; [ -s "$SESSION/vrtiming.log" ] && set -- "$@" "$SESSION/vrtiming.log"
python3 -I "$REPORT" "$@" > "$SESSION/report.txt" 2>&1 || log "session-report exited non-zero"
else
log "no session-report.py at $REPORT"
fi
# Keep the newest sessions only.
KEEP=${KARTFRAME_KEEP_SESSIONS:-30}
ls -1d "$KFHOME"/sessions/2* 2> /dev/null | head -n -"$KEEP" | xargs -r rm -rf
exit $rc
+7
View File
@@ -0,0 +1,7 @@
# Files over 1 MB the kit may contain (globs, see boundary-check.sh). Prebuilt runtime objects and
# archives, the sysroot's libraries, and the converted controller models are not disc-derived.
objects/*
libs/*
sysroot/usr/lib/*
sysroot/usr/lib/gcc/*
resources/*
+232
View File
@@ -0,0 +1,232 @@
# First-run build of the translated game against the kit: writes <BUILD>/build.ninja from kit.json
# and the translator's shard lists. Run with the toolchain's own cmake (no other tool is needed):
#
# cmake -DKIT=<kit> -DWORKSPACE=<dir holding generated/> -DBUILD=<build dir> -DTOOLCHAIN=<root>
# [-DJOBS=3] -P kitbuild.cmake
# <toolchain>/bin/ninja -C <build> -j<jobs> -> <build>/kartframe
#
# kit.json says how each kind of source is compiled and how everything links. Placeholders in it:
# {kit}, {sysroot} (the kit's own sysroot, so host headers are never read), {workspace}. The
# shard lists come from generated/build_shards/shards.cmake (the translator's own manifest).
cmake_minimum_required(VERSION 3.25)
foreach(v KIT WORKSPACE BUILD TOOLCHAIN)
if(NOT DEFINED ${v})
message(FATAL_ERROR "kitbuild.cmake: -D${v}= is required")
endif()
endforeach()
if(NOT DEFINED JOBS)
set(JOBS 3)
endif()
get_filename_component(KIT "${KIT}" ABSOLUTE)
get_filename_component(WORKSPACE "${WORKSPACE}" REALPATH)
get_filename_component(BUILD "${BUILD}" ABSOLUTE)
file(MAKE_DIRECTORY "${BUILD}")
file(READ "${KIT}/kit.json" KJ)
string(JSON KIT_SCHEMA GET "${KJ}" schema)
if(NOT KIT_SCHEMA EQUAL 1)
message(FATAL_ERROR "kit.json schema ${KIT_SCHEMA} is not supported by this kitbuild.cmake")
endif()
function(kit_sub out text)
string(REPLACE "{kit}" "${KIT}" t "${text}")
string(REPLACE "{sysroot}" "${KIT}/sysroot" t "${t}")
string(REPLACE "{workspace}" "${WORKSPACE}" t "${t}")
set(${out} "${t}" PARENT_SCOPE)
endfunction()
# One argument for a POSIX shell, with ninja's own `$` escaping on top.
function(sh_quote out text)
if(text MATCHES "^[A-Za-z0-9_@%+=:,./-]+$")
set(q "${text}")
else()
string(REPLACE "'" "'\\''" q "${text}")
set(q "'${q}'")
endif()
string(REPLACE "$" "$$" q "${q}")
set(${out} "${q}" PARENT_SCOPE)
endfunction()
function(json_args out) # <out> <json path...>: a JSON string array as a shell-quoted, substituted string
string(JSON n LENGTH "${KJ}" ${ARGN})
set(res "")
math(EXPR last "${n} - 1")
if(n GREATER 0)
foreach(i RANGE ${last})
string(JSON a GET "${KJ}" ${ARGN} ${i})
kit_sub(a "${a}")
sh_quote(a "${a}")
string(APPEND res " ${a}")
endforeach()
endif()
set(${out} "${res}" PARENT_SCOPE)
endfunction()
# ---- the translator's shard lists, relocated to where this translation actually is ---------------
set(manifest "${WORKSPACE}/generated/build_shards/shards.cmake")
if(NOT EXISTS "${manifest}")
message(FATAL_ERROR "no translation at ${WORKSPACE}/generated (run setup.sh translate first)")
endif()
file(READ "${manifest}" manifest_text)
if(manifest_text MATCHES "set\\(MKW_TRANSLATED_SHARD_ROOT \"([^\"]+)\"\\)")
set(recorded_root "${CMAKE_MATCH_1}")
get_filename_component(real_root "${WORKSPACE}/generated/build_shards" REALPATH)
if(NOT recorded_root STREQUAL real_root)
string(REPLACE "${recorded_root}" "${real_root}" manifest_text "${manifest_text}")
endif()
endif()
file(WRITE "${BUILD}/shards_relocated.cmake" "${manifest_text}")
include("${BUILD}/shards_relocated.cmake")
# ---- the data blob assembly: ELF section syntax and this machine's blob paths (as runtime/cmake/PublicProducts.cmake)
set(blob_in "${WORKSPACE}/generated/data_sections_init_blobs.S")
set(blob_out "${BUILD}/data_sections_init_blobs_elf.S")
if(EXISTS "${blob_in}")
file(READ "${blob_in}" blob)
string(REPLACE ".section .rdata,\"dr\"" ".section .rodata,\"a\",@progbits" blob "${blob}")
string(REGEX REPLACE "\\.incbin \"[^\"]*/data_sections_init_blobs/"
".incbin \"${WORKSPACE}/generated/data_sections_init_blobs/" blob "${blob}")
if(NOT blob MATCHES "\\.note\\.GNU-stack")
string(APPEND blob "\n.section .note.GNU-stack,\"\",@progbits\n")
endif()
file(WRITE "${blob_out}" "${blob}")
endif()
# ---- sources per slot --------------------------------------------------------------------------
function(slot_sources out slot)
string(JSON n LENGTH "${KJ}" sources ${slot})
set(res "")
math(EXPR last "${n} - 1")
foreach(i RANGE ${last})
string(JSON entry GET "${KJ}" sources ${slot} ${i})
if(entry MATCHES "^@(.+)$")
list(APPEND res ${${CMAKE_MATCH_1}})
else()
kit_sub(entry "${entry}")
if(EXISTS "${entry}")
list(APPEND res "${entry}")
else()
message(FATAL_ERROR "kit source missing: ${entry}")
endif()
endif()
endforeach()
set(${out} "${res}" PARENT_SCOPE)
endfunction()
slot_sources(SRC_RUNTIME runtime)
slot_sources(SRC_PRODUCT product)
slot_sources(SRC_TRANSLATED translated)
list(LENGTH SRC_PRODUCT n_product)
list(LENGTH SRC_TRANSLATED n_translated)
if(n_translated EQUAL 0 OR n_product EQUAL 0)
message(FATAL_ERROR "the shard lists are empty (${manifest})")
endif()
# ---- build.ninja ---------------------------------------------------------------------------------
set(cxx "${TOOLCHAIN}/bin/clang++")
set(cc "${TOOLCHAIN}/bin/clang")
set(ninja "# generated by kitbuild.cmake from ${KIT}/kit.json; do not edit\nninja_required_version = 1.10\n")
string(APPEND ninja "pool heavy\n depth = ${JOBS}\n\n")
# Kinds with at least this many sources get a precompiled mkw_pch.h of their own (it is only valid for
# the compiler and flags that wrote it, so it is made here, never shipped).
set(PCH_MIN_SOURCES 8)
set(all_objects "")
function(add_kind kind sources compiler)
json_args(flags compile ${kind} flags)
string(JSON want_pch GET "${KJ}" compile ${kind} pch)
list(LENGTH sources count)
set(extra "")
set(pch_dep "")
set(out "")
if(want_pch AND count GREATER_EQUAL PCH_MIN_SOURCES)
set(pch "${BUILD}/pch/${kind}.pch")
string(APPEND out "rule pch_${kind}\n command = ${compiler} ${flags} -x c++-header ${KIT}/include/mkw_pch.h -o $out\n description = PCH ${kind}\n pool = heavy\n")
string(APPEND out "build ${pch}: pch_${kind} ${KIT}/include/mkw_pch.h\n\n")
set(extra "-include-pch ${pch}")
set(pch_dep "| ${pch}")
elseif(want_pch OR kind STREQUAL "runtime")
set(extra "-include mkw_pch.h")
endif()
string(APPEND out "rule cc_${kind}\n command = ${compiler} ${flags} ${extra} -MD -MF $out.d -o $out -c $in\n description = ${kind} $in\n depfile = $out.d\n deps = gcc\n pool = heavy\n\n")
set(objs "")
set(idx 0)
foreach(src IN LISTS sources)
get_filename_component(base "${src}" NAME)
set(obj "${BUILD}/obj/${kind}/${idx}_${base}.o")
string(APPEND out "build ${obj}: cc_${kind} ${src} ${pch_dep}\n")
list(APPEND objs "${obj}")
math(EXPR idx "${idx} + 1")
endforeach()
set(KIND_OBJECTS_${kind} "${objs}" PARENT_SCOPE)
set(NINJA_${kind} "${out}\n" PARENT_SCOPE)
endfunction()
# asm sources are the blob object only; the .cpp runtime sources are the generated data files
set(runtime_cpp "")
set(runtime_asm "")
foreach(s IN LISTS SRC_RUNTIME)
if(s MATCHES "\\.S$")
list(APPEND runtime_asm "${blob_out}") # the rewritten copy made above
else()
list(APPEND runtime_cpp "${s}")
endif()
endforeach()
add_kind(runtime "${runtime_cpp}" "${cxx}")
add_kind(product "${SRC_PRODUCT}" "${cxx}")
add_kind(translated "${SRC_TRANSLATED}" "${cxx}")
add_kind(asm "${runtime_asm}" "${cc}")
foreach(k runtime product translated asm)
string(APPEND ninja "${NINJA_${k}}")
endforeach()
# link: kit objects and libraries in the recorded order, the game's objects at their slots
json_args(link_flags link flags)
string(JSON n_inputs LENGTH "${KJ}" link inputs)
math(EXPR last "${n_inputs} - 1")
set(rsp "")
set(kit_files "")
foreach(i RANGE ${last})
string(JSON in GET "${KJ}" link inputs ${i})
if(in STREQUAL "{game:runtime}")
foreach(o ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm})
string(APPEND rsp "${o}\n")
endforeach()
set(game_objs ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm})
elseif(in STREQUAL "{game:product}")
foreach(o ${KIND_OBJECTS_product})
string(APPEND rsp "${o}\n")
endforeach()
elseif(in STREQUAL "{game:translated}")
string(APPEND rsp "-Wl,--start-lib\n")
foreach(o ${KIND_OBJECTS_translated})
string(APPEND rsp "${o}\n")
endforeach()
string(APPEND rsp "-Wl,--end-lib\n")
else()
kit_sub(in "${in}")
string(APPEND rsp "${in}\n")
if(in MATCHES "^/")
list(APPEND kit_files "${in}")
endif()
endif()
endforeach()
# flags go through the response file too (one per line is fine for clang)
string(JSON n_flags LENGTH "${KJ}" link flags)
set(flag_lines "")
math(EXPR lastf "${n_flags} - 1")
foreach(i RANGE ${lastf})
string(JSON f GET "${KJ}" link flags ${i})
kit_sub(f "${f}")
string(APPEND flag_lines "${f}\n")
endforeach()
string(JSON out_name GET "${KJ}" link output)
file(WRITE "${BUILD}/link.rsp" "${flag_lines}-o\n${BUILD}/${out_name}\n${rsp}")
set(all_game ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm} ${KIND_OBJECTS_product} ${KIND_OBJECTS_translated})
string(REPLACE ";" " " all_game_s "${all_game}")
string(REPLACE ";" " " kit_files_s "${kit_files}")
string(APPEND ninja "rule link\n command = ${cxx} @${BUILD}/link.rsp\n description = LINK ${out_name}\n pool = console\n\n")
string(APPEND ninja "build ${BUILD}/${out_name}: link ${all_game_s} | ${kit_files_s} ${BUILD}/link.rsp\n\ndefault ${BUILD}/${out_name}\n")
file(WRITE "${BUILD}/build.ninja" "${ninja}")
message(STATUS "kitbuild: ${n_translated} translated + ${n_product} product shards, pool ${JOBS}, ${BUILD}/build.ninja")
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Maintainer step: build the game kit (everything the first-run build links except the translated
# game) with the SAME portable toolchain and sysroot the user's machine will use, and export it.
#
# make-kit.sh <kit-outdir>
#
# Environment: KARTFRAME_DAWN_DIR (patched Dawn package; default the newest pinned one),
# KF_KIT_BUILD (build dir, default <repo>/build-kit), KF_SYSROOT_CACHE (default
# ~/.cache/kartframe/sysroot), JOBS (default 4). Needs a development translation at <repo>/generated
# (its shard lists only supply compile flags; none of it ends up in the kit). Takes the shared
# build lock (~/Games/KartFrame/.build.lock) for the compile.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repo=$(cd "$here/.." && pwd)
[[ $# -eq 1 ]] || { echo "usage: make-kit.sh <kit-outdir>" >&2; exit 2; }
out=$1
build=${KF_KIT_BUILD:-$repo/build-kit}
sysroot=${KF_SYSROOT_CACHE:-$HOME/.cache/kartframe/sysroot}
dawn=${KARTFRAME_DAWN_DIR:-$(ls -dt "$HOME"/.cache/kartframe/dawn/*/ 2>/dev/null | head -1)}
dawn=${dawn%/}
[[ -d $dawn/lib ]] || { echo "no patched Dawn package (frame/build-dawn.sh)" >&2; exit 1; }
[[ -e $repo/generated/build_shards/shards.cmake ]] || { echo "no translation at $repo/generated (flags only; see frame/DEV.md)" >&2; exit 1; }
tc=$("$here/toolchain.sh")
"$here/make-sysroot.sh" "$sysroot"
if [[ ! -f $build/build.ninja ]]; then
# No --sysroot here: SDL3 and friends need the host's X11/Wayland/ALSA headers. The sysroot is a copy
# of the host's glibc/libstdc++/kernel headers and crt files (make-sysroot.sh), so the objects built
# here are the ones the sysroot would have produced; the first-run build compiles and links with it.
PATH=$tc/bin:$PATH cmake -S "$repo/runtime" -B "$build" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER="$tc/bin/clang" -DCMAKE_CXX_COMPILER="$tc/bin/clang++" \
-DCMAKE_AR="$tc/bin/llvm-ar" -DCMAKE_RANLIB="$tc/bin/llvm-ranlib" \
-DCMAKE_MAKE_PROGRAM="$tc/bin/ninja" \
-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \
-DMKW_DAWN_PACKAGE_DIR="$dawn" -DMKW_BUILD_TESTS=OFF
fi
exec 9>"$HOME/Games/KartFrame/.build.lock"
echo "make-kit: waiting for the build lock"; flock 9
"$tc/bin/ninja" -C "$build" -j"${JOBS:-4}" kartframe_kit_probe
"$here/export-kit.py" --build "$build" --sysroot "$sysroot" --out "$out"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Maintainer step: build the minimal compile/link sysroot that goes into the kit, from the files
# of this machine's own packages (glibc headers and crt objects, libstdc++ headers, libgcc and the
# crtbegin/crtend objects, the kernel headers glibc includes). The portable LLVM has no sysroot of
# its own; without this the first-run build would compile against whatever /usr/include a user's
# machine happens to have.
#
# make-sysroot.sh <destdir> creates <destdir> (replaced if present), prints its size
#
# Layout: <dest>/usr/include, <dest>/usr/lib (+ gcc/<triple>/<ver>), <dest>/lib -> usr/lib, and
# <dest>/LICENSES with the origin packages and licence texts. Use it as `--sysroot=<dest>`.
set -euo pipefail
[[ $# -eq 1 ]] || { echo "usage: make-sysroot.sh <destdir>" >&2; exit 2; }
dest=$1
command -v pacman >/dev/null || { echo "make-sysroot.sh needs pacman (it copies this Frame's packages)" >&2; exit 1; }
rm -rf "$dest"; mkdir -p "$dest/usr/include" "$dest/usr/lib" "$dest/LICENSES"
dest=$(cd "$dest" && pwd)
ln -s usr/lib "$dest/lib"
# Every regular file or symlink the package owns under the given prefix, copied with its path.
copy_pkg() { # <pkg> <regex on the absolute path>
pacman -Ql "$1" | awk '{print $2}' | grep -E "$2" | grep -v '/$' | while IFS= read -r f; do
[[ -f $f || -L $f ]] && cp -a --parents "$f" "$dest"
done
}
gccver=$(pacman -Q gcc | awk '{print $2}' | sed 's/+.*//')
triple=aarch64-unknown-linux-gnu
copy_pkg glibc '^/usr/include/'
copy_pkg linux-api-headers '^/usr/include/'
copy_pkg gcc "^/usr/include/c\\+\\+/$gccver/"
copy_pkg glibc '^/usr/lib/(crt1|crti|crtn|Scrt1)\.o$'
copy_pkg glibc '^/usr/lib/(libc\.so|libc\.so\.6|libc_nonshared\.a|libm\.so|libm\.so\.6|libmvec\.so|libmvec\.so\.1|ld-linux-aarch64\.so\.1|libdl\.a|libdl\.so\.2|librt\.a|librt\.so\.1|libpthread\.a|libpthread\.so\.0|libutil\.a|libutil\.so\.1)$'
copy_pkg gcc-libs '^/usr/lib/(libstdc\+\+\.so[.0-9]*|libgcc_s\.so(\.1)?)$'
copy_pkg gcc "^/usr/lib/gcc/$triple/$gccver/(crtbegin|crtbeginS|crtbeginT|crtend|crtendS|libgcc|libgcc_eh)\\.(o|a)$"
[[ -f $dest/usr/lib/gcc/$triple/$gccver/crtbeginS.o ]] || { echo "gcc crt objects missing" >&2; exit 1; }
[[ -f $dest/usr/include/c++/$gccver/vector && -f $dest/usr/include/stdio.h && -f $dest/usr/include/linux/types.h ]] \
|| { echo "headers missing" >&2; exit 1; }
# Provenance and licences.
{
echo "Minimal sysroot for building KartFrame's translated game, copied from these packages of the"
echo "SteamOS image it was made on (files only; nothing was modified):"
echo
for p in glibc gcc gcc-libs linux-api-headers; do
pacman -Qi "$p" | awk -F' *: *' -v p="$p" '/^Name/{n=$2} /^Version/{v=$2} /^Licenses/{l=$2} END{printf " %-20s %-40s %s\n", n, v, l}'
done
echo
echo "glibc: LGPL-2.1-or-later (GPL-2.0-or-later for some files), https://www.gnu.org/software/libc/"
echo "gcc/libstdc++/libgcc: GPL-3.0-or-later with the GCC Runtime Library Exception, https://gcc.gnu.org/"
echo "linux-api-headers: GPL-2.0 with the Linux syscall note, https://www.kernel.org/"
echo "Object files linked into the game (crt*.o, libgcc, libc_nonshared) fall under the exceptions above."
echo "Source for all of them is available from the upstream projects named here."
} > "$dest/LICENSES/README.txt"
for d in /usr/share/licenses/gcc /usr/share/licenses/gcc-libs; do
[[ -d $d ]] && cp -a "$d/." "$dest/LICENSES/" 2>/dev/null || true
done
for l in GPL2 GPL3 LGPL2.1 FDL1.3; do
[[ -f /usr/share/licenses/common/$l/license.txt ]] && cp -a "/usr/share/licenses/common/$l/license.txt" "$dest/LICENSES/$l.txt" || true
done
echo "sysroot: $dest ($(du -sh "$dest" | cut -f1), $(find "$dest" -type f | wc -l) files)"
+70
View File
@@ -0,0 +1,70 @@
# H1 (2026-10-13 03:07, first headset session of the native build): what happened
Session: `$KARTFRAME_HOME/sessions/20261013-030749` (game.log, vrtiming.log, report.txt), build `native`
at eb8192d: eyes 1728x1728, display 120 Hz, pipelined pacing, TU_DEBUG=sysmem, projected menus,
the old half-rate rule. Owner: "jittery, felt like half speed, ghosting when turning my head; the 2D
screen with the VR menu on was flickering; the controls guide looked bad".
## Timeline
Seconds are the game's pacing seconds (`vr pacing: t=`). Game frames/s from the game's own frame
counter in the `[mkw-vr]` lines (scene-frame) between two marks. GPU is the median of the per-second
p50 and the worst per-second p99, ms per eye set.
| t (s) | what | sets/s | late | repeats | half-rate s | GPU p50 / worst p99 | encode p50 | game frames/s |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1-49 | boot, menus (projected), seated-space init at 37 | 102.6 | 0.9 % | 14.6 % | 10 | 2.06 / 7.76 | 2.86 | about 46 (boot included) |
| 49-67 | race, restarted at 54 (same scene) | 75.4 | 7.6 % | 37.4 % | 12 | 6.54 / 10.97 | 2.59 | 32 to 37 |
| 67-100 | race scene on the virtual screen (pause), focus blip at 83 | 105.4 | 0.0 % | 12.6 % | 8 | 2.12 / 8.02 | 2.84 | 16 (paused) |
| 100-244 | race | 68.4 | 4.7 % | 43.2 % | 115 | 5.76 / 10.14 | 2.58 | 38.5 |
| 244-248 | VR settings panel open (chord) | 60.0 | 0.0 % | 50.1 % | 4 | 4.30 / 8.82 | 2.88 | 25 |
| 248-254 | race to the end | 75.2 | 2.1 % | 37.6 % | 6 | 6.91 / 11.38 | 2.72 | - |
The compositor was healthy throughout (vrtiming: 120 frames/s, app frames reused 0.3 %, compositor
GPU 0.8 ms). The game asked for 90 Hz (`display refresh rates: 90; requested 90 Hz`, the only rate
SteamVR offered), the display went to 90, then the per-app key put it back to 120.
Correction to the first reading: the session was not 5 s of race and 4 minutes of menus. The race
restarted at t=54 ("Scene Restart") and no new "entered RaceScene" was logged, so session-report.py
counted 168 s of racing as menus. The seconds at GPU 5 to 7 ms a set were the race (presentation
immersive-race, scene 2); real menu seconds cost 2.1 ms a set, as in the mock. session-report.py now
also uses the presentation lines.
## Causes (confirmed headless where possible)
1. **Half speed: the game ran at 38 game frames a second in the race.** aurora called
SDL_SyncWindow on every game frame. On X11, when the window's geometry differs from what SDL
expects (gamescope sizes the window itself; Xvfb has no window manager) and nothing is pending, it
ends in SDL_Delay(10): 10 ms of every game frame on the game thread. Menus (6 ms of game logic) still
made 60; the race (13 to 17 ms) dropped to 38, and Mario Kart's logic is frame-locked, so the race
ran at 63 % speed. Mock A/B on one build (1728, sysmem, pipelined, demo race): old behaviour
(`KARTFRAME_SYNC_WINDOW=1`) 38.1 game fps, 72.9 sets/s, 3.7 % late; fixed 59.8
game fps, 120.6 sets/s, 0.0 % late, GPU 4.70 / 5.35 ms a set (old 6.13 / 8.17). Commit 57526ee.
2. **Judder and head-turn ghosting: half rate.** The old rule dropped to every second refresh at
13 late of 240 and came back after 10 s; it flapped 16 down / 15 up, 33 % of all ticks were
repeats, and 115 of the 143 race seconds ran at half rate (60 Hz content shown twice). Half rate
is now off by default (late sets repeat the retained layer, which SteamVR reprojects); commit
b240caf.
3. **Race GPU 5.8 to 6.9 ms a set** (p99 to 11), above the mock's 4.7 / 5.35 with the fix. Part of
that may be cause 1 (the mock's old-behaviour run also showed 6.1 / 8.2), part SteamVR's own GPU
work (0.8 ms) and the headset's clocks. Unconfirmed until the next session.
4. **Flicker of the 2D screen.** In 67-100 (race scene on the virtual screen) the immersive
display-copy source region alternated every game frame between the display copy (893x456) and the
full-EFB fallback (939x528): 1100 log lines in 33 s. The 2D content is scaled from that region, so
alternate frames were 5 % wider and 16 % taller. Menus are now a quad layer (commit b240caf); the
fallback frequency is logged every 10 s now ("Immersive display-copy source: N frames with a
display copy, M with the full-EFB fallback"). Whether the race HUD alternates the same way on the
headset is to be read from the next session's log.
5. **Projected menus.** Not the GPU cost (2.1 ms a set, intro included: mock with the welcome and
controller guide shown, sysmem 2.10 / 2.87, tiled 2.06 / 2.70), but every menu set was rendered for
both eyes and encoded at 2.9 ms. Menus are now a quad fixed in the room, cropped to the picture
(1728x972 of the 1728 square), as GalaxyFrame; the first-run guide stays projected.
## What the owner should see in the next session
- The race at full speed (60 game frames a second), every refresh at 120 Hz, no switching to half
rate (`mode=pipelined` in every `vr pacing` line, no "every second refresh" lines).
- Menus: a flat screen in front, fixed in the room, no black bands, the VR panel beside it.
- No 90 Hz request: "120 Hz not offered, requested none" in the log.
- If the race GPU stays above 6.5 ms a set, late sets show as repeats (reprojected by SteamVR);
the per-second lines and the display-copy fallback count tell which.
+69
View File
@@ -0,0 +1,69 @@
# H2 (2026-10-13 11:12, second headset session): what the owner saw, and lane G's fixes
Session `$KARTFRAME_HOME/sessions/20261013-111257`, build `native` 0441316: 120 Hz, pipelined, 119.7
sets/s, 0.4 % late, 0.7 % repeats, compositor reused 0.2 %, GPU 3.5 ms menus / 5.9 ms race a set.
The owner: (1) "when moving my head every image was doubled" (head still: fine), (2) "the HUD wasn't
present and would flicker in only", (3) "the pointer and controller models didn't render when it was a
2D screen". All runs below: mock runtime, real Turnip GPU, 1728 per eye, 120 Hz, TU_DEBUG=sysmem.
## 1. Doubled images
- Image/pose pairing is clean. New exact check: with `MOCK_XR_STAMP=1` the game stamps every eye image
with the display time its set was located for (and the eye); the mock reads the stamp back from the
image each projection layer submits and compares it with the locate of the layer's poses. Pipelined
and inline, with and without 2 ms prediction jitter, menus and race: 10 064 to 14 318 eye images a
run, every one rendered for the locate of its submitted poses, none with the other eye's stamp.
- Display times: on SteamVR a pipelined set is located for "this prediction + one period", which need
not be the next cycle's prediction. Layers are now ended with the display time their poses were
located for (kept increasing; a repeated layer keeps the cycle's time); `KARTFRAME_END_TIME=cycle`
is the old way. Mock with `MOCK_XR_PREDICT_JITTER=2`: old 0 of 7155 fresh layers carried their
poses' time, new 7157 of 7159. Whether SteamVR's predictions jitter is in the next session's log.
- The cause found: VR interpolation did not interpolate. A set's display time lies 2 to 3 refreshes
after the newest game frame's boundary, and the weight `(display - boundary) / interval` was clamped
to 1: in the pipelined race 98 % of sets showed the newest game frame whole (mock: 2 to 54 of ~1180
sets per 10 s between two frames), so each game frame stayed on two refreshes, 60 Hz scenery on a
120 Hz panel. Anything the eye follows (the road and walls streaming past when the head turns) shows
twice. Head still and looking ahead, little moves across the view. `stereo::SceneClock` samples the
scene at the display time minus a delay it learns (running mean of display minus boundary, minus
half an interval): mock race now 80 to 94 % of sets between two frames, motion uniform (unit check:
step error under 0.002 frames at 90 and 120 Hz, leads 16.7 to 33 ms). `KARTFRAME_SCENE_CLOCK=0` is
the old rule. Cost: none (one subtraction a set); the scene is shown about half a game frame later
than the old rule intended (and earlier than what it actually did, a whole frame held twice).
## 2. HUD missing or flickering
- Not the FOVs: the headset's "screen clip self-check -2.00000" is the check's "not run yet" value
(menus, first race seconds); in the race it read 0.00000 on the real asymmetric FOVs too.
- Cause: lane E's clip block was written at `screen_clip_offset(uniformSize)`, but `uniformSize` is
padded to the 64-byte dynamic-offset alignment while the shader's clip fields follow the WGSL
struct's end. The shader read padding (stale bytes), discarded nearly every 2D draw on the virtual
screen, and let one through now and then on leftover bytes.
- Found headless: `MOCK_XR_PRESS` scripted A presses take the mock into a real Grand Prix race (Luigi
Circuit). With the owner's settings (hud_virtual_screen = true, hand_hud = false) the eyes had no HUD
with the clip on or off (`KARTFRAME_HUD_CLIP=0` only blanks the CPU block); hud_virtual_screen =
false showed it; removing the shader clip brought it back.
- Fix: `ShaderInfo::structSize` places the block on both sides; the block carries a marker without
which the shader does not clip (a future layout slip shows the HUD unclipped, never hides it).
`frame/perf/hudrace.sh NAME` runs the scripted race and writes eye PNGs: TIME, LAP, position and map
are on the screen in both eyes (mock FOVs now asymmetric, -52/44/48/-52 deg, self-check 0.00000).
- `KARTFRAME_HUD_CLIP=0` is no longer needed as a wrapper default.
## 3. Controllers and pointer in menus
A menu set now also renders the room around the screen into a packed two-eye image (3456x1728): plain
background, the controller models, the laser pointers with their marks, the screen's area transparent.
It goes out as one projection layer with the set's poses under the screen's quad (plus the panel
quad). `KARTFRAME_MENU_ENV=over` puts it over the quad, alpha-blended (controllers in front of the
screen stay in front, if SteamVR keeps the layer order); `=0` turns it off. Mock, warm caches, menus
30-90 s: 120.5 sets/s, 0 late, GPU 2.60 / 2.99 ms a set (without: 1.25 / 5.89), encode 0.52 ms.
## What the next session should show (game.log)
- `OpenXR eye fields of view (deg, left right up down): ...` once: the Frame's real FOVs (for the mock).
- `vr display times (10 s): predicted step minus period ...`: SteamVR's prediction jitter and display
lead; `located for another time than their cycle's` counts sets the new end time changed.
- `VR interpolation weights (10 s)`: most sets "between" in the race.
- `OpenXR menu environment layer ready (3456x1728, under the screen)`.
- Owner: no doubling when turning the head in a race; HUD on the screen in front, steady (item box
sliding up out of the frame clipped); controllers and laser in menus; whether the controllers
disappear behind the screen when held in front of it (then try `KARTFRAME_MENU_ENV=over`).
+99
View File
@@ -0,0 +1,99 @@
# H3 (2026-10-13 13:22, third headset session): what happened, and lane H's fixes
Session `$KARTFRAME_HOME/sessions/20261013-132235` (game.log, vrtiming.log, report.txt), build `native`
ff9b277 (lane G's fixes). Owner: no doubled images; "the frame rate felt really bad, flickering, it
hurt, kind of nauseous"; "half speed at the start, every second of game time took two, better later";
HUD practically fixed. Requests: (R1) full-screen 2D effects (Blooper ink, the black of a respawn)
across the whole view; (R2) remove a thin transparent vertical line right of the map. Later: the menu
laser's part over the screen and its target mark were hidden.
Session: 3.1 min, race 137 s at 90.8 sets/s, 24.2 % late, GPU 6.68 ms/set p50 (H2: 5.9), encode 2.59;
SteamVR: app frames reused 4.4 % (H2 0.2), mispresented 1284 (H2 116).
## Causes
1. **Half speed at the race start: a PrimeFrame build.** Its ninja log: 1172 jobs written 13:23:25 to
13:23:44 (mostly cache hits); the game started 13:22:46, the race at t=41. Slow game frames
(`hitch:` lines) per 10 s: 192 and 285 in t=40-60, at 21.5 / 15.7 ms of game CPU and 4.0 / 3.7 ms
run-queue wait each; afterwards 33 to 164 per 10 s at ~14 ms game CPU and 0.7 ms run-queue. The
build is paused by the headset-in-use marker now (orchestrator).
2. **Flicker: 20 to 36 % of race sets late, the whole race.** GPU p50 6.3 to 7.3 ms a set, p99 9 to
10, against ~7.5 ms of GPU per 8.33 ms refresh left beside SteamVR's compositor: a late set repeats
the retained layer and the scene stalls a refresh. Lane G's end-time rule made it worse: every
layer was ended with its poses' locate time, so each late set went out with a display time one
refresh older than its cycle's prediction (SteamVR counts such frames as mispresented; 1284 here).
3. **"Located for another time than their cycle's ~1180 of ~1190" was not an off-by-one.** SteamVR's
predictions jitter by +-0.02 ms; a pipelined set is located for "this prediction + one period", so
nearly every set differed from its showing cycle's prediction by microseconds. Only the late sets
(the -8.3 ms minimum) were a refresh off. The display lead was 25.06 ms in menus and 27.18 in the
race (3 refreshes; the mock had 2).
4. **GPU per set 6.7 ms vs H2's 5.9 is not lane G's cost.** Same mock race (Grand Prix, HUD on the
screen, real FOVs, 3-refresh lead): H2's build 4.92 ms, H3's build 5.05, H3's with
KARTFRAME_SCENE_CLOCK=0 5.03. The headset difference is not reproduced headless; candidates are the
SoC's state in a third session of the day (CPU 85 to 90 C under load here) and SteamVR. Unconfirmed.
## Fixes (branch lane/H)
| commit | what |
| --- | --- |
| d46788d | Every xrEndFrame carries its cycle's predicted display time (KARTFRAME_END_TIME=locate: lane G's rule). Display-time line counts late sets apart from jitter. Mock: the Frame's per-eye FOVs, 3-refresh lead (MOCK_XR_LEAD), 0.02 ms jitter, an ERROR for any other end time, MOCK_XR_DUMP_STEP. |
| 673408a | Game thread, pacing thread, Aurora's frame worker, GX thread and GPU watcher at nice -15 through RealtimeKit (else RLIMIT_NICE; KARTFRAME_THREADPRIO=high/nice/off). run.sh holds PrimeFrame's lock too; perf/bgload.sh. |
| 4b87fcf | uclamp min 1024 for the game thread, 800 for the frame worker (KARTFRAME_UCLAMP=0 off). |
| 0409ac2 | xrmock: unique head poses; the pose check had matched a "twin" locate (false stamp errors). |
| f127ea4 | R1: blended full-frame 2D rectangles drawn after the scene go across the whole eye (KARTFRAME_FULL_VIEW_FADES=0 off). |
| 798a4a5 | R2: half-scale copies at a fractional scale leave out their last 3 logical columns (KARTFRAME_COPY_TRIM=0 off). |
| 3175a78 | Menus: environment layer over the screen quad by default (KARTFRAME_MENU_ENV=under: old order). |
| 96bb810 | Eye replay skips blended screen filters with alpha under 2/255 (the race's every-frame full-frame quad is not one of them: R1's cost stays). |
## Numbers (mock, real Turnip GPU, 1728/eye, 120 Hz, sysmem, pipelined, Grand Prix race with HUD, both build locks)
| run | race sets/s | late | GPU p50 | game fps | slow frames / 74 s |
| --- | --- | --- | --- | --- | --- |
| H3 build (lane G), mock with real lead/FOV | 119.7 | 0.05 % | 5.05 | 58.4 | 423 |
| lane H, nice only (B1) | 119.3 | 0.20 % | 5.30 | 59.4 | 244 |
| lane H, nice + uclamp (H6 / J3) | 119.3 / 119.6 | 0.17 / 0.15 % | 5.33 / 5.40 | 60.0 | 85 / 111 |
| same, KARTFRAME_FULL_VIEW_FADES=0 (J2) | 114.9 | 0.39 % | 5.02 | 60.0 | 152 |
| final build 96bb810, shipping defaults (Z1) | 119.3 | 0.14 % | 5.34 | 60.0 | 89 |
| same with bgload.sh 4 jobs at nice 5 (Z2) | 108.5 | 8.87 % | 5.66 | 44.5 | 1820 |
Unloaded, without vs with the thread changes (stamp runs): priority off 58.8 fps / 286 slow frames,
nice only 58.2 / 376, nice + uclamp 60.0 / 75. R1 costs ~0.3 ms of GPU a set in the race (a blended
full-frame quad is drawn every frame and now fills both eyes).
Background load (`frame/perf/bgload.sh`: clang compiles, run beside the race):
| load | priority off | nice -15 | nice -15 + uclamp |
| --- | --- | --- | --- |
| 4 jobs at nice 5 | 45.5 fps, 8.8 % late | 46.7 fps, 6.8 % | 45.9 / 44.7 fps, 7.5 / 9.2 % |
| 5 jobs at nice 0 | 38.8 fps, 11.3 % | 39.5 fps, 8.8 % | 39.0 fps, 10.3 % |
The game does not hold 60 under a compile load whatever its priority: slow frames keep ~15 ms of game
CPU and only 1.3 to 2.9 ms of run-queue wait, so the loss is shared cores, caches and memory, not
scheduling. Pausing other builds while the headset is in use is the fix that matters.
Checks: pipelined and inline, exact release check (MOCK_XR_BUSY_MS): 23722 / 18208 releases, 0
unfinished; stamps 17818 / 18246 eye images, 0 mismatches; end times 0 off; 0 errors. ctest 32/32.
## R1, R2, menu pointer (eye dumps, ~/.cache/kf-laneH/evidence)
- R1: race start fade-in: before, a black rectangle on the virtual screen; now the whole eye darkens.
The Blooper ink was not reproduced headless (the player is last in the scripted race and is never
inked); the ink is particle effects ("rk_gessoA*" in Race/Common.szs RKRace.breff). If it is drawn as
blended full-frame rectangles it is covered; if as smaller particles it still sits on the screen: the
"VR large 2D rectangle" lines of the next session will tell.
- R2: the line is in the minimap texture: its bake copy (440x440 logical -> 220x220 IA4, half scale)
holds a faint white stripe (alpha 1/15) in the last two or three logical columns; trimming 4 render
pixels of the copy source removed it, 2 or 3 did not, clamping the sampler did not. What in the bake
draws it is not known.
- Menus: rays reach their marks on the screen; a controller raised in front of the screen is drawn over
it (composited from MOCK_XR_DUMP_ALPHA dumps by a scratch script, both layer orders).
## What the owner should see in the next session
- Race at full speed, `thread priority:` lines with nice -15 and uclamp, no PrimeFrame build.
- `vr display times`: "a refresh or more earlier, shown late: N" small; SteamVR mispresented near H2's.
- `vr pacing` late per second: if still 20 % or more in the race with GPU above 6.5 ms, the GPU budget
is the problem (try KARTFRAME_FULL_VIEW_FADES=0 to win ~0.3 ms, then the 90 Hz fallback).
- A respawn: the black covers the whole view. The ink: whole view or still on the screen (report).
- No vertical line right of the map; the map otherwise unchanged.
- Menus: the laser ray and its mark visible on the screen; controllers in front of the screen.
+38
View File
@@ -0,0 +1,38 @@
# H4 (2026-10-13 18:57, fourth headset session, 90 Hz): lane I's first two fixes
Session `$KARTFRAME_HOME/sessions/20261013-185740`, build `native` 2fe42c4 at 90 Hz: pacing clean (89.8
sets/s, 0.2 % late, compositor reused 0.1 %). Owner: doubling of the character (and the Time Trial ghost
"like a PNG") when turning the head; the laser ray still not visible on the 2D menu screen.
## 1. Doubling on head turns: lane H's full-view fades took textured composites
Lane H's R1 (f127ea4) drew every blended full-frame 2D rectangle after the scene across the whole eye,
mapped from frame space, i.e. pinned to the head. Untextured ones are fades (black, white flashes,
wipes); textured ones composite a picture of the scene (the ghost from its offscreen copy, the player's
character). Pinned to the head, such a picture doubles against the world on every head turn. The
session's log has the textured, depth-tested full-frame rectangle (blend 1, depth func LESS, 2 TEV
stages, 1 texgen) that R1 made full-view, one a frame in the Time Trial.
Fix (lane/I-fixes 6a9632b, lane/I 65e6fce): a full-view fade needs no texgens. Textured full-frame quads
take their pre-lane-H path (the virtual screen, or the framebuffer-effect path when they sample a fresh
EFB copy). `Full-frame 2D draw class: blend B logic L texgens N -> <path>` names each class once. Mock
(demo race and a scripted Grand Prix, head sway +-0.3 rad): only "blend 1 logic 0/15 texgens 0" go
across the eye (one a frame: a black quad blended by destination alpha, per eye pixel, so it cannot
double); "blend 1 logic 15/3 texgens 1", "texgens 2" are textured and now stay on the screen. Not
reproduced: a real ghost (no scripted route to a Time Trial). A textured composite of an offscreen
render was never stereo-correct (before lane H either): it is flat on the screen. Making the ghost
3D needs its own draws replayed per eye (follow-up).
## 2. Laser on the menu screen: SteamVR ignores our layer order
With "OpenXR menu environment layer ready (3456x1728, over the screen)" the ray and its mark, drawn in
the environment projection layer over the screen's quad, still did not show on the screen in the
headset, while the mock (which draws layers in submission order) showed them. So SteamVR on the Frame
evidently composites the projection layer before (under) the quad layer whatever order xrEndFrame lists
them in. Nothing may depend on the order of a projection layer against a quad layer there.
Fix (lane/I): the pointer's target marks (a dark-rimmed ring in the hand's colour with a white centre,
both hands when both aim at the screen) are drawn into the screen quad's own image, on its surface:
visible and stereo-correct under any layer order. The environment layer goes under the screen quad by
default again (KARTFRAME_MENU_ENV=over: lane H's order). The ray now stops at the screen's edge where
the quad covers it. Mock dump (menus, MOCK_XR_HANDS=1, t=35 s): both marks on the screen image.
+208
View File
@@ -0,0 +1,208 @@
# H7 (lane K): the seventh headset session, the zigzag on head turns, and sets rendered ahead
Sessions `$KARTFRAME_HOME/sessions/20261014-031224` (launch 1, `KARTFRAME_EYE_PAIRS=2`) and `20261014-031408`
(launch 2, 6 pairs), both recorded (`KARTFRAME_RECORD=raw`) with one XRF line per frame
(`KARTFRAME_FRAME_LOG=1`). Owner: both "bad, doubled on head turns", the second maybe worse. Panel
analysis: `frame/perf/panels/analyse.sh`. Scripts for this page: `$HOME/.cache/kf-laneK/h7/` (not kept in
the repo; `h7.py` the table, `xcurve.py` the alignment, `track.py` the feature check).
## 1. Does the zigzag follow the end time? No.
**Alignment, to the refresh.** A refresh with no frame (XRF wake-up step > 1) shows black on both panels.
Panel refresh n (round(pts x 120)) shows the frame of wake-up refresh r = n + K, counted from the first XRF
line by summed wake-up steps (no drift). Launch 1: K = 458; black-vs-missing correlation by K: 456 0.33,
457 0.62, **458 1.00**, 459 0.62, 460 0.31 (the gaps are 3 refreshes wide), two anchors 44 s apart (r
932-934 -> panels 474-476, r 6160-6162 -> 5702-5704), and the retained repeat r 6163 (f=0) lands on panel
5705, pixel-identical to 5701 (r 6159), as a repeat must. Launch 2: K = 455 +-1 (fewer, messier gaps).
In time: video t + 3.8 s = XRF w; video t + about 4.5 s = `vr pacing` t (not the 5.9 s of the sixth
session: the recorder starts at a different point after the game each time; take K from black refreshes).
Generic signals (e, f=0, late) correlate with no panel measure at any offset (|r| < 0.2, flat).
**The zigzag is real.** Launch 1, right eye, refreshes 3839-3847: three textured 48x48 patches tracked at
full resolution: +3.9, -11.3, +4.0, -11.0 px (480-px units) per refresh against motion.txt's +3.3, -11.0,
+3.6, -10.9. Every second refresh sits about 7.5 px off a smooth -3.5 px/refresh track: an image placed
two refreshes of motion away, then back. Not pixel-identical to an earlier one (stale: 0.2 %).
Race, moving refreshes (n-1, n, n+1 captured back to back, median shift over n-2..n+3 at least 2 px);
zigzag refresh: |pos(n) - mean(pos(n+-1))| >= 1 px and >= 0.75 refresh of motion; e, f, p of the frame
shown on that refresh:
| launch | eye | e < 1 ms | e 1-2 | e 2-4 | e 4-6 | e > 6 | f=1 | f=0 repeat | p=0 / p=1 (launch 2: p0..p5) | menus |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 (2 pairs) | L | 31 % (547, 1.8 px) | 33 % (18) | 33 % (6) | 1 of 1 | 3 of 4 | 31 % (572) | 2 of 2 | 27 / 36 % | 4 % (184) |
| 1 (2 pairs) | R | **84 % (477, 6.1 px)** | 78 % (23) | 5 of 5 | 1 of 1 | 0 of 4 | 83 % (506) | 0 of 2 | 84 / 82 % | 6 % (224) |
| 2 (6 pairs) | L | 45 % (29) | 28 % (138) | 27 % (146) | 36 % (67) | 22 % (96) | 30 % (381) | 19 % (48) | 17-56 % | (5 refreshes) |
| 2 (6 pairs) | R | 56 % (52) | 69 % (130) | 71 % (106) | 69 % (58) | 72 % (86) | 68 % (350) | 68 % (47) | 55-82 % | (7 refreshes) |
(n moving refreshes in brackets; mean |zigzag| where given.)
- **e: no.** In launch 1's race 78 % of frames ended within 1 ms of the wake-up (e p50 0.11 ms) and the
right eye zigzagged on 84 % of those refreshes. Neither eye's rate rises with e in launch 2 (e p50
2.2 ms). e of the frame shown, of the frame before or after, the change of e, and e straddling 1, 2 or 4
ms each explain under 4 % of the variance.
- **f: no.** Launch 2 right eye: 68 % on fresh frames, 68 % on repeats, 71 % right after a repeat.
- **p: no refresh-level effect.** Both pairs alike in launch 1; where a repeat flips pair against refresh
parity the zigzag phase followed the pair 4 times of 7. The pair count changes the shape: with 2 pairs
clean +-2-refresh jumps with period 2, with 6 pairs irregular jumps of 1 to 8 refreshes (only 8 to 11 % at
a pair's own reuse distance).
- **What does predict it: the eye and race against menus.** Same frames, same e, f, p: right 83 %, left
32 %. Menus (98.6 % of ends under 1 ms): 4 to 6 %, the noise floor; but the menus are a quad layer that
SteamVR places in the room itself, so they cannot show a projection-layer misplacement either way.
So the premise of this lane (frames ended late after the wake-up are shown inconsistently and that is the
doubling) does not hold for this session: launch 1 was already ended at the wake-up for most of its race.
The sawtooth is real (launch 2: e p50 2.2 ms, p90 7.5 ms; launch 1 in its worse seconds), and sections 2-4
remove it, but on this evidence the doubling has another cause, specific to the eye, present with any end
time and any pair count. Candidates not tested here: what SteamVR does with the right eye's layer (its pose
or image) when it composes; a per-eye difference in our own submission; the race's scene motion (a game
frame is two refreshes, the size of the jump) against the head's.
## 2. The fix for the sawtooth: sets rendered ahead (the new default of `pipelined`)
**Why the old loop drifted.** It had one set in flight: the set for refresh k+1 was located and published
only after frame k was ended, and frame k was ended when its own set finished on the GPU (waiting up to
the deadline, 1 ms before the next wake-up). A set costs encode (about 2.5 ms; behind a game frame's
encode on the frame worker when one is running) plus GPU (about 5 ms p50 in the worse race seconds,
queued behind the game frame's own GPU work): more than a refresh. So each frame ended later after its
wake-up by the excess (e(k+1) = e(k) + cost - 8.33 ms; the race's +1.7 ms a refresh), until a set missed
the deadline, the cycle repeated the retained layer, and the next set, started at once, ended near the
wake-up again: the 0 to 10 ms sawtooth with a repeat every 4 to 5 refreshes. Not the frame worker
interleaving as such, but the serial chain: the start of a set waited for the end of the last.
**The new cycle** (`openxr_integration.cpp` AheadCycle, backend `*AheadSet*` in `openxr_vulkan_win32.cpp`):
1. xrWaitFrame, xrBeginFrame.
2. Among the sets due by this refresh, take the newest whose GPU work is finished (a non-blocking poll of
the timeline semaphore); end the frame at once with it (its own poses, this cycle's display time),
else with the retained layer. Never wait inside the cycle. A set due now and not finished counts late;
if it finishes later it is shown on a later refresh unless a newer set is ready first, then it is
released unshown (`dropped=`).
3. Start the set for the display time two refreshes ahead (this cycle's predicted time + 2 periods),
located now for that time. Aurora takes one set at a time, so the previous set must have reached
Aurora's queue.Submit first (it has, normally: it was published a refresh ago; the wait is bounded by
this cycle's deadline, else no set starts this refresh).
So each set has the rest of its starting refresh and all of the next (about 16 ms) for encode plus GPU,
one set is on the GPU while the next is encoded, and the end of a frame no longer depends on any set's
timing: it is the wake-up plus xrBeginFrame, the poll, two releases and xrEndFrame. With the race's
measured costs (encode 2.5, GPU 5 p50, 11 p99 including queueing) a set finishes 3 to 9 ms before its
wake-up; only a set over about 15 ms total misses its refresh. Pose prediction is one refresh longer
than before (located about 42 ms before display instead of 33; SteamVR's own lead is 25 of those). The
scene clock (interpolation) learns the new lead by itself. `KARTFRAME_AHEAD_START_US` (default 0)
delays each start after the wake-up for less prediction at the cost of margin.
**Three eye pairs.** Two sets in flight plus the pair on display. A set never writes the displayed pair,
so a set that is overtaken can be released unshown: a release makes that image its swapchain's newest,
which for the displayed pair would change what the retained layer shows. With two pairs the second set
would have to use the displayed pair. Pairs are reused first in, first out: with three, a pair is written
again right after it left the display (as the old two-pair alternation did); `KARTFRAME_EYE_PAIRS=4..8`
lets each rest longer. The old loop's default goes back to 2 (six were worse on the headset). The menu
environment image travels with its set the same way (three of those too, made as needed).
**Switches.** `KARTFRAME_PIPELINED=wait` restores the old loop (with its own pair default, 2).
`KARTFRAME_PACING=inline` is unchanged. Sets ahead need the timeline semaphore (polled); without it the
backend keeps the old loop and the log says so. Log: "OpenXR pipelined pacing: sets rendered two
refreshes ahead ..." and "OpenXR eye swapchain pairs: 3 (sets rendered ahead ...)"; the pacing line's mode
reads `pipelined` (new) or `pipelined-wait` (old) and ends in `dropped=<n> end_late=<n>` (finished sets
released unshown; xrEndFrame calls more than 1 ms after the wake-up). session-report.py shows both.
## 3. The mock scores it
`xrmock` now logs every xrEndFrame against the runtime's own wake-up (the refresh grid, so a late
xrWaitFrame call cannot hide lateness) and prints at the end of the session:
"xrmock: end after wake-up (ms, from the runtime's wake-up): N frames, p50, p99, max; histogram <0.5,
0.5-1, 1-1.5, 1.5-2, 2-4, 4-6, 6-8.3, more; later than 1 ms K (P %)". `KF_MOCKRUN_END_GATE=<percent>` makes
mockrun FAIL above that share (2 separates the two loops below: old 3.0 to 55 %, new 0.4 to 1.0 %).
It also prints a late-end model ("xrmock: late-end model ..."): a frame ended more than
`MOCK_XR_LATE_END_MS` (default 1.5) after its wake-up is taken for the refresh after its display time
(PrimeFrame's H2 reading of SteamVR), each vsync shows the newest frame taken for it, and "zigzag" counts
vsyncs whose pose step differs from the last by 2 or more (a 0 next to a 2). It is a model, not a measure:
section 1 shows the headset's zigzag does not follow the end time, and the model makes no claim about it.
lane J's compositor models (spec, lazyrel, lazyacq) are unchanged.
## 4. Measured: A/B on the scripted Grand Prix, SteamVR's compositor running
`frame/perf/abgp.sh`, interleaved A B A B, 90 s each, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1
KARTFRAME_FRAME_LOG=1`; A = `KARTFRAME_PIPELINED=wait KARTFRAME_EYE_PAIRS=2`, B = the new default (3 pairs).
At render scale 1.0 (the mock's own load) and 1.25 (measurement only: puts the mock's old loop at the
headset's lateness). Per second, pacing lines after t=5; end ms = median of the seconds' p50 / p99 (worst
second's p99); "> 1 ms" = xrEndFrame calls more than 1 ms after the wake-up, share of ticks.
| run | part | s | sets/s | late | end ms p50 / p99 (worst) | > 1 ms | dropped | GPU ms p50 / p99 | encode p50 | GPU MHz, max C |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1.0 A1 | race | 52 | 120.2 | 0.11 % | 0.16 / 0.69 (15.93) | 6.1 % | 0 | 4.68 / 5.06 | 2.68 | 802, 76 |
| 1.0 B1 | race | 49 | 120.5 | 0.03 % | 0.16 / 0.64 (0.95) | 0.1 % | 0 | 4.53 / 4.93 | 2.27 | 797, 74 |
| 1.0 A2 | race | 60 | 120.2 | 0.06 % | 0.15 / 0.62 (10.61) | 2.5 % | 0 | 4.54 / 4.96 | 2.59 | 803, 76 |
| 1.0 B2 | race | 57 | 120.4 | 0.03 % | 0.14 / 0.55 (1.06) | 0.1 % | 0 | 4.62 / 4.99 | 2.29 | 803, 79 |
| 1.25 A1 | race | 60 | **110.9** | **7.80 %** | **3.98 / 7.83** (16.98) | **78.7 %** | 0 | 6.17 / 6.64 | 2.53 | 802, 81 |
| 1.25 B1 | race | 57 | **120.4** | **0.03 %** | **0.19 / 0.47** (0.97) | **0.1 %** | 0 | 6.29 / 6.77 | 2.50 | 803, 80 |
| 1.25 A2 | race | 60 | 112.3 | 6.69 % | 3.89 / 7.72 (8.44) | 77.4 % | 0 | 6.11 / 6.56 | 2.48 | 803, 79 |
| 1.25 B2 | race | 57 | 120.3 | 0.06 % | 0.18 / 0.45 (1.44) | 0.1 % | 0 | 6.32 / 6.77 | 2.54 | 801, 94 |
| 1.0 A1 / B1 | menus | 32 / 35 | 119.5 / 120.2 | 0.18 / 0.07 % | 0.16 / 0.71 / 0.11 / 0.44 | 1.0 / 0.1 % | 0 / 1 | 2.36 / 2.33 | 0.48 / 0.54 | |
| 1.25 A2 / B2 | menus | 24 / 27 | 120.5 / 120.7 | 0 / 0 % | 0.14 / 0.68 / 0.11 / 0.37 | 0.2 / 0.0 % | 0 / 0 | 3.66 / 3.77 | 0.55 / 0.52 | |
The mock's whole-session view (xrmock, every frame from the runtime's wake-up):
| run | end p50 / p99 / max ms | later than 1 ms | late-end model zigzag | lazyrel own poses L / R, eyes from different sets |
| --- | --- | --- | --- | --- |
| 1.0 A1 / A2 | 0.34 / 6.15 / 17.3, 0.29 / 3.10 / 22.3 | 5.5 %, 3.0 % | 0.3 %, 0.2 % | |
| 1.0 B1 / B2 | 0.28 / 0.89 / 14.7, 0.27 / 0.85 / 13.3 | 0.6 %, 0.4 % | 0.1 %, 0.0 % | |
| 1.25 A1 / A2 | 1.67 / 8.08 / 41.8, 1.58 / 7.97 / 13.5 | 55.4 %, 55.4 % | 4.0 %, 3.6 % | A1: 41 / 22 %, 1357 of 7143 |
| 1.25 B1 / B2 | 0.36 / 1.00 / 34.2, 0.34 / 0.89 / 25.9 | 1.0 %, 0.7 % | 0.0 %, 0.1 % | B1: 100 / 100 %, 0 |
- The sawtooth is gone: at the headset-like load the old loop ended 77 to 79 % of race frames more than
1 ms after the wake-up (p50 3.9 ms, p99 7.8), the new one 0.1 % (p50 0.19, p99 0.47). The few later ends
(max 25 to 34 ms) fall outside the race's seconds (its worst second's p99 was 0.97 to 1.44 ms): loading
and scene changes.
- And it holds the rate: at 1.25 the old loop showed 111 to 112 new sets a second (6.7 to 7.8 % late), the
new one 120.3 to 120.4 (0.03 to 0.06 % late), on the same GPU time per set (6.2 to 6.3 ms p50): the
overlap of one set's encode with the last one's GPU work is worth about 9 sets a second there.
- Menus unchanged (0.1 to 0.2 ms p50). GPU clock the same in every run (797 to 803 MHz average); B2's
94 C peak is the SoC's hottest zone late in the series, its numbers match B1's.
- Checks, every run: 0 errors, 0 of 20356 to 21482 releases changed after the GPU passed them, stamped eye
images all rendered for their submitted poses' locate (11786 of 11790 to 14296 of 14302; the 4 to 12
others carry no stamp, as in the old loop), every xrEndFrame with its own predicted display time, lane
J's spec model 100 % own poses. Under the lazyrel model the new loop is consistent (100 %, no split
eyes), the old two-pair loop at 1.25 was not (41 / 22 %, 19 % split). ctest 32 of 32.
- Negative control: `KARTFRAME_NO_GPU_WAIT=1` still fails the exact release check (4 of 9528 releases
changed, 40 s in the menus); few, because a set rendered ahead is nearly always finished two refreshes
later anyway.
- Not measured: the real headset (SteamVR's own lead and composition), the new loop with
`KARTFRAME_AHEAD_START_US` above 0, and with the settings panel's own layer (that layer is off in the
game; its swapchains travel with the sets like the menu environment's).
## 5. Next headset session (two recorded launches, under a minute each)
Install `frame/perf/session-h8/01-old-ending.env` and `02-ahead.env` into `$KARTFRAME_HOME/session.queue/`
(the wrapper takes them in name order, one per launch). Both record the panels and log every frame.
Each launch: menus, then straight into a race (Time Trial is easiest). **During the countdown, or with the
kart braked to a standstill**, look at a track-side edge and turn the head slowly left and right for about
five seconds; then drive and do the same for ten seconds; hold still for five. Say for each launch:
doubled or not, which eye, and whether it was doubled while the kart stood still too.
1. `01-old-ending.env`: `KARTFRAME_PIPELINED=wait KARTFRAME_EYE_PAIRS=2` (the seventh session's launch 1).
Expected: as before, doubled on turns. Logs: "OpenXR pipelined pacing: one set at a time ...", pacing
lines `mode=pipelined-wait`, `end_ms` p99 several ms in heavier race seconds, `end_late` some per cent
of ticks; XRF `e` a sawtooth in those seconds.
2. `02-ahead.env`: `KARTFRAME_EYE_PAIRS=3`, the new default. Logs: "OpenXR pipelined pacing: sets
rendered two refreshes ahead ...", "OpenXR eye swapchain pairs: 3 (sets rendered ahead ...)", pacing
lines `mode=pipelined`, `end_ms` p50 about 0.2 and p99 under 1.5 ms in every race second, `end_late`
under 1 % of ticks, `late` and `repeats` near 0, `dropped` near 0; XRF `e` under 1.5 ms on nearly every
line, `l=0.00` on fresh frames, `p` cycling 0 1 2. session-report.py: "xrEndFrame later than 1 ms after
wake-up" under 1 % (launch 1 several %).
Panel analysis for both (`frame/perf/panels/analyse.sh <session>`, then the zigzag table of section 1;
align with the black refreshes, not a fixed offset). What it decides:
- Launch 2 clean, launch 1 doubled: ending at the wake-up was the fix after all (section 1 then
under-measured it); keep the default.
- Both doubled, zigzag in launch 2 at launch 1's rate (expected from section 1): the end time is not the
cause; keep the new default anyway (no cost, no sawtooth, cleaner in the mock's lazy models) and look
at the cause by the standstill part:
- doubled with the kart standing still (head motion only): the eye images' placement (the poses SteamVR
uses for the right eye, or our submission per eye); next, log both eyes' submitted poses per frame and
compare the right eye's zigzag with its pose steps;
- clean standing still, doubled only while driving: scene time (a game frame is exactly the two
refreshes of the jump); next, the interpolation weights per set (`VR interpolation weights`) against
the zigzag, and `KARTFRAME_SCENE_CLOCK=0` as an A/B.
+197
View File
@@ -0,0 +1,197 @@
# H9 (lane O): what KartFrame hands SteamVR, against GalaxyFrame
GalaxyFrame (native Vulkan, 120 Hz, both eyes from one locate every refresh, two eye swapchain sets,
images released after a fence) was clean on the same headset on 2026-10-14 (30 s of head turns), while
KartFrame doubles on head turns in every one of nine sessions. So SteamVR and the Frame are fine; this page
lists every difference in what the two apps hand SteamVR, as SteamVR logged it (section 1), as the mock
runtime sees the calls (section 2) and in the Vulkan hand-over (section 3), what was switched to
GalaxyFrame's way (section 4) and the next session (section 5). Scratch: `$HOME/.cache/kf-laneO/h9/`.
## 1. What SteamVR logged (xrclient_*.txt, vrcompositor.txt)
Sources: `~/.local/share/Steam/logs/xrclient_kartframe.txt` (11:25 session, 8 pairs) and `.previous`
(11:23, 6 pairs), `xrclient_galaxyframe.txt` (11:38, the clean session) and `.previous` (06:03), and
`vrcompositor.txt` around each app's connection. Every log line was normalised (time stamps, handles and
numbers out) and the message types of the two apps compared (`norm.py`, `scenes.py` in the scratch dir).
Identical in both: the client start-up, the Vulkan renderer of the client ("Supports dmabuf formats +
modifiers? - Yes!", "Enable advanced image processing of scene textures", "max texture dimensions
3458x3458"), the session states, the LOCAL space ("xrLocateViews: Switching to TrackingUniverseSeated
because XR_REFERENCE_SPACE_TYPE_LOCAL continued to be requested"), the projection bounds (full image, one
image per eye), 120 Hz granted the same way, "WaitForPendingPresent: failed to wait for present" once at
connection (both), no warning from SteamVR about either app's frames.
| | KartFrame | GalaxyFrame |
| --- | --- | --- |
| extensions requested | XR_KHR_vulkan_enable2, **XR_KHR_convert_timespec_time**, XR_VALVE_frame_controller_interaction, XR_FB_display_refresh_rate, XR_HTC_vive_cosmos/focus3_controller_interaction, XR_EXT_hand_tracking, ... | **XR_KHR_opengl_es_enable, XR_MNDX_egl_enable**, XR_KHR_vulkan_enable2, XR_VALVE_frame_controller_interaction, XR_FB_display_refresh_rate, XR_FB_space_warp, XR_META_recommended_layer_resolution, XR_META_performance_metrics, XR_EXT_eye_gaze_interaction |
| client's Vulkan debug utils | "enabled" (Dawn's instance enables VK_EXT_debug_utils) | "disabled, missing entrypoints" |
| eye swapchains | **VkFormat 50 (B8G8R8A8_SRGB)**, 1728x1728, sampleCount 1, array 1, mip 1, 3 images each; 6 to 16 eye swapchains (3 to 8 pairs) | **VkFormat 43 (R8G8B8A8_SRGB)**, 1555x1555 (its resolution 0.9), sampleCount 1, array 1, mip 1, 3 images each; 4 eye swapchains (2 sets) |
| usage flags (from the mock, SteamVR does not log them) | **COLOR_ATTACHMENT, TRANSFER_DST** | **COLOR_ATTACHMENT, SAMPLED** |
| remote image memory | 12001280 bytes for 1728x1728x4 = 11943936 + 57344, memory type 0 | 10088448 for 1555x1555: 1600x1568x4 + 53248, memory type 0 |
| other swapchains | menu environment 3456x1728 (format 50; 11:23 only, 11:25 "xrCreateSwapchain failed (-10)" with 8 pairs), settings panel when open | UI quads 1600x900, 880x1300, 2048x1152 (2), 1280x940, 32x512, 64x64, 128x128, format 43 |
| layers in the race | one projection layer | one projection layer plus quad layers (HUD, pointer) |
| client's per-frame log | `Scene[eye:image]` for both eyes every frame, every frame a new image | the same |
| per-eye image index | pairs 0-2 left = right; pairs 3-7 left one ahead (the menus' quad uses only the left image of a pair) | always equal |
| compositor at exit (last two sessions) | 8007 / 7553 presents, 10 / 13 dropped, 45 / 32 reprojected; "ApplicationTime GPU 1.09 ms" | 13001 presents, 18 dropped, 74 reprojected; "ApplicationTime GPU 4.46 ms" |
| shared images | 6 "VRMsg_CompositorNewSharedImage" (the menu quad as an overlay, "openxr_quadlayer_0") | none in the session (its quads are not overlays) |
Reading it:
- The two real hand-over differences SteamVR shows are the **pixel format** (BGRA against RGBA: the
client imports both as dmabufs with modifiers, both compressed, judging by the metadata after each image,
but the compositor samples a B8G8R8A8 image on another path than R8G8B8A8 for all we know) and the
**usage** (TRANSFER_DST against SAMPLED; it can change the modifier the client picks). The size
(1728 against 1555) and the swapchain count are differences too.
- "ApplicationTime GPU" is SteamVR's time between its own GPU marks in xrBeginFrame and xrEndFrame:
GalaxyFrame submits its eye work between them, KartFrame's frame worker submits it at other times. Not a
fault, but SteamVR sees a 1 ms app.
- Nothing in either log names a per-eye problem; SteamVR's counters are as clean for KartFrame as for
GalaxyFrame (0.4 to 0.6 % reprojected).
## 2. The OpenXR calls per frame (both apps against the same mock)
`xrmock` has a detail trace now (`MOCK_XR_TRACE_DETAIL=1`): every line ends in "@ms after the frame's
scheduled wake-up", plus the reference spaces with their poses, every xrLocateViews (space, display time
against the frame's predicted one), xrWaitSwapchainImage's timeout, and per xrEndFrame the blend mode,
display time, next chains, and per layer its space, flags, rects, array indices, fields of view and the
locate its poses came from. GalaxyFrame's installed binary ran against KartFrame's mock in a scratch home
(`gfmock.sh`: `GALAXYFRAME_HOME` scratch, the game data linked read-only, `PETARI_GFX=vk`, 30 s,
`TU_DEBUG=sysmem`); KartFrame the scripted Grand Prix (`frame/perf/hudrace.sh`, 90 s). Steady frames
(GalaxyFrame f2500, KartFrame race f9000, baseline build 3337d22):
| | KartFrame (baseline) | GalaxyFrame |
| --- | --- | --- |
| order in a refresh | wait @0.07, begin @0.18-0.33, release L, R, **xrEndFrame @0.29-0.53**, then locate for **+2 refreshes**, acquire + wait L, R (Aurora renders the set on its frame worker) | wait @0.08, begin @0.21, **locate for +1 refresh**, acquire + wait L, R, record and submit both eyes, release the previous set L, R @0.48-0.76, release 3 UI quads, **locate for this frame's own display time** @0.82-0.86, acquire UI quads, **xrEndFrame @0.91-1.01** |
| last xrLocateViews before xrEndFrame | the one for the set two refreshes ahead (made the refresh before) | one for the frame's own display time, right before the end |
| reference spaces | VIEW, LOCAL, and a recentred LOCAL (pose 0, 1.6, 0 and a yaw) for locates and layers | the same three (LOCAL twice, VIEW, then the recentred LOCAL) |
| poses submitted | the set's own locate, for the end's display time (+-0.003 refreshes of SteamVR-like jitter) | the same |
| xrWaitSwapchainImage | infinite timeout | infinite timeout |
| xrEndFrame | blend OPAQUE, display time = the frame's predicted time, no next chain; one projection layer, flags 0, recentred LOCAL, each view its own swapchain, rect 0,0 1728x1728, array 0, no next chain (no depth) | the same, 1555x1555; plus three quad layers (flags BLEND_TEXTURE_SOURCE_ALPHA) |
| ends after the wake-up (mock) | p50 0.47 ms, 1.6 % over 1 ms | p50 0.96 ms, 42.6 % over 1 ms |
| swapchains | 3 eye pairs (6), 2 environment (3456x1728), usage 0x11; per pair left and right image indices out of step (the menus' screen uses only the left image) | 4 eye swapchains, 8 UI, usage 0x21; indices in step |
| extensions (mock) | vulkan_enable2, convert_timespec_time, frame_controller, display_refresh_rate, performance_settings | opengl_es_enable, MNDX_egl_enable, vulkan_enable2, frame_controller, display_refresh_rate, performance_settings, eye_gaze_interaction (the mock offers no META/space_warp) |
Nothing GalaxyFrame submits is missing from KartFrame's projection layer (no depth info, no flags, no
composition settings: GalaxyFrame's layer settings and SpaceWarp are off on Vulkan). The differences are
the order (GalaxyFrame acquires and records the next set before it releases and ends; KartFrame releases
and ends first), the extra locate for the frame's own time before the end, the lead of the set's locate
(+1 against +2 refreshes; KartFrame's old one-set loop, `KARTFRAME_PIPELINED=wait`, located for +1 like
GalaxyFrame and doubled the same, H7 launch 1), when in the refresh the end comes (KartFrame earlier) and
the quad layers beside the eyes.
## 3. The Vulkan hand-over
GalaxyFrame: `platform/src/xr/xr_gfx_vk.cpp`, `gx/vk_renderer.cpp` (transitionImage, Barriers). KartFrame:
`openxr_vulkan_win32.cpp`, `aurora-main/lib/webgpu/vulkan_win32_interop.cpp` (Encode, Submitted), the Dawn
patch (`AuroraDawnVulkanWrap`, `AuroraDawnVulkanRelease`, Dawn's `TextureVk.cpp` barrier rules).
| | KartFrame | GalaxyFrame |
| --- | --- | --- |
| format, views | **B8G8R8A8_SRGB**, one sRGB view, no MUTABLE_FORMAT (sRGB sink) | **R8G8B8A8_SRGB**, one sRGB view, no MUTABLE_FORMAT |
| usage | **COLOR_ATTACHMENT, TRANSFER_DST** | **COLOR_ATTACHMENT, SAMPLED** |
| how the eye gets in | a Dawn render pass (LOAD_OP_CLEAR, STORE) with a full-screen triangle that loads Aurora's eye texture and writes it through the sRGB view | its own dynamic-rendering passes composite the eye straight into the image |
| barrier at the first write after xrWaitSwapchainImage | Dawn's tracked state: COLOR_ATTACHMENT_OPTIMAL to COLOR_ATTACHMENT_OPTIMAL, src COLOR_ATTACHMENT_OUTPUT / COLOR_ATTACHMENT_READ+WRITE, dst the same, queue families IGNORED | `assumeImageLayout`: src **ALL_COMMANDS / MEMORY_WRITE**, dst COLOR_ATTACHMENT_OUTPUT / COLOR_ATTACHMENT_READ+WRITE, old layout COLOR_ATTACHMENT_OPTIMAL (UNDEFINED where the pass discards), queue families IGNORED |
| barrier before the release | COLOR_ATTACHMENT_OPTIMAL to COLOR_ATTACHMENT_OPTIMAL, COLOR_ATTACHMENT_OUTPUT, COLOR_ATTACHMENT_READ+WRITE to the same, IGNORED (appended by `AuroraDawnVulkanRelease`, flushed in its own submit) | none when the image is already COLOR_ATTACHMENT_OPTIMAL after the pass (else a transition to it), IGNORED |
| ownership transfer to EXTERNAL / FOREIGN | none | none |
| GPU finished before the release | timeline semaphore signalled after the set, waited before every release (mock: 0 of 21182 released images changed afterwards) | timeline value per submit, waited before the release (and re-checked in it) |
| command buffers | one Dawn command buffer for both eyes (and the test stamp), one more submit for the release barriers | one command buffer for both eyes |
| GPU writes after the release barrier | none: the stamp (tests only) and the sink draw are in the set's encoder before it; the release submit holds only barriers | none |
| reads of swapchain images | never | none for the eyes in play (SAMPLED is there for UI mips and debug shots) |
| an image on display rewritten | no: a set never writes the pair on display (lane K); mock spec and lazyrel models 100 % own poses | no: two sets, the one on display is not acquired again until the next is released |
| queue | one queue; SteamVR's client submits on it inside our begin, end, acquire and release calls, under Dawn's device guard | one queue, one thread |
| memory (SteamVR) | 1728x1728x4 + 56 KiB (compressed: UBWC metadata) | 1600x1568x4 + 52 KiB (compressed) |
Memory visibility for the compositor (a compressed image read stale by a foreign reader): both apps give
SteamVR the same guarantee, the image finished on the GPU and in COLOR_ATTACHMENT_OPTIMAL, without a queue
family release to EXTERNAL and without a flush of their own; SteamVR's client does the hand-off on our
queue after our release. If that left a compressed image partly stale, GalaxyFrame would show it too, and
a stale image would show old pixels: lane N's search (below) finds the doubled refreshes are not pixel
matches of older (or newer) images. KartFrame's release barrier is the stronger of the two; GalaxyFrame's
first-write barrier is (it waits for ALL_COMMANDS and MEMORY_WRITE, so it orders after whatever SteamVR's
client recorded at the acquire). Making Dawn do that needs a new Dawn entry point (a rebuild of Dawn);
not done here, it is the next candidate if the format does not decide it (section 5).
## 4. What changed (switches, default GalaxyFrame's way)
- `KARTFRAME_XR_FORMAT=rgba|bgra` (default rgba): the eye pairs are R8G8B8A8_SRGB (VkFormat 43) whenever
the sRGB sink is on and SteamVR offers 43 (it does). Aurora still renders BGRA; the sink's draw writes
any channel order, so it costs nothing. The menus' screen image (the left image of a pair) went in by a
copy before; with RGBA it takes the same draw. The menu environment and settings panel swapchains keep
Aurora's BGRA (they take copies). `bgra` is the old format; the copy and blit sinks keep BGRA.
- `KARTFRAME_XR_USAGE=galaxy|kart` (default galaxy): eye swapchains COLOR_ATTACHMENT | SAMPLED (0x21), with
TRANSFER_DST only where a copy can still reach an eye image (BGRA eyes, the test stamp, the copy sinks);
`kart` is the old COLOR_ATTACHMENT | TRANSFER_DST (0x11).
- `KARTFRAME_END_LOCATE=1|0` (default 1): one xrLocateViews for the frame's own display time between the
set's release and xrEndFrame, as GalaxyFrame's last call before its end (result unused; eye-set frames
only, not the menus; through the runtime's dispatch). About 0.01 ms.
- `KARTFRAME_XR_EXTENSIONS=galaxy` (default KartFrame's list): exactly GalaxyFrame's extension request
(its GLES binding extensions beside Vulkan's, XR_FB_space_warp, XR_META_recommended_layer_resolution,
XR_META_performance_metrics, XR_EXT_eye_gaze_interaction; no XR_KHR_convert_timespec_time, no other
controllers' profiles, no hand tracking). Not the default: it drops hand steering and the other
headsets' controller profiles. Without the clock extension, display times read XrTime as
CLOCK_MONOTONIC (SteamVR's clock); that choice is now made once at the first frame (a stalled frame
switched it to the anchored fallback before, which the mock's stamp check caught in a dump stall).
- Log line: "OpenXR Vulkan swapchains ready: VkFormat 50 (Aurora 44), eye swapchains VkFormat 43 usage
0x21 (KARTFRAME_XR_FORMAT=rgba, KARTFRAME_XR_USAGE=galaxy), end locate on (KARTFRAME_END_LOCATE), ...";
"OpenXR extensions asked for: ...".
- Not switched: the order (acquire and record the next set before the release and end: sets ahead keep
three pairs in rotation and would need a fourth), the +1 lead (tested: the old loop), the eye size (1555
is GalaxyFrame's resolution 0.9; 1728 is the floor here), the quad layers (none in KartFrame's race),
the first-write barrier (Dawn).
Mock checks (scripted Grand Prix, 90 s, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
KF_MOCKRUN_END_GATE=2`, SteamVR's compositor running):
| run | errors | releases changed after the GPU | stamps for their poses' locate | end times off | ends > 1 ms after wake-up | gate |
| --- | --- | --- | --- | --- | --- | --- |
| baseline 3337d22 | 0 | 0 of 21182 | 13292 of 13298 (6 unstamped) | 0 of 10649 | 1.6 % | ok |
| new defaults (rgba, galaxy usage, end locate) | 0 | 0 of 21182 | 13296 of 13304 (8 unstamped) | 0 of 10653 | 1.8 % | ok |
| launch 1 (defaults + galaxy extensions) | 0 | 0 of 21228 | 13336 of 13338 (2 unstamped) | 0 of 10673 | 0.9 % | ok |
Race seconds alike (end p50 0.18 ms in both, late sets 14 of about 3970). Validation layer
(`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, defaults, 100 s, menus and race, usage 0x21): 0 messages,
0 mock errors. ctest 32 of 32. Eye images (dumps at 65 and 75 s) identical in colour to the baseline's;
the race position number's colour changes from run to run in every lane's dumps (game state), not with
the format.
## Lane N's future-lag analysis (uncommitted, `$HOME/.cache/kf-laneN/`): verdict
Lane N asked whether the doubled refreshes are images newer than the frame's poses (SteamVR reading an eye
after a newer release) as well as older ones. On the headset recordings (`cross-*.txt`, `cadence-*.txt`:
best match of each refresh among the 8 before and the 8 after, per eye, at rest, slow and turning) the
matches are symmetric in past and future (for example 20261014-111622 right eye turning: -7 17.4 %, -5
15.0 %, +5 10.8 %, +7 12.8 %), and pixel-exact matches with another refresh are rare and as often future
as past (0 to 4.7 %, right eye at rest 9 past / 3 future). In the mock, a lazy compositor shows newer
images only if it reads the eyes after the vsync (3 pairs: 96 to 97 % newer when read 0 to 5 ms after;
none when read before), and with 6 pairs almost never; yet 6 and 8 pairs were worse on the headset. So the
doubled image is neither an older nor a newer swapchain image: the content is right for its set and placed
off, which is what H7 measured (not pixel-identical, two refreshes of head motion). That points at how
SteamVR takes our layer, not at which image it reads; the hand-over differences above are what is left.
One older data point: the third session (H3, 20261013-132235, lane G's build) is the only KartFrame
session the owner called free of doubling. It ended every layer with its poses' locate time (lane G's
`KARTFRAME_END_TIME=locate`) and had 24 % late sets; its frame rate "felt really bad", which may have
hidden the doubling. The format, usage, order and extensions were as in every other session.
## 5. Next headset session (session 10)
Files: `frame/perf/session-h10/01-galaxy-way.env`, `02-format-alone.env`, `README.md`. Install 01 into
`$KARTFRAME_HOME/session.queue/` (and 02 only as the README says). Each records the panels and logs every
frame. Build: this lane's branch installed with `install --dev`.
Expectations, launch 1 (everything GalaxyFrame's way):
- game.log: "OpenXR extensions asked for: GalaxyFrame's list (KARTFRAME_XR_EXTENSIONS=galaxy)";
"OpenXR display times: no conversion offered; CLOCK_MONOTONIC fallback" and "OpenXR: no clock
conversion; XrTime - CLOCK_MONOTONIC = <about 25000> us, reading XrTime as CLOCK_MONOTONIC"; "OpenXR
Vulkan swapchains ready: VkFormat 50 (Aurora 44), eye swapchains VkFormat 43 usage 0x21
(KARTFRAME_XR_FORMAT=rgba, KARTFRAME_XR_USAGE=galaxy), end locate on"; pacing as session 9 (`mode=pipelined`,
end_ms p50 about 0.2, late near 0).
- `~/.local/share/Steam/logs/xrclient_kartframe.txt`: the extension line starts "XR_KHR_vulkan_enable2,
XR_KHR_opengl_es_enable, XR_MNDX_egl_enable, ..."; "Created remote Vulkan image ..., dimensions 1728 x
1728, format 43" for the eye swapchains and "format: 43" after their CreateSwapchain (the 3456x1728
menu environment stays format 50); "Scene[...] [1728x1728]" every frame as before.
- Colours as before (a channel swap would show red skies at once; the mock's dumps are right).
- What it decides: clean (no doubling on head turns, standing or driving, either eye) means the hand-over
was the cause: then launch 2 (format alone) says whether the format is all of it. Still doubled means
none of these four differences: the next candidates are the order of the calls (acquire and render the
next set before releasing and ending), GalaxyFrame's first-write barrier (src ALL_COMMANDS, a Dawn
change), the quad layers beside the eyes, and the eye size (a measurement at render_scale 0.9).
- Panel analysis as before (`frame/perf/panels/analyse.sh <session>`, the zigzag table of H7 section 1).
+63
View File
@@ -0,0 +1,63 @@
# Lane S: is the race's double image already in the eye images KartFrame renders? No.
## Method
Scripted mock race (`frame/perf/mockdump/dumprace.sh`, wrapping hudrace.sh/run.sh, mockrun ok in every run):
Moo Moo Meadows, the head still until 51 s, then a 25 deg / 1.5 s yaw sway (`MOCK_XR_SWAY`, about 2 px a
refresh at 432 px across, up to 3.5); the kart stands until A is held from 56 s (`MOCK_XR_PRESS` hold). Five
windows of 240 consecutive sets (both eyes, every release) were dumped without stalling the app
(`MOCK_XR_DUMP_QUEUE`: blit to 432 px, written when the dumps pause; end after wake-up p99 1.1 ms during the
windows; 0-1 refreshes without a set per window, 12 in the first). Each image's stamp (eye, display time, scene words) is in
index.txt. Phases checked on the images: 48.5 s kart standing after GO, 52.5 s standing, 58.5 s leaving the
start line, 62.5 and 66.5 s driving.
`frame/perf/mockdump/zigzag.py`, per eye and window:
- **view**: global shift from column projections of the upper image (motion.py's fit), H7's zigzag rule as in
session-h12's zig2.py (moving: |speed| >= 2 px; zigzag: |pos(n) - mean(pos(n+-1))| >= 1 px and >= 0.75 refresh).
- **betweenness** (catches any motion, forward too): r = D(n-1, n+1) / mean(D(n, n-1), D(n, n+1)), RMS image
differences of the upper image and the ground left and right of the kart.
- lagdiff (each set against the same eye 1 and 2 sets before), eye phase, the scene stamp's steps.
- Positive control `ZZ_INJECT=2`: every odd set of the left eye replaced by the image of two sets before.
Run s3 (`KF_MOCKRUN_RENDER_SCALE=0.75`: the race at 60 fps, scene stamps exactly 0.25/0.75 as on the
headset's XRS lines, GPU 4.0 ms p50) and s2 (scale 1.0, GPU 6.7 ms, game 50-53 fps: the scene clock clamps,
steps sd 0.3 game frames).
## Results (view track; moving sets / zigzag)
| window | phase | s3 L | s3 R | s2 L | s2 R | |dev| p95 px (s3) |
| --- | --- | --- | --- | --- | --- | --- |
| 48.5 | kart and head still (noise floor) | 0 moving; 0 of 236 sets >= 1 px | same | same | same | 0.00 / 0.00 |
| 52.5 | standing, head swaying | 112 / 0 | 107 / 0 | 110 / 0 | 105 / 0 | 0.20 / 0.17 |
| 58.5 | driving off | 103 / 0 | 108 / 0 | 92 / 0 | 98 / 0 | 0.19 / 0.18 |
| 62.5 | driving | 115 / 0 | 118 / 0 | 113 / 0 | 115 / 0 | 0.14 / 0.16 |
| 66.5 | driving | 137 / 0 | 143 / 0 | - | - | 0.10 / 0.11 |
| control (62.5, 66.5) | odd L sets = n-2 | 87 / 87, 101 / 100 (1.9 refreshes, lag-1 autocorr -1.00) | 0 % | | | 6.1 / 0.16 |
- Zigzag share 0.0 % in both eyes, standing and driving, at both loads; the deviation stays under 0.1 refresh
of motion (p95 0.10-0.20 px at 1.9-2.2 px a refresh). Lag-1 autocorrelation of the deviation -0.01 to -0.60
(white position noise gives -0.67; a period-2 jump gives -1.00 with 2-refresh amplitude, as the control).
L/R deviations +0.15 to +0.46 (no alternation to phase).
- Betweenness, driving with an even scene clock (62.5, 66.5): r < 1 in 0 (upper) and 0-15 (ground) of 238 sets
per eye, lag-1 autocorrelation of r +0.88 to +1.00 (smooth); the control: upper r < 1 in 17-28 sets and
autocorrelation -0.31/-0.35 in the injected eye only.
- lagdiff: no image identical to or nearer an older set while moving (3 of 238 at a sway reversal, both eyes).
- Scene stamps: the two eyes carry the same scene words in 2160 of 2160 sets. Where the mock's game ran under
60 fps (s2; s3 at 58.5 s) the track-side strips followed the stamp's uneven steps in both eyes in phase
(step correlation +0.2 to +0.67, L/R +0.31 to +0.86): the scene clock's own judder, identical per eye, absent
on the headset (XRS weights 0.25/0.75 there). The strip track itself is too noisy for H7's rule while
driving (fit outliers; not used for the verdict).
## Verdict
The eye images KartFrame renders and releases are smooth in both eyes, with the kart standing and driving and
the head turning: no period-2 jump, no stale or older-set image, no eye difference, at the noise floor (0 %),
while the same test flags an injected period-2 jump in 99-100 % of moving sets. Within what a mock can show,
the double image is made after the release: in what SteamVR does with the layers (or in a timing that only
the headset has). Not covered: the headset's own GPU/driver timing (the mock ran at two GPU loads, 4.0 and
6.7 ms a set, both clean).
## Next
`frame/perf/session-h13/README.md`: an xrprobe replay of these recorded race images through GalaxyFrame's
loop, with A/B cases for the per-launch eye and the KartFrame hand-over details.
+109
View File
@@ -0,0 +1,109 @@
# Lane U: does the release wait for the GPU work that wrote the image? Yes (verdict B)
Question (after session 13: KartFrame's own race images through a minimal loop were clean on the headset in
all six cases): can a set be released while its images are still unwritten, so that SteamVR shows the
image's content from one reuse ago (H7: the jump follows the pair count)? And what else differs per
process between the probe and the game at submission?
## 1. The order, read from the code
- Aurora's frame worker encodes both eyes and the sink draw into the swapchain images in one command buffer,
then, holding `g_queueSubmitMutex`: `g_queue.Submit(1, &buffer)` and right after it the sink's `submitted`
callback (aurora.cpp, `submitEncodedSlot` / the replay path).
- Dawn (pinned 13abc3bc, `src/dawn/native/vulkan/QueueVk.cpp`): `Queue::SubmitImpl` records the command
buffers into the pending recording context and calls `SubmitPendingCommandsImpl()` at once, which calls
`vkQueueSubmit`. Nothing is deferred: when `Submit` returns, the render work is on the VkQueue.
- `Bridge::Submitted` -> `AuroraDawnVulkanRelease` (patch `aurora_vulkan_interop.inc`): under Dawn's device
guard, the COLOR_ATTACHMENT_OPTIMAL barriers into the pending context and `SubmitPendingCommands()`
(a second vkQueueSubmit). Then `GpuCompletion::Signal`: under the same guard, an empty vkQueueSubmit that
signals the set's timeline value. A semaphore signal's first synchronization scope is every command
earlier in submission order on that queue, so the value cannot signal before the set's render work and
its barriers finished. The pacing thread releases only after the watcher saw that value
(`PollAheadSet` / `FinishFrame`).
- Queue and family: the XR binding gets Dawn's family and queue index 0 (`handles.queueFamily/queueIndex`);
the watcher's `vkGetDeviceQueue` uses the same pair; Turnip has one queue family. SteamVR's client submits
on that queue inside our calls, under the guard.
- Layouts: images stay COLOR_ATTACHMENT_OPTIMAL (Dawn's tracked state, the release barrier, lane Q's
first-write barrier); no ownership transfer, as GalaxyFrame (H9 table).
So a "fence on the real submission" adds no ordering the timeline value lacks; no `fence` mode was built.
## 2. The mock's checks, and a strict one
- Old exact check (`MOCK_XR_BUSY_MS`): host reads 512 x 64 bytes of the image's memory at the release (no
wait on anything of the app's), then again once an empty submission made at the release completed. Blind
spot: work submitted AFTER the release would land after both samples (the stamps would catch a stale
image, the bytes would not).
- New strict check: the same bytes at the release against the bytes at the image's next
`xrAcquireSwapchainImage` (CPU only, no queue). Any write between release and reacquire, however ordered,
is an error ("changed between its release and its next acquire").
- New release-queue line per swapchain: a waiter thread times the empty submission made inside
`xrReleaseSwapchainImage` (as SteamVR's client submits there) until it completes, i.e. how long a
submission at the release waits behind our queued GPU work; split by frame parity.
- Positive control (`KARTFRAME_NO_GPU_WAIT=1 KARTFRAME_PACING=inline`: release right after submission):
strict 13891 of 14282 changed, exact 13909 of 14300; release queue p50 1.37 ms, p90 3.51 (even frames
p50 3.40, odd 1.14: the per-set GPU cost alternates with the 60 Hz content). With the default pipelined
pacing, `NO_GPU_WAIT` alone is caught once in 14271 (sets are released two refreshes after submission, the
GPU long done).
## 3. Measurements (scripted race, A/B interleaved)
`hudrace.sh` 100 s, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KF_MOCKRUN_END_GATE=2`, lane T's presses (A held from
56 s), head sway from 51 s, `KARTFRAME_FRAME_LOG=1`; runs 1-2 at render scale 0.75 (the race at 60 fps),
run 3 at 1.0. A = default, B = `KARTFRAME_RELEASE=idle`. Race window (67 s):
| run | sets/s | late | GPU ms p50 / p99 | end > 1 ms after wake-up | strict changed | exact changed | mock errors | release queue p50 / p99 ms (eye swapchains) | drain before release / end, mean ms |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| A1 | 120.5 | 0.0 % | 3.16 / 4.32 | 0.4 % | 0 of 23857 | 0 of 23880 | 0 | 0.31-0.45 / 0.71-0.73 | - |
| B1 | 120.4 | 0.0 % | 3.23 / 4.46 | 13.2 % | 0 of 23867 | 0 of 23890 | 0 | 0.16-0.24 / 0.36-0.41 | 0.33-0.42 / 0.12-0.14 |
| A2 | 120.5 | 0.0 % | 3.34 / 4.17 | 0.3 % | 0 of 23871 | 0 of 23894 | 0 | 0.27-0.43 / 0.64-1.39 | - |
| B2 | 120.4 | 0.0 % | 3.14 / 4.01 | 12.2 % | 0 of 23877 | 0 of 23900 | 0 | 0.14-0.17 / 0.37-0.41 | 0.31-0.44 / 0.10-0.14 |
| A3 (1.0) | 120.4 | 0.0 % | 4.55 / 5.75 | 0.5 % | 0 of 23867 | 0 of 23890 | 0 | 0.25-0.32 / 0.68-1.72 | - |
| B3 (1.0) | 120.5 | 0.0 % | 4.47 / 5.63 | 10.7 % | 0 of 23869 | 0 of 23892 | 0 | 0.19-0.26 / 0.35-0.38 | 0.28-0.35 / 0.12-0.15 |
- Default: a submission made at the release completes in 0.3-0.45 ms p50 (about the floor of a submit,
interrupt and wake-up on this machine; with idle it is 0.15-0.25), > 2 ms in at most 0.3 % of releases,
even and odd frames alike. The set released was finished and the next one is mostly done by the wake-up:
at the release our queue is about as empty as the probe's.
- Stamps (A1): 15992 of 15998 eye images rendered for the locate of their submitted poses, 0 for another.
- `idle` costs the drain's round trip (0.3-0.4 ms before the releases, 0.1 before xrEndFrame): xrEndFrame
later than 1 ms after the wake-up in 11-13 % of frames (fails the 2 % end gate), no late sets, no
missed refreshes, GPU per set unchanged. Fine for a headset A/B, not a default.
- Machine idle check before every run; SteamVR (vrserver/vrcompositor) running as always.
## Verdict
B: the release is ordered correctly. The release cannot precede the write: by Dawn's code (the render work
is on the VkQueue before the timeline signal is queued), by the exact check, and by the new strict check
(0 of about 143000 released images in six race runs changed between release and next acquire, while the
positive control flags 97 %). And at SteamVR's release-time submission our queue is nearly empty, as in the
probe. H7's "jump follows the pair count" is therefore not our own image reuse.
Per process, what the game has that the probe (and every mock run) lacks, ranked:
1. **The desktop window's presentation.** On the headset the game creates a Gamescope WSI swapchain
(854x480, Mailbox; "Creating swapchain for xid" in every game log) and presents into it once per game
frame: 60 Hz, i.e. every second refresh, on the same VkQueue as the eyes and SteamVR's client (Dawn's
present path waits on the WSI acquire semaphore in a queue submission and vkQueuePresentKHR goes through
the gamescope layer). Mock runs set `KARTFRAME_HEADLESS=1` (no swapchain; 63 offscreen presents a second),
xrprobe has no desktop swapchain. Period 2 at 120 Hz with a start-up phase against the eye releases fits
"every second refresh, which eye per launch". Not reproducible headless (no gamescope here).
2. **Queue backlog at SteamVR's release or end submission that only the headset has** (for example
created by 1, or the headset's own GPU timing). Measured absent in the mock; `KARTFRAME_RELEASE=idle`
removes it and its 10 s log line measures it on the headset.
3. **GPU contention with SteamVR's compositor**: the game keeps the GPU busy 3-5 ms a set, with a heavy and
a light set alternating at 60 Hz (positive control: 3.4 against 1.1 ms of work left at the release);
the probe's blit is a fraction of a millisecond. Next test if 1 and 2 are not it: a render-scale-0.5
measurement launch, or xrprobe replay with an added GPU load alternating at 60 Hz.
4. Excluded here: release before the GPU finished, a Dawn submission after the signal, timeline semaphore
ordering, queue family mismatch, layout transitions. TU_DEBUG=sysmem and the Vulkan layer chain are the
same for both (the wrapper exports TU_DEBUG before running KARTFRAME_EXE; both logs show the
fdm_injection and Gamescope WSI layers); sysmem changes how Turnip renders a pass, not the memory of
SteamVR's images.
Session 14 (`frame/perf/session-h14/README.md`) tests 1 and 2 in two launches.
## Commits (lane/U)
- `xrmock`: strict release check and release-queue latency (only with MOCK_XR_BUSY_MS).
- `KARTFRAME_RELEASE=idle` (default unchanged) with DEV.md lines.
+89
View File
@@ -0,0 +1,89 @@
# Lane W: the compositor's phase, and a lock against it
Sessions 16 to 18: ending the frame about 4 ms after xrWaitFrame's wake-up was clean once (session 16 launch 3)
and doubled in the right eye on repeat (session 18 launch 1), with identical app logs. Working model: SteamVR's
compositor takes each eye's image at its own point in the refresh (the right eye later), and the relation between
the app's xrWaitFrame grid and those points differs per launch and drifts. This lane makes that relation
observable per frame and designs the lock. The lock itself waits for session 19's data (below: why).
## What is logged now
- **vrframes.log** (session folder; `KARTFRAME_VRTIMING_FRAMES=1`, written by frame/tools/vrtiming.py in its own
process, 4 polls a second, read-only background OpenVR app as before). One `F` line per compositor frame:
index, `m_flSystemTimeInSeconds` (openvr.h: "absolute time reference ... aligns with the vsync that running
start is relative to"), presents, mispresented, dropped, reprojection flags, then, in ms after that system
time (openvr.h: "relative to this frame's SystemTimeInSeconds"), WaitGetPoses called, new poses ready, new
frame ready, compositor update start, update end, render start; then present / wait-for-present / idle CPU ms,
compositor GPU ms, client interval. SteamVR keeps 128 frames (1.07 s at 120 Hz); each poll writes the frames it
has not written, except the newest two (they may still be filling in); `G` lines name any frames missed.
One `P` line per poll: CLOCK_MONOTONIC, _RAW and BOOTTIME read around `IVRSystem::GetTimeSinceLastVsync`, its
seconds-since-vsync and counter, and the newest frame's index and system time.
- **XRF lines** (game.log, `KARTFRAME_FRAME_LOG=1`) end in `m=` (the wake-up, CLOCK_MONOTONIC ms; steady_clock is
CLOCK_MONOTONIC on Linux), `r=` (eye images released, ms after the wake-up, -1 on a retained repeat) and `t=`
(the frame's predicted display time, CLOCK_MONOTONIC ms through `xrConvertTimeToTimespecTimeKHR`). The end call
is `m + e`. One `XRF clock:` line says how t= was converted and XrTime minus CLOCK_MONOTONIC.
## How the clocks are matched
1. Without any matching (view A of phase.py): SteamVR measures the app itself. The existing vrtiming summaries
already show it for OpenXR apps: "poses -> frame ready" per second equals our end delay (session 16 launch 3:
4.60 ms, session 18: 4.53 / 5.00 / 4.21 ms at 4000 / 4500 / 3500 us, session 17 launch 2: 6.52 at 6000). So
`posesready` is our wake-up and `frameready` our xrEndFrame, both in SteamVR's own frame-relative ms; the
compositor's update and render start are in the same frame. This alone gives the phase.
2. With matching (view B): `GetTimeSinceLastVsync` returns seconds since the last vsync and a counter; CLOCK_MONOTONIC
read around the call (the call is a few us) gives the vsync in CLOCK_MONOTONIC; if the counter is the F frame
index, `offset = vsync_mono - sys` exactly, else modulo one refresh (all a phase needs). phase.py prints the
offset, its spread and drift (0 means SteamVR's system time is CLOCK_MONOTONIC itself; a constant means the
same clock with another zero; a drift means _RAW, and the P lines carry _RAW to confirm). Check: XRF end
minus the nearest frame-ready should be about 0 (plus vrclient's submit time).
Headless: no compositor in the mock, so only the format is proven: frame/tools/openvr-stub.c (a fake
libopenvr_api.so, system time = CLOCK_MONOTONIC - 1000 s) through `KARTFRAME_STEAMVR_DIR`, beside a mock run.
## Reading phase.py
`python3 -I frame/tools/phase.py SESSION_DIR [...]`, per launch (race only unless `--all`):
- A: p5/p50/p95 of poses ready, frame ready, update start / end, render start (ms after the vsync reference), and
the **margin** = update start minus frame ready (how long before the compositor's update our frame was ready),
with histograms of frame ready and of the margin.
- B: wake-up, end, release and display time minus the nearest vsync, update start and render start (signed,
+-period/2), histograms for the end against each anchor and the wake-up against the vsync.
- Drift table (`--every S`): circular means of end-vsync, end-update, end-render, wake-vsync, margin.
A clean launch and a doubled one at the same delay must differ in some of these; the model predicts: the same
end-minus-wake-up (e) but a different wake-up-vs-vsync, so a different end-vs-update/render start, with the
doubled launch's end piled near one anchor (or a margin near 0). If nothing differs, the per-eye pick-up is not
tied to anything GetFrameTimings reports, and the next lever is the panel recording against vrframes.log.
## The lock (design; build it after session 19)
The game times its end against the compositor's clock instead of its own wake-up: end at
`anchor + phase_target`, where anchor is the compositor frame's vsync reference (or update start), extrapolated
from the newest known one by whole periods, and phase_target is the value session 19 shows clean (e.g. "1.5 ms
before update start"), bounded to [wake-up + 0.5 ms, wake-up + period - pacing margin]. Where the grid comes
from, in order of preference:
1. **From xrWaitFrame's display time, in process, no helper.** If session 19 shows `display time minus the
nearest vsync` (B) constant across launches (clean and doubled alike), the compositor's grid is the display
time minus a constant, and `end = t - K` replaces `wake + D`. One line in the pacing thread, nothing to fail.
The h18 logs argue against it a little (locate-to-display offsets equal while the outcome differed), so this
is the case to rule in or out first.
2. **Published by the helper (the expected case).** vrtiming.py (already polling, own process) writes a 64-byte
record into a file in `$XDG_RUNTIME_DIR` (mmap, seqlock: seq odd while writing): the latest vsync in
CLOCK_MONOTONIC ns (via the P-line offset), the period in ns (from 128 frames: sub-us), the medians of update
start, render start and frame ready over the last second, and the write time. The game mmaps it once and reads
it every frame (no syscall), uses it only when fresh (written < 1 s ago, period within 1 % of
xrWaitFrame's), else falls back to the fixed `KARTFRAME_END_DELAY_US`. Switch: `KARTFRAME_END_LOCK=<anchor>:<us>`
(vsync|update|render, signed offset), logged per XRF line as the chosen target.
3. **OpenVR in process: no.** The OpenXR runtime already runs SteamVR's vrclient in our process; a second
`VR_Init` there shares its globals, and `VR_Shutdown` could take down the OpenXR session's connection.
Risks: (a) the eye pick-up points are not among the reported fields (the compositor may take the eyes at fixed
offsets after render start that only the recording shows); then phase_target is found by a sweep of offsets
against the compositor's anchor instead of the wake-up (one session, 3 values, each repeated) and the lock still
removes the per-launch variation; (b) SteamVR re-phases its running start after a hitch: the helper follows
within a poll (0.25 s; 0.1 s when the lock is on), a few hundred ms of doubling at worst; (c) sleep precision
(about 0.05-0.1 ms here) against a clean band that sessions 16-18 suggest is about 1 ms wide; (d) the end moves
later inside the refresh: no change to the display time or the poses (the set is located two refreshes ahead
either way), only less slack before the compositor, so late ends must stay counted (end_late=); (e) the helper
dies or SteamVR restarts: stale record, fixed-delay fallback, logged once.
Session 19 is needed first: it decides between 1 and 2, and which anchor and offset the lock aims at.
+128
View File
@@ -0,0 +1,128 @@
# Lane X: session 19 frame by frame, and the end lock against the compositor's grid
Data: session 19, `$KARTFRAME_HOME/sessions/20261016-012613` (launch 1, owner: doubled in the right eye) and
`20261016-012746` (launch 2, owner: clean, right eye "more sensitive to movement"), both
`KARTFRAME_END_DELAY_US=4000`, race only (33.6 s / 36.5 s, 4022 / 4355 compositor frames). Tool:
`python3 -I frame/tools/phase.py DIR` (view C below is new in this lane).
## 1. Method
All window numbers are in SteamVR's own clock (no clock matching): compositor frame k reports our frame-ready
(our xrEndFrame as SteamVR saw it, about 0.12 ms after our call) relative to its own vsync, normally -3.5 ms,
i.e. in the previous refresh. So our end lands at `fr(k) + (sys(k) - sys(k-1))` after vsync k-1, and frame
k-1's update start / end are the compositor window it falls in. Frames whose compositor logged no update times
(start = end = 0: 140 / 42 frames, around compositor hiccups) are left out. Our release (XRF r=) is 0.05 ms
before the end (p50; the doubled launch has a 0.55 ms p95 between release and end, the clean one 0.10), so
release and end classify the same.
Clock note: SteamVR's m_flSystemTimeInSeconds runs on CLOCK_MONOTONIC_RAW (the vsync from
GetTimeSinceLastVsync in _RAW minus the frame's system time is constant to +-10 us, folded to one refresh);
CLOCK_MONOTONIC drifts 14 / 33 ppm against it in these launches. phase.py now matches through _RAW; its
per-poll matching already followed the drift, so view B's numbers did not change. Fixed: view B's "end vs frame
ready" check used only positive frame-ready values (it now reads -0.12 ms p50, i.e. the clocks agree).
## 2. Where the end lands (view C)
| race frames | doubled 012613 | clean 012746 |
| --- | --- | --- |
| end after vsync, p5 / p50 / p95 ms | 4.52 / 4.81 / 5.67 | 4.56 / 4.85 / 5.42 |
| update start, p5 / p50 / p95 | 0.22 / 2.83 / 3.17 | 0.62 / 3.03 / 3.20 |
| update end, p5 / p50 / p95 | 3.37 / 5.25 / 5.49 | 4.74 / 5.32 / 5.50 |
| end BEFORE its refresh's update start | **0** | **0** |
| end INSIDE the update window | 3021 (77.7 %) | 3824 (88.6 %) |
| end AFTER the update end | 865 (22.3 %) | 492 (11.4 %) |
| end minus update end, p5 / p50 / p95 | -0.85 / -0.37 / +1.04 | -0.82 / -0.42 / +0.34 |
| class changes between consecutive refreshes | **975 (25.1 %)** | **548 (12.7 %)** |
| the same, seconds without an early update start | 25.0 % (after 16.0 %) | 11.6 % (after 8.7 %) |
SteamVR's per-frame presents by class (presented 0x / 2x+ / mispresented):
| class | doubled 012613 | clean 012746 |
| --- | --- | --- |
| inside | 46 / 25 / 52 of 3021 (2.4 % not 1x) | 17 / 10 / 20 of 3824 (0.7 %) |
| after | 69 / 39 / 121 of 865 (12.5 %) | 22 / 20 / 42 of 492 (8.5 %) |
Anomalies against end minus update end (doubled launch; the clean one has the same shape at lower rates):
-1.0 to 0 ms: 1.4-3.1 %; 0 to +0.25: 4.2 %; +0.25 to +1.25: 11-28 %; later than +1.5 (hitches): 50 %.
## 3. The update window over time
The early update start is neither random nor a steady beat: it comes in **episodes**. Each begins with a
compositor discontinuity (one frame presented 2-3 times or mispresented, then one or two frames with no update
times, sometimes an update a whole refresh late at +8.6 ms), after which the update starts 0.2-1.4 ms after the
vsync and ends early too (3.2-4.0 ms; in the clean launch's end-of-race episode 4.1-5.6), and creeps back to
about +2.9 over 0.3 s (or 3 s):
| launch | episodes (race time, length) |
| --- | --- |
| doubled 012613 | 4.7 s (0.31 s), 9.7 s (0.34 s, after a late update), 15.7 s (0.28 s), 29.6 s (3.2 s) |
| clean 012746 | 32.9 s (1.5 s), 34.4 s (1.7 s) (the race's last seconds) |
The doubled launch's three short ones are 5.0 and 6.0 s apart (a beat-like recurrence of compositor hiccups,
cause unknown: a load or scheduling effect on vrcompositor, not our end); its seconds 22-29 also had the whole
window 0.2-0.5 ms earlier (update start p50 2.4, end 4.8-5.0) with 22-58 "after" frames a second. Outside the
episodes the window is steady at start 2.8-3.2, end 5.2-5.4.
## 4. The model, checked
The orchestrator's reading was "our release fell about 3 ms BEFORE the update in at least 5 % of the doubled
launch's frames". **The per-frame data contradicts that part: no end in either launch landed before its
refresh's update start.** The -3 ms in view B was the nearest-anchor fold: during an episode the update starts
about 4.2 ms before our release, past the +-4.17 ms fold, so the nearest start was the next refresh's. In the
early-start frames our end lands AFTER the (early) update end instead.
What the data supports: the end sits 0.4 ms before the compositor's **update end** in both launches (p50
-0.37 / -0.42), so jitter on either side (our end's tail: p95 - p50 = 0.6-0.9 ms; the update end's: p50 - p5 =
0.6-1.9 ms) carries it across the update end in some refreshes and not in others. The doubled launch crossed
twice as often (25 % vs 13 % class changes, also outside the episodes: 25 % vs 12 %), with its update end
earlier and its own end later in the tails, plus four re-phase episodes. Frames that land after the update end
are the ones SteamVR itself presents 0 or 2 times or mispresents (5-12x the rate of inside frames). With the
per-eye pick-up model (each eye takes the latest released image when the compositor binds it, the right eye
late in the update): inside = the right eye takes the new set a refresh early (consistently: "right eye more
sensitive"), after = both eyes take it on time; every change between the two repeats or skips a set in the right
eye only. That matches the right eye doubling and the left staying clean, and the 2:1 ratio of changes matches
doubled vs clean.
What it does not explain (earlier sessions, no compositor log): session 17's 6000 us launch ended about vsync +
6.9 (1.6 ms after the update end, every frame) and was "right eye very bad, left nearly perfect with a 2 s beat";
session 18's 3500 / 4500 us launches (about vsync + 4.0 / + 5.0, inside the window) were doubled in the right
eye. If the window was where session 19 shows it, the right eye has a second sensitive point after the update
end (e.g. where the compositor's GPU pass reads it, 1-2 ms after the CPU update) and the clean band is narrow
on both sides. Session 20 logs the compositor in every launch, so each verdict gets its own window.
## 5. Consequences for the lock
- The medians did not differ between the clean and the doubled launch (wake-up +0.34 / +0.42 after the vsync,
end +4.81 / +4.85, update end +5.25 / +5.32): a lock that only tracks the median grid would have produced
the same two launches. What the lock must give is **margin from the update end** in the tails, held while
the compositor re-phases.
- "before" (update start p50 - 1.5 ms, about vsync + 1.5, about 1.1 ms after the wake-up) lands inside the
early windows of the episodes (start 0.2-1.4), and is close to the ends at the wake-up (vsync + 0.7) of the
doubled sessions 4-15. It is the probe of the model, not the expected fix.
- "after" (update end p95 over the last second + 0.3 ms, about vsync + 5.8, about 5.4 ms after the wake-up)
clears the update end in at least 95 % of refreshes by construction and moves with an episode's window. Its
open risk is the session 17 point (vsync + 6.9) 1.1 ms later.
## 6. The lock (built, default off)
Helper: frame/tools/vrtiming.py, when `KARTFRAME_END_PHASE` is set in its environment (the wrapper exports the
session.env lines to it), polls every 0.1 s instead of 0.25 s and publishes a 64-byte record (format in
vrtiming.py and openxr_integration.cpp, `EndPhaseGrid`) by atomic rename to `KARTFRAME_PHASE_FILE`, default
`$XDG_RUNTIME_DIR/kartframe-phase.bin`: the newest vsync in CLOCK_MONOTONIC ns (GetTimeSinceLastVsync, read
between two CLOCK_MONOTONIC reads), the refresh period (from the vsyncs of successive polls), update start
p5 / p50 and update end p50 / p95 over the last 120 compositor frames (no-update-time frames left out), and
the write time.
Game: the pacing thread re-reads the file at most every 50 ms (open, pread 64 bytes, close; about 10 us) and
aims the end at a phase of the vsync at or before the wake-up (1 ms slack):
- `KARTFRAME_END_PHASE=after[:us]` -> vsync + update end p95 + us (default 300),
- `KARTFRAME_END_PHASE=before[:us]` -> vsync + update start p50 - us (default 1500),
bounded to [now, wake-up + period - pacing margin]. It falls back to `KARTFRAME_END_DELAY_US` (or 4000 us, the
session 16/19 setting, when unset) whenever the record is missing, older than 0.5 s, or its period is more than
1 % off xrWaitFrame's, and logs `end phase:` once for the mode, at every switch between lock and fallback, and
every 10 s the achieved phase (p50 / p95 of the end minus the published update start and update end, the end
after the wake-up, and the share of fallback frames). The order is lane V's: the next set (locate, acquire,
encode) starts at the wake-up before the sleep, 4 eye pairs; if Aurora was busy and the set starts after the
end, in "before" mode that start waits until the published update end + 0.3 ms (bounded by the cycle's
deadline) so no acquire or locate lands in the window; both eyes are released back to back in the end call.
Headless check, results and session 20: frame/perf/session-h20/README.md.
+180
View File
@@ -0,0 +1,180 @@
# PANELS (lane J): the doubled image on head turns, from a panel recording
Session `$KARTFRAME_HOME/sessions/20261014-010727` (build `native` 7daa878: 120 Hz, pipelined, sysmem,
scene clock, an eye set every refresh), recorded with `KARTFRAME_RECORD=raw` (framecorder `--view raw`:
both panels as scanned out, 1920x1080, HEVC). The owner turned the head slowly left and right in a race:
"double image still when moving my head"; head still was fine.
## The recording
- 17019 frames over 171.5 s (the container's 141.8 s is frame count / 120). Each frame's time is the panel
vblank it was grabbed at (pts snap to the 8.33 ms grid within 0.3 ms), so consecutive refreshes are
known. The recorder missed refreshes while the GPU was loaded: menus 2 % of gaps, race 22 to 28 %
(framecorder: 90 to 100 fps, 3 ms of GPU a frame, headset GPU 93 %). Only pairs of consecutive
refreshes are compared. The recorder's GPU load is itself a suspect for the race's 13 % late sets
(session 20261013-232117, same build family, no recording: 6.3 %).
- Video time + about 5.9 s = the game's `vr pacing` t (race start, the t=36 hitch; framecorder started
01:07:48, the game's session at about 01:07:42).
Tools (`frame/perf/panels/`, no numpy or PIL: ffmpeg decodes, `audioop` does the per-pixel work in C):
`analyse.sh SESSION_DIR` runs `extract.sh` (per-eye 480x270 grey), `lagdiff.py` (RMS difference of a
central region against the 8 refreshes before, gray levels; codec noise of an identical image is 1.5 to
4, a new image 10 to 60), `motion.py` (global image shift per refresh from column/row projections) and
`cadence.py` (the summary below). About 4 minutes for this recording.
## a. Cadence per panel
Each refresh of each eye is "new", "held" (the previous refresh again) or "stale": pixel for pixel an
image the same eye showed 2 to 8 refreshes earlier while the previous refresh differed.
| part | eye | refreshes | new | held | stale (lag: count) |
| --- | --- | --- | --- | --- | --- |
| menus (0-25 s) | L | 2821 | 75.0 % | 25.0 % | 0 |
| | R | 2821 | 75.7 % | 24.3 % | 0 |
| race (25-171 s) | L | 10661 | 85.4 % | 4.1 % | **10.4 %** (2:36 3:141 4:880 5:53) |
| | R | 10661 | 90.1 % | 2.9 % | **7.0 %** (2:55 3:643 4:17 5:17) |
- Holds in the race: 1 refresh 10054 / 10220 (L/R), 2 refreshes 158 / 125, 3: 8 / 1. Held is only
visible while nothing moves on the panel (the compositor re-projects a held frame), so the retained
repeats of late sets (11 % of ticks in the game log) mostly show as "new" here.
- Both eyes: out of step in **19.4 %** of race refreshes (one eye stale while the other is new: L stale
1110, R stale 728); stale in both eyes at once 2 of 10661 (0.02 %). Menus: out of step 4.1 %, all of it
held/new on static menu content, never stale.
- The stale rate does not depend on motion: L 10.6 / 10.2 / 10.3 % and R 7.1 / 6.8 / 7.2 % of refreshes
with the image still / slow (0.5-2 px a refresh) / moving (2 px and more, up to 25 px). A stale image
matches the earlier one **at zero shift** while the view turns: it appears where it appeared 3 to 5
refreshes before, not where the world now is. The compositor did not re-project it from its own
pose: it was shown with a newer frame's pose.
## b. Within one frame
No doubled edges inside one panel image (full-resolution crops of lamp posts, fences, the screen edge
during turns: single sharp edges; `evidence/withinframe_L_48.45s_fullres.png`). Nothing is composited
twice: the double is temporal.
## c. Across frames
During a turn, an eye's sequence of positions runs smoothly and then, for one or two refreshes, jumps
back to where the image was 3 to 5 refreshes (25 to 42 ms) before, and forward again. Example (L,
48.4 s, image moving 2-3 px a refresh): refreshes 5814 and 5815 are refreshes 5810 and 5811 again
(RMS 3.1 and 2.7 against 16 to 30 for neighbours; `evidence/stale_event_48.4s_L5814-5815_shows_5810-5811.png`).
`motion.py`'s per-refresh shift alternates sign far more in the race than in menus (fraction of
refreshes whose acceleration reverses: menus 0 to 0.08, race L up to 0.25, R up to 0.52 a second).
Seen with a moving eye, an image that jumps back by 3 to 5 refreshes of head motion at about 10 % of
refreshes, in one eye at a time, is a second image of everything: the doubling.
## d. The virtual screen and the world
They move together: a stale image is the whole eye, screen included. The race timer on the screen
shows it frame by frame (L, 60.00-60.30 s): 1.178, 1.178, 1.197, 1.206, **1.178**, 1.206, 1.232,
1.232, 1.244, **1.232**, **1.232**, 1.244, 1.273, 1.273, 1.296, **1.273**, **1.273**, 1.296 ...; the right eye
at the same refreshes shows other game frames (1.167, 1.147, 1.178, 1.197, 1.197, 1.178, 1.206, 1.197,
1.206, 1.260, 1.244, ...), at times two game frames from the left.
## e. Against the logs
- Per second, the stale rate stays at 9 to 11 % (L) through the race, uncorrelated with the game's late
sets per second (r = -0.2 at any alignment from 2 to 9 s): late sets (repeats) are not the cause.
- What differs between menus (0 stale) and race (10 %): when frames are ended. SteamVR's "poses to frame
ready" (vrtiming.log) is 0.16 to 0.22 ms in menus and 3 to 6 ms in the race (max 8 to 10): in menus the
set finished during the previous refresh and was ended right at the wake-up; in the race the pipelined
loop ends it whenever the GPU finishes, anywhere up to 7.3 ms into the refresh.
- SteamVR's own counters stay clean (reused 0.5 %, mispresented 1.2 a second): SteamVR shows a new frame
nearly every refresh; it is the content of an eye that is old.
## Mechanism
Nothing in a set can make one eye pixel-identical to an image from 4 refreshes before: both eyes come
from one set, rendered and copied into their swapchain images in one command buffer from one locate.
The old images were images SteamVR still had in those swapchains. With two swapchain pairs alternating,
the pair a frame showed is acquired and written again one refresh later, while SteamVR, which predicts
each frame's display 3 refreshes (25 to 27 ms) after its wake-up, may not yet have composed that frame.
SteamVR evidently resolves each eye's image when it composes the eye (it also "shows a swapchain image
the moment it is released"), not when the frame was ended: depending on where in the refresh the next
acquire, render and release fall, an eye gets the image the frame was ended with, the swapchain's
newer one (2 refreshes ahead) or, while the newly acquired image is still being written, the one from
two uses before (4 refreshes behind, the stale images). Each eye is composed at its own moment, so the
eyes split. Ending sets at a fixed point right after the wake-up (menus here, GalaxyFrame always) keeps
whichever image SteamVR takes consistent, hence clean; ending them anywhere in the refresh (this race)
mixes them.
Candidates ruled out: PrimeFrame's 1-3 cadence (SteamVR's counters are clean, stale is not a cadence);
late sets shown a refresh after their poses' time and the retained repeats (those are 1-refresh holds,
3 to 4 % here, and the stale rate does not follow the late rate); interpolation time jitter (it cannot
make an image pixel-identical to an old one); two images composited in one frame (none seen).
## The mock reproduces it
`xrmock` (with `MOCK_XR_STAMP`) now logs every acquire, release (with the released image's stamp) and
xrEndFrame and at the end of the session replays a vsync grid against three models of when SteamVR
resolves an eye's image: `spec` (latched at xrEndFrame), `lazyrel` (the swapchain's newest released
image when the eye is composed) and `lazyacq` (its newest acquired image, which until its release still
holds its previous content); frames by display time, eyes composed 3 / 1 ms before the vsync
(`MOCK_XR_COMPOSITE_MS`). Lines "xrmock: compositor ..." and "xrmock: xrEndFrame after its xrWaitFrame
returned". The headset's end times need a loaded GPU: `KF_MOCKRUN_RENDER_SCALE=1.25` (measurement only)
puts the mock race at the headset's 4 to 5 % late and xrEndFrame 4 ms after the wake-up (median).
Scripted Grand Prix (`frame/perf/hudrace.sh NAME 90 MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 ...`), 7148 vsyncs:
| run | pairs | end after wake-up (<0.5 / 0.5-4 / >4 ms) | lazyacq: eye with its own poses L / R | shown again from 2-8 refreshes before L / R | eyes from different sets | lazyrel: again, split |
| --- | --- | --- | --- | --- | --- | --- |
| A2s (scale 1.25) | 2 | 4685 / 3039 / 2941 | 2.3 / 2.3 % (off -4: 1919 / 818, +2: 4216 / 5462) | **20.3 / 11.6 %** | **17.6 %** | 9.0 %, 17.6 % |
| B6s (scale 1.25) | 6 | 4777 / 3129 / 2469 | 100.0 / 100.0 % | 0 / 0 | 0.0 % | 0, 0.0 % |
| A2 (scale 1.0) | 2 | 9800 / 794 / 126 | 1.6 % (+2: 6110) | 0.2 / 0.2 % | 0.2 % | 0.2 %, 0.1 % |
| B6 (scale 1.0) | 6 | 9685 / 763 / 111 | 100.0 / 100.0 % | 0 / 0 | 0.0 % | 0, 0.0 % |
With two pairs and the headset's end times, `lazyacq` gives what the recording showed: one eye at a time
an image 4 (and 5) refreshes old, the left more than the right, eyes from different sets 17.6 % (recording
19.4 %). At the mock's own unloaded timing (ends at the wake-up) it is consistent (+2 refreshes, almost
no stale ones), like the menus and GalaxyFrame. `spec` never shows it, as the stamp check never did.
## The fix
`KARTFRAME_EYE_PAIRS` (default 6; 2..8): the per-refresh sets rotate through that many eye swapchain
pairs and a pair is acquired again only `pairs - 1` refreshes after the last frame that showed it (5:
beyond SteamVR's 3.3-refresh lead plus the refresh a frame stays on show). Under any of the three models
every eye then shows the image its frame's poses were rendered for. `=2` is the old alternation. A
refresh with no pair cooled off long enough renders nothing (`pairwait=` in the pacing line; 0 in every
run, the rotation never waits while a set is shown every refresh). The extra pairs are made with the
first per-refresh set (the render-first path, interpolation off, keeps two). Cost: 8 more 1728x1728
swapchains (about 290 MB with SteamVR's images), no GPU or CPU time (mock race: B6 116.1 sets/s, 0.4 %
late, GPU 4.61 ms; A2 118.8, 0.2 %, 4.60).
Checks, all runs: 0 errors, 19996 to 21258 releases compared after the GPU passed them and 0 changed,
13458 to 14030 stamped eye images all rendered for their submitted poses' locate, every xrEndFrame with
its predicted display time; ctest 32/32.
Also new: `vr pacing` lines end in `end_ms=<p50>/<p99> pairwait=<n>` (xrEndFrame after the wake-up);
`KARTFRAME_FRAME_LOG=1` writes one `XRF c= w= e= f= p= i= d= l=` line per frame (cycle, wake-up time,
end after it, fresh or retained, pair, released image index per eye, display step, display minus locate
in refreshes), to line a recording up with the frames; session-report.py shows the pairs and end times.
Not known: whether SteamVR is lazy in exactly this way (the mock is a model fitted to one recording);
whether 5 refreshes is enough margin on the headset (8 pairs is the next step); whether the recorder's
GPU load (3 ms a frame) is what pushed race end times to 3-6 ms in this session.
## Next headset session (three launches, under a minute each, each recorded)
Each launch: from the menus straight into a race (Time Trial or Grand Prix, any course). In the race,
look at a track-side edge (a post, the fence) and turn the head slowly left and right for about ten
seconds, then hold still for five, then once more. Say for each launch: doubled or not, which eye.
1. `session.env`: `KARTFRAME_EYE_PAIRS=2` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
`KARTFRAME_SESSION_ENV_ONCE=1`. The old build's behaviour: doubled on turns, as before.
Logs: game.log "OpenXR eye swapchain pairs: 2 (alternating ...)", XRF lines, `end_ms` p50 3 to 6 ms in
the race. `frame/perf/panels/analyse.sh <session>`: race stale about 10 % (L) and 7 % (R), eyes out of
step about 19 %, menus 0.
2. `session.env`: `KARTFRAME_EYE_PAIRS=6` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
`KARTFRAME_SESSION_ENV_ONCE=1` (6 is the default; set to make the variable explicit). Expected: no
doubling on turns, both eyes alike. Logs: "OpenXR eye swapchain pairs: 6 (a pair is written again 5
refreshes ...)", `pairwait=0` every second, late and `end_ms` like launch 1. analyse.sh: stale under
0.5 % in both eyes, eyes out of step only by held frames (under 3 %).
3. `session.env`: `KARTFRAME_EYE_PAIRS=8` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
`KARTFRAME_SESSION_ENV_ONCE=1`. Only if launch 2 still shows stale images (some, fewer than launch 1):
more margin. If launch 2 is clean, run launch 2's settings without `KARTFRAME_RECORD` instead, to see
whether the recorder's GPU load is what makes the race 13 % late.
How it decides: launch 2 clean and its stale rate near 0 against launch 1's 10 % -> six pairs stay the
default, the mechanism is confirmed. Launch 2 stale at launch 1's rate -> SteamVR does not resolve images
this way: line the XRF lines up with the recording (the stale refreshes against `e=`, `p=`, `i=`) to see
which image it took. Launch 2 fewer but some -> launch 3 (8 pairs) tells whether the margin is short.
+92
View File
@@ -0,0 +1,92 @@
# RACE120 (lane I): fitting the race into 120 Hz at 1728 per eye
All numbers: mock runtime (xrmock), real Turnip GPU, 1728x1728 per eye, 120 Hz, pipelined, TU_DEBUG=sysmem,
`hud_virtual_screen = true`, both build locks. SteamVR's own vrserver/vrcompositor were running (idle,
no app) during the A/B pairs and the soaks, as they always are beside the game in the headset; the SoC
ran at 92-95 C with the GPU at its top clock (880-903 MHz, devfreq). GPU ms are per eye set.
## How it was measured
- `KARTFRAME_FPSLOG=2` now times every set, not only the game frame's encode: a "game-frame set" (the
game frame's native render and its uploads) and each "refresh set" (both eyes, the panel, the copy into
the swapchain). Render passes carry pass timestamps; inside the eye passes Dawn's experimental in-pass
timestamps (requested only with FPSLOG=2) split them by draw class: world (scene), 2D on the virtual
screen (hud2d), full-view fades (fade), framebuffer effects (efbfx), clears; plus the uniform upload and
the sink (the eyes' way into the swapchain images). `frame/perf/gpuprof.py RUN [FIRST_S LAST_S]` makes
the table (ms per set, averaged over sets). The marks cost a little; the absolute times of
FPSLOG=2 runs are slightly high.
- The demo races pick their course at random, so two demo runs are not an A/B. `frame/perf/abgp.sh` runs
A and B interleaved (A B A B) on the scripted Grand Prix (frame/perf/hudrace.sh: Luigi Circuit, the same
every time), each with the GPU clock and the hottest thermal zone sampled (run.sh's gpufreq.tsv).
- Run-to-run and state-to-state spread is large: the same build's refresh set measured 4.7 ms on a cooler
machine early on and 7.1 ms at 93 C with the compositor busy. Only interleaved pairs count.
## Where a race set's GPU time went (before)
Demo, first 1-player race (t = 100-145 s), build `native` 2fe42c4 + the profiler only (run P0, cooler
machine, ms per set):
| set kind | sets | native (mono) | efb copies | snapshots | eye load+clear | scene | 2D on screen | full-view fade | upload | sink (copy) | sum |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| game-frame set (60 Hz) | 2701 | 0.93 | 0.04 | 0.03 | - | 0.01 | - | - | - | - | 1.07 |
| refresh set (120 Hz) | 5399 | - | - | - | 0.27 | 2.55 | 0.16 | 0.30 | 0.03 | 1.38 | 4.73 |
Hot machine, compositor running, scripted GP (AB4-A1, the copy as shipped): refresh set 7.16 ms = scene
3.67, sink 2.35, fade 0.37, 2D 0.28, load 0.40; game-frame set native 0.16 (cut already in) / 1.31 (AB5-A1,
whole native render). 21 % of race sets late.
Reading: the 2D layer on the screen is 56 to 58 draws a frame in these races (not ~400; that count was
menus) and costs 0.16 to 0.28 ms of a set. The copy into the swapchain, which nobody had timed, was the
largest item after the world itself. The native render drew the whole scene every game frame.
## Changes, each measured
| change | evidence (interleaved pairs, same clock/temperature) | saving |
| --- | --- | --- |
| Eyes reach the swapchain through an sRGB-decoding draw into an sRGB view; the swapchain images lose MUTABLE_FORMAT, so the driver may compress them (`KARTFRAME_SINK=copy` restores the copy) | AB4: sink 2.35 / 2.28 -> 0.47 / 0.48 ms; refresh set 7.16 / 7.08 -> 5.14 / 5.10; race late 21.3 / 19.4 % -> 1.6 / 1.6 %; sets/s 94.8 / 97.0 -> 118.4 / 118.5 | ~1.9 ms every refresh set |
| Native render of an immersive race frame (Linux too) stops after the last copy the eyes sample, clipped to the copies' rectangles (here a 6x4 depth copy at the sun for the lens flare, after the whole scene); the desktop window shows black in races (`KARTFRAME_MONO_FULL=1`, `KARTFRAME_NATIVE_CLIP=0`) | AB5 pair 1: native 1.31 -> 0.17 ms a game frame, race late 4.7 -> 2.5 % (pair 2's A run was stopped by the headset marker) | ~1.1 ms a game frame (every second set) |
| Eyes rendered straight into the swapchain images (no copy), chosen per session by measuring both with GPU timestamps on the first race | AB3 (copy into MUTABLE images): direct 7.20 / 7.02 vs copy 7.24 / 7.12 ms: the copy saved 2.2 ms but every eye pass got slower on the uncompressed image (load +0.9, fade +0.55, scene +0.5). With the sRGB sink the images are not mutable and direct is impossible; the trial never applies | none (kept for KARTFRAME_SINK=copy/blit) |
| HUD as a quad layer (task 2a) | not done: the 2D layer costs 0.16-0.28 ms a set; a quad layer would add its own copy every set and give up the per-eye clip/depth behaviour | - |
| Framebuffer effects per eye (task 2b) | efbfx is 0.00 in all the 1p races measured (MKW's race passes have no EFB-sampling 2D effect in the eye); nothing to cut | - |
| loadOp/storeOp (task 2c) | eyes are one render pass each (eye plan), clear+load segment 0.12-0.20 ms per eye: the clears of colour and depth/stencil, which are needed | - |
Full-view fade: one untextured blended quad a frame (0.27-0.39 ms a set) stays; lane I's 90 Hz fix
(H4.md) moved every textured full-frame quad off that path.
## After
Soak, 10 minutes at 120 Hz through the demo cycle, MOCK_XR_BUSY_MS=1, MOCK_XR_STAMP=1, compositor
running, GPU 890 MHz average, SoC up to 92 C (run SOAK120, lane/I 66da38a):
| part | s | sets/s | late % | GPU p50 / p99 | encode p50 / p99 | game fps |
| --- | --- | --- | --- | --- | --- | --- |
| menus | 428 | 120.4 | 0.02 | 2.45 / 3.05 | 0.58 / 1.19 | 60.0 |
| race 1p (2 races) | 84 | 119.6 | 0.81 | 4.52 / 6.56 | 2.36 / 3.30 | 59.3 |
| race 2p | 43 | 120.6 | 0.00 | 4.07 / 5.01 | 2.29 / 3.14 | 58.7 |
| race 4p (30 fps logic) | 42 | 115.9 | 3.83 | 5.32 / 6.53 | 1.96 / 3.19 | 29.7 |
Segments of the 1p race refresh set after: scene 2.95, sink 0.59, fade 0.27, 2D 0.17, load 0.25, upload
0.03 = 4.32 ms; game-frame set 0.64 (native 0.50: this soak's courses have a half-size scene copy for
the eyes, which needs the whole scene). Mock: 0 errors; 143278 releases checked, 0 unfinished; 40224
stamped eye images, all rendered for their poses' locate; end times 0 off.
Same soak at 90 Hz (MOCK_XR_HZ=90, KARTFRAME_REFRESH_HZ=90; GPU 674 MHz average, up to 90 C; SOAK90):
menus 90.5 sets/s, 0.02 % late; race 1p 90.5, 0.01 %, GPU 4.16 / 7.13; race 2p 90.6, 0 %; race 4p 90.6,
0 %. Mock: 0 errors, 107876 releases all finished, 30174 stamped eye images all for their poses' locate.
Menus are unchanged (the screen and environment images are still copied: 1.86 ms of a 2.47 ms set).
## The two stamp errors of the orchestrator's soak
Trace: `ERROR stamp: eye 0/1 image rendered for a display time 0.24 ms before the one its submitted poses
were located for`, one set. Not a pairing error: the game's DisplayTimeNanos turned the XrTime into
steady-clock ns as a delta between two clock reads (steady_clock, then CLOCK_MONOTONIC after the
conversion call); a preemption between them shifted that set's stamp (and its interpolation time) by
0.24 ms. On Linux the converted CLOCK_MONOTONIC time is steady_clock's own: it is returned as is now.
The mock filed an offset of less than a refresh (n = 0) under ">=+2"; it is "between" now.
## Switches
KARTFRAME_SINK=srgb (default) | copy | blit; KARTFRAME_DIRECT_EYES=0/1 (forces the direct trial's
outcome, copy/blit sinks only); KARTFRAME_MONO_FULL=1; KARTFRAME_NATIVE_CLIP=0; KARTFRAME_FPSLOG=2
(segment profile); MOCK_XR_MUTABLE=0 (mock: images without MUTABLE_FORMAT even when asked).
+197
View File
@@ -0,0 +1,197 @@
# Lane E measurements (headless mock, real Turnip GPU)
All runs: `frame/perf/run.sh NAME SECONDS VAR=value ...` from the worktree (mock runtime, private
Xvfb, scratch home `~/.cache/kf-laneE/home` with warm caches unless noted), then
`frame/perf/pacing-parts.py <run dir>` (per part, exact percentiles from KARTFRAME_PACING_SAMPLES) and
`frame/perf/hitches.py <run dir>`. Build: lane/E after bba3286 (SDL_SyncWindow fix, quad menus,
never half rate, HUD clip). Every row has `MOCK_XR_SKIP=1 TU_DEBUG=sysmem` (the wrapper's default)
and `"KF_MOCKRUN_VR=hud_virtual_screen = true"` (the owner's config; without it the race's 2D layer is
not reprojected); pacing is the default (auto = pipelined). Eyes 1728 per eye unless noted.
The attract-mode demo runs 1, 2 and 4 player races in turn (MKW runs 3 and 4 players at 30 fps).
"race" below is the 1-player race, the owner's case; the 10-minute soak also had a 2p and a 4p race,
listed apart. GPU and encode are ms per eye set (both eyes); sets/s per second of that part; late is
% of sets due; repeats % of compositor ticks.
## Table
| row | command (after `frame/perf/run.sh`) | part | s | sets/s | late % | repeats % | missed | GPU p50 / p99 | encode p50 / p99 | game fps |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| (a) shipped default, 10 min | `A-1728-120-soak 600 MOCK_XR_SKIP=1 TU_DEBUG=sysmem "KF_MOCKRUN_VR=hud_virtual_screen = true" MOCK_XR_BUSY_MS=1` | menus | 429 | 120.3 | 0.04 | 0.11 | 36 | 1.58 / 3.08 | 0.18 / 0.44 | 59.8 |
| | | race (2 races) | 85 | 119.5 | 0.72 | 0.78 | 9 | 5.19 / 6.37 | 2.27 / 3.72 | 58.3 |
| | | race 2p | 41 | 112.9 | 6.37 | 6.37 | 0 | 6.18 / 7.61 | 1.90 / 3.32 | 46.0 |
| | | race 4p | 42 | 120.6 | 0.02 | 0.02 | 0 | 4.32 / 5.23 | 1.78 / 2.83 | 30.5 |
| (b) 1728 tiled | not rerun (see below) | | | | | | | | | |
| (c) FDM layer off | not applicable headless (see below) | | | | | | | | | |
| (d) 2160 at 120 | `D-2160-120 200 ... KF_MOCKRUN_RENDER_SCALE=1.25` | menus | 157 | 120.1 | 0.07 | 0.28 | 11 | 2.10 / 3.25 | 0.21 / 0.67 | 60.6 |
| | | race | 42 | 93.9 | 22.15 | 22.15 | 0 | 7.68 / 9.43 | 2.55 / 4.13 | 51.1 |
| (e) 2160 at 90 | `E-2160-90 200 ... KF_MOCKRUN_RENDER_SCALE=1.25 MOCK_XR_HZ=90 KARTFRAME_REFRESH_HZ=90` | menus | 156 | 90.4 | 0.05 | 0.24 | 1 | 2.37 / 3.73 | 0.24 / 0.73 | 60.6 |
| | | race | 42 | 90.5 | 0.05 | 0.05 | 0 | 7.41 / 8.67 | 2.36 / 3.67 | 58.8 |
| (f) 1728 at 90 | `F-1728-90 200 ... MOCK_XR_HZ=90 KARTFRAME_REFRESH_HZ=90` | menus | 156 | 90.3 | 0.06 | 0.24 | 9 | 2.10 / 4.09 | 0.24 / 0.77 | 60.6 |
| | | race | 42 | 90.5 | 0.05 | 0.05 | 0 | 6.88 / 8.09 | 2.57 / 4.03 | 54.3 |
(a) mock checks: 91202 releases compared after the GPU passed them, 0 changed after release, 0 mock
errors; game ticks 71918 / rendered 71465 / late 411 / repeats 453 against the mock's 71956 ended,
401 repeated + 43 without a layer = 444 (the difference is the seconds before the first and after
the last pacing line). Menus are a quad layer now: 51813 of the 71956 frames.
Reading the table:
- The shipped default holds the 1-player race: 0.72 % late over 85 s of race, GPU p99 6.37 ms (ship
rule p99 <= 6.3 ms and late <= 2 %: late passes, p99 is at the line). The 2-player demo race is
the only part above 2 % late.
- 2160 per eye at 120 Hz does not hold (22 % late, GPU 7.7 ms a set against 8.3 ms a refresh). At
90 Hz it holds with room (0.05 % late, GPU p99 8.7 of 11.1 ms). It is the candidate if the owner
wants the panel's native resolution: 2160 at 90.
- GPU ms per set at 90 Hz is higher than at 120 Hz for the same 1728 eyes (6.9 against 5.2): the
GPU's clock follows its load (devfreq), so per-set times at different rates are not comparable;
sets/s and late are.
- Run-to-run noise is large. The same 1728 1-player race measured 4.6 to 6.1 ms GPU p50 and 0 to 4 %
late in back-to-back 135 s runs of one build (t8 to t11 in the scratch runs). A single 200 s row is
a direction, not a verdict; the 10-minute soak is the number to use.
- (b) and (c) were measured by the previous lane E runs on the pre-fix build (1728 tiled: race 90.5
sets/s, 1.5 % late, GPU 5.92 / 8.44; FDM switch: see below) and were not repeated: menus no longer
render eyes, and the race's game rate changed with the SDL fix.
- (c) Valve's FDM layer is not in the Vulkan chain headless: the startup line reads
`Vulkan layer chain (active, 1): VK_LAYER_FROG_gamescope_wsi_aarch64`. Its library is loaded, but
as the OpenXR API layer `XrApiLayer_VALVE_fdm_injection` in front of the mock. The A/B is only
meaningful in the headset (H1's chain: the loader skipped fdm_injection and rpo, "no
vkGetInstanceProcAddr").
## Game frame rate (the "half speed" of H1)
aurora's window::pump_events called SDL_SyncWindow every game frame; on X11 with a window whose
geometry SDL does not expect (Xvfb without a window manager, gamescope) it ends in SDL_Delay(10).
Same build, 1728, demo race: `KARTFRAME_SYNC_WINDOW=1` (old) 38.1 game fps, 72.9 sets/s, 3.7 %
late; fixed 59.8 game fps, 120.6 sets/s, 0.0 % late. H1's race ran at 38.5 game fps. Commit 57526ee.
`KARTFRAME_FPSLOG=1` logs the game rate every 5 s (run.sh sets it).
## Validation layer (VUID 08910)
`vvl2 140 MOCK_XR_SKIP=1 TU_DEBUG=sysmem MOCK_XR_BUSY_MS=1 VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation
"KF_MOCKRUN_VR=hud_virtual_screen = true"` (current build, HUD clip shaders included), menus and the
demo race (entered at frame 5869, left at 6792): 0 validation messages, 0 mock errors. Chain:
`VK_LAYER_FROG_gamescope_wsi_aarch64 VK_LAYER_KHRONOS_validation`. The layer prints to stdout, which
mockrun writes into the game log; lane A's run of the same setup showed VUID 08910 and 01387 there,
so the empty result is a real one. Fixed by 375e0e3 (TexCopyConv blit pipeline for RGBA8 targets)
and 1ab059b (VK_EXT_surface_maintenance1 on the instance). Nothing is left from our code.
## Hitches (run (a))
Game frames longer than 20 ms (aurora `hitch:` lines, end_frame to end_frame), with the cause the
frame's own counters point at:
| part | frames > 20 ms | 20-33 | 33-50 | 50-100 | 100-250 | pipelines | textures | disc | worker | no cpu | game cpu | blocked |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| menus (429 s) | 23 | 15 | 6 | 1 | 1 | 0 | 0 | 21 | 0 | 0 | 0 | 2 |
| race 1p (68 s after the first 10) | 317 | 315 | 1 | 1 | 0 | 0 | 0 | 3 | 16 | 1 | 161 | 136 |
| race 1p start (2 x 10 s) | 141 | 127 | 11 | 1 | 2 | 0 | 0 | 10 | 7 | 6 | 31 | 87 |
| race 2p | 796 | 781 | 15 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 771 | 25 |
| race 4p (30 fps by design) | 943 | 433 | 509 | 1 | 0 | 0 | 0 | 2 | 0 | 0 | 941 | 0 |
- Pipelines: none. With a warm cache no frame waited for a pipeline.
- 1p race: 8 % of game frames miss one 60 Hz slot (20 to 33 ms), half of them with the game thread
computing 13 ms or more of the frame itself ("game cpu": translated game logic plus GX recording),
the rest blocked in the game's own waits. VR interpolation hides these from the eyes (sets/s stays
at 119.5); they show as a game-frame repeat in motion.
- Race start: one 144 ms frame per race (course load: 4 MB read, 7.5 ms texture conversion) and a
52 ms one, both inside the fade-in.
- Per-tick (compositor) stalls: 9 missed refreshes in the 1p race seconds of the soak, 36 in 429 s
of menus (load screens).
Pipeline cache: the bundled seed `initial_pipeline_cache.db` (runtime/assets/pipeline, copied beside
the binary) is stale: its rows are GX config version 19 (2768 bytes) and clear version 3, the code is
at 22 and 4, so every row is skipped and a fresh install compiles everything at first use: 536 new
pipelines in hitch frames at the first race start of a cold home. See "Cold cache" below for what a
regenerated seed does.
## Half-rate rule
Commit fffb57c had changed the return rule (return early only when the GPU p90 at half rate is under
60 % of a refresh, otherwise after a retry wait doubling 10 to 160 s), because at 2160 and 120 Hz the
old rule cycled 10 s at half rate and 3 s at 40 % late. H1 then showed what any return rule does in
the headset: 16 drops and 15 returns in 4 minutes, 115 of the 143 race seconds at half rate, judder
and head-turn ghosting. Since b240caf the loop never drops by default (a late set repeats the
retained layer with its own poses and SteamVR reprojects it); KARTFRAME_HALF_RATE=auto drops after
more than 5 % late over 30 s and never comes back in the session; =1 always. Run (a): never
triggered (0 switches). Run (d), default: no switch, 22 % late shown as repeats.
## dlopen audit
`LD_DEBUG=libs` run (`ld-30s 30 ... LD_DEBUG=libs LD_DEBUG_OUTPUT=...`), game process only. The binary
needs libm, libstdc++, libgcc_s, libc, ld-linux (glibc 2.39, gcc-libs 15.1.1). Everything else is
dlopened: the OpenXR runtime (here the mock), the OpenXR API layer, the Vulkan loader and ICD, the
Vulkan layer, SDL's X11/dbus/udev back ends.
| library | package | installed |
| --- | --- | --- |
| ~/.cache/kf-xrmock/.../libxrmock.so (mock runtime; pulls libGLESv2, libEGL) | none (ours, test only) | built 2026-10-13 |
| ~/.local/share/Steam/steamrtarm64/libvulkan.so.1 | none: Steam runtime's Vulkan loader | file 2024-12-05 |
| ld-linux-aarch64.so.1, libc, libm | glibc 2.39-2 | 2026-09-15 |
| libstdc++, libgcc_s | gcc-libs 15.1.1 | 2026-09-15 |
| libcap | libcap 2.69-4 | 2026-09-15 |
| libdbus-1 | dbus 1.14.10-2 | 2026-09-15 |
| libexpat | expat 2.6.2-1 | 2026-09-15 |
| libffi | libffi 3.4.6-1 | 2026-09-15 |
| libsystemd, libudev | systemd-libs 257.7-2.2 | 2026-09-15 |
| libusb-1.0 | libusb 1.0.27-1 | 2026-09-15 |
| libz | zlib 1.3.1-1 | 2026-09-15 |
| libzstd | zstd 1.5.5-1 | 2026-09-15 |
| libdisplay-info | libdisplay-info 0.1.1-3 | 2026-10-07 |
| libdrm | libdrm 2.4.134-1 | 2026-10-07 |
| libEGL, libGLESv2, libGLdispatch | libglvnd 1.7.0-1 | 2026-10-07 |
| libSPIRV-Tools | spirv-tools 1.4.309.0-4 | 2026-10-07 |
| libVkLayer_FROG_gamescope_wsi_aarch64 | gamescope 3.16.28-2 | 2026-10-07 |
| libVkLayer_VALVE_fdm_injection (as the OpenXR API layer) | deckard-vulkan-layers-linux-aarch64 20260923.1-1 | 2026-10-07 |
| libvulkan_freedreno (Turnip) | deckard-mesa-linux-aarch64 26.3.0_devel+gitabc426bb-1 | 2026-10-07 |
| libwayland-client | wayland 1.26.0-1 | 2026-10-07 |
| libX11, libX11-xcb, libXau, libXcursor, libXdmcp, libXext, libXfixes, libXi, libXrandr, libXrender, libXtst, libxshmfence | libx11 1.8.9, libxau, libxcursor, libxdmcp, libxext, libxfixes, libxi, libxrandr, libxrender, libxtst, libxshmfence | 2026-10-07 |
| libxcb, libxcb-dri3/present/randr/shm/sync/xfixes | libxcb 1.17.0-1 | 2026-10-07 |
The 2026-10-07 dates are not the owner's installs: `/etc/os-release` has BUILD_ID=20261007.6125817,
and 850 of the 966 packages carry install times between 15:58 and 16:01 on 2026-10-07, packaged by
SteamOS's CI, while pacman.log has no transaction after 2026-10-02 (the only manual one there:
`pacman -S --needed base-devel git`). They are the stock image of the current SteamOS build; the
other 116 date from 2026-09-15. Nothing the game loads is an owner install.
Worth knowing: the shell (and Steam's launch environment) puts `~/.local/share/Steam/steamrtarm64`
on LD_LIBRARY_PATH, so the game uses the Steam runtime's Vulkan loader (2024-12-05), not the system
vulkan-icd-loader 1.4.309; every other library is tried there first and then found in /usr/lib.
## Cold cache and the pipeline seed
Same settings as row (a), 200 s, empty home (`KF_PERF_COLD=1`, own `KF_PERF_HOME`):
| run | seed | boot | race sets/s | late % | GPU p50 / p99 | game fps | race frames > 20 ms (pipelines) |
| --- | --- | --- | --- | --- | --- | --- | --- |
| C1-cold-stale-seed | bundled (v19 rows, all skipped) | no prewarm | 118.3 | 1.84 | 5.88 / 6.88 | 57.2 | 175 (0) |
| C2-cold-new-seed | regenerated (2346 v22 + 10 clear rows) | prewarm 2346 pipelines in 3.8 s, 4220 Dawn blob stores | 105.9 | 12.06 | 6.76 / 8.02 | 51.3 | 552 (0) |
Neither run has a game frame that waited for a pipeline: first-use compiles run on the compiler
workers and the draw is skipped until its pipeline is ready (a missing element for a frame or two,
not a stall). The C2 race is worse, but within the run-to-run spread measured above; the seed does
not show a pacing benefit in the mock. It does remove the first-use compiles (the old cold run
compiled 536 pipelines at the first race start) at a cost of 3.8 s of background compiling at the
first boot. How it was produced (current GX config version 22, clear version 4):
cp build-linux/initial_pipeline_cache.db seed.db
sqlite3 seed.db "attach 'file:$HOME/.cache/kf-laneE/home/data/WiiCompiled/Cache/pipeline_cache.db?immutable=1' as u;
delete from pipeline_cache;
insert into pipeline_cache select * from u.pipeline_cache
where (type=0 and config_version=4) or (type=1 and config_version=22);
detach u; vacuum;"
from a home that ran menus and the demo races (1, 2 and 4 players, several courses). Shipping it means
replacing runtime/assets/pipeline/initial_pipeline_cache.db (not a lane E file); it goes stale again
whenever GXPipelineConfigVersion changes.
## Other checks
- Refresh request: `t16-rates90 40 MOCK_XR_SKIP=1 TU_DEBUG=sysmem MOCK_XR_RATES=90` (the mock offers
only 90, as SteamVR did in H1): `display refresh rates: 90; 120 Hz not offered, requested none
(SteamVR's per-app setting decides)`, no request in the mock trace.
- KARTFRAME_HALF_RATE=auto: `D2-2160-120-auto 200 ... KF_MOCKRUN_RENDER_SCALE=1.25 KARTFRAME_HALF_RATE=auto`
dropped once, 23 s into the race ("181 of the last 3600 eye sets late; rendering every second refresh
for the rest of the session"), and stayed there.
- Intro (first-run welcome and controller guide shown, `KF_MOCKRUN_INTRO=1`, 90 s, projected): GPU
2.10 / 2.87 ms a set with TU_DEBUG=sysmem, 2.06 / 2.70 tiled. sysmem does not make the guide
expensive in the mock.
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# abgp.sh PREFIX ROUNDS SECONDS "A env..." "B env..." [common env...]: A/B on the scripted Grand Prix (Luigi Circuit)
# Each run: frame/perf/hudrace.sh (deterministic: the same course every time, unlike the demo races),
# KARTFRAME_FPSLOG=2 segments (gpuprof.py), pacing (pacing-parts.py), GPU clock and temperature
# (run.sh gpufreq.tsv). Interleave A and B so they share the clock and thermal state (lane I).
P=$1; R=$2; S=$3; A=$4; B=$5; shift 5
HERE=$(cd "$(dirname "$0")" && pwd); cd "$HERE/../.."
for r in $(seq 1 $R); do for v in A B; do
E=$A; [ $v = B ] && E=$B
KF_PERF_OUT=${KF_AB_DIR:-$HOME/.cache/kf-ab}/runs KF_PERF_HOME=${KF_AB_DIR:-$HOME/.cache/kf-ab}/home-race frame/perf/hudrace.sh $P-$v$r $S KARTFRAME_FPSLOG=2 MOCK_XR_DUMP_AT=999 $E "$@" > /dev/null 2>&1
d=${KF_AB_DIR:-$HOME/.cache/kf-ab}/runs/$P-$v$r
echo "== $P-$v$r ($E)"
python3 frame/perf/pacing-parts.py $d | grep '| race '
python3 frame/perf/gpuprof.py $d | grep -E '^. part|race 1p' | cut -c1-330
grep -a 'VR eyes:' $d/game.log | cut -c40-260
awk 'NR==1{t0=$1} {t=$1-t0; if(t>50){n++; f+=$2; if($4>tm)tm=$4}} END{printf "gpu MHz avg %.0f, max temp %.1f C\n", f/n/1e6, tm/1000}' $d/gpufreq.tsv
done; done
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Background CPU load for timing runs (lane H): frame/perf/bgload.sh SECONDS [JOBS] [NICE]
#
# JOBS (default 4) loops compiling the mock runtime's C file with clang -O2, at nice NICE (default 5,
# as kfbuild.sh builds), for SECONDS, the way a build of another project loads the Frame's cores and
# memory. Nothing is written outside a scratch directory under ~/.cache/kf-laneH.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
SECS=${1:?seconds}; JOBS=${2:-4}; NICE=${3:-5}
SRC=$HERE/../tools/xrmock/xrmock.c
OUT=$HOME/.cache/kf-laneH/bgload; mkdir -p "$OUT"
INC=${OPENXR_INCLUDE:-/usr/include}
end=$((SECONDS + SECS))
for j in $(seq "$JOBS"); do
( while [ $SECONDS -lt $end ]; do
nice -n "$NICE" clang -O2 -c "$SRC" -I "$INC" -I "$HERE/../tools/xrmock" -o "$OUT/load$j.o" 2>/dev/null || sleep 1
done ) &
done
wait
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Where a set's GPU time goes, by part of the demo cycle: gpuprof.py RUN_DIR [FIRST_S LAST_S] (lane I).
Reads the "GPU ms per game-frame sets / refresh sets" lines KARTFRAME_FPSLOG=2 writes every 5 s (in-pass
timestamps: gfx::gpu_timing_mark) and averages each segment per set over a part (menus, race 1p/2p/4p,
pacing-parts.py's rule) or over the pacing seconds FIRST_S..LAST_S. Prints a markdown table: ms per set
for game-frame sets (native render + an eye set) and refresh sets (an eye set alone), plus the span
p50/p99 (first to last timestamp of the set's GPU work)."""
import re
import sys
LINE = re.compile(r"GPU ms per (game-frame sets|refresh sets) \((\d+)\): span p50/p99 ([\d.]+)/([\d.]+)((?: [\w-]+=[\d.]+)*)")
PACING = re.compile(r"vr pacing: t=(\d+) ")
def main():
d = sys.argv[1]
window = (int(sys.argv[2]), int(sys.argv[3])) if len(sys.argv) > 3 else None
parts = {}
screens, in_race, t = 1, False, 0
for line in open(f"{d}/game.log", errors="replace"):
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
if m:
screens = int(m.group(1))
if "entered RaceScene" in line or "presentation=immersive-race" in line:
in_race = True
elif "exited RaceScene" in line or "presentation=virtual-screen" in line or "presentation=desktop" in line:
in_race = False
m = PACING.search(line)
if m:
t = int(m.group(1))
for chunk in line.split("; "):
m = LINE.search(chunk)
if not m:
continue
if window:
if not window[0] <= t <= window[1]:
continue
part = f"t={window[0]}-{window[1]}"
else:
part = "menus" if not in_race else ("race 1p" if screens <= 1 else f"race {screens}p")
kind, n = m.group(1), int(m.group(2))
p = parts.setdefault((part, kind), {"n": 0, "seg": {}, "p50": [], "p99": []})
p["n"] += n
p["p50"].append(float(m.group(3)))
p["p99"].append(float(m.group(4)))
for kv in m.group(5).split():
k, v = kv.split("=")
p["seg"][k] = p["seg"].get(k, 0.0) + float(v) * n
keys = []
for p in parts.values():
for k in p["seg"]:
if k not in keys:
keys.append(k)
print("| part | sets | sets timed | span p50 | span p99 (worst 5 s) | sum | " + " | ".join(keys) + " |")
print("| --- | --- | --- | --- | --- | --- | " + " | ".join("---" for _ in keys) + " |")
for (part, kind), p in sorted(parts.items()):
n = max(p["n"], 1)
segs = [p["seg"].get(k, 0.0) / n for k in keys]
mid = sorted(p["p50"])[len(p["p50"]) // 2]
print(f"| {part} | {kind} | {p['n']} | {mid:.2f} | {max(p['p99']):.2f} | {sum(segs):.2f} | "
+ " | ".join(f"{v:.2f}" for v in segs) + " |")
main()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Game-frame hitches of a run: hitches.py RUN_DIR [RACE_START_WINDOW_S] (lane E).
Reads aurora's "hitch:" lines (one per game frame longer than 20 ms, end_frame to end_frame, with what
the process did meanwhile; aurora-main/lib/gfx/hitch_stats.hpp), "vr pacing clock" (the session start
on CLOCK_MONOTONIC), the pacing seconds around "entered/exited RaceScene", and io.tsv (iosample.py:
read bytes and newly opened game files every 50 ms). Prints histograms for menus, the race and the
first N seconds (10) of each race, and the frames with their likely cause:
pipelines blocked on or compiling pipelines (pipeline_wait + inline compiles) for most of the excess
textures CPU texture conversion for most of the excess
disc game data read in the frame (rchar, newly opened files under DATA/files)
worker waiting for the frame worker (Aurora's encode of the previous frame) or its GPU lock
no cpu the game thread was runnable but waiting for a CPU (runq_ms) for most of the excess
aurora begin inside aurora_begin_frame (frame-worker preparation) for most of the excess
game cpu the game thread computed for 80 % of a 60 Hz frame or more (game logic, GX recording)
blocked none of these: the thread slept elsewhere (the game's own waits: VI retrace, threads)
other older logs without game_cpu_ms: none of the above."""
import bisect
import os
import re
import sys
CAUSES = ("pipelines", "textures", "disc", "worker", "no cpu", "aurora begin", "game cpu", "blocked", "other")
BUCKETS = [(20, 33), (33, 50), (50, 100), (100, 250), (250, 1000), (1000, 1e9)]
HITCH = re.compile(r"hitch: mono=([\d.]+) frame_ms=([\d.]+)(?: game_cpu_ms=([\d.]+))?(?: runq_ms=([\d.]+) begin_ms=([\d.]+))? worker_wait_ms=([\d.]+) pipelines_new=(\d+) pipeline_wait_ms=([\d.]+) "
r"pipeline_inline=(\d+) pipeline_inline_ms=([\d.]+) tex_convert_ms=([\d.]+) tex_convert_kb=(\d+) tex_upload_kb=(\d+)(?: begin_joins=(\d+))?")
def load(d):
hitches, races, start = [], [], None
t_last, race_open, screens = 0, None, {}
with open(os.path.join(d, "game.log"), errors="replace") as f:
for line in f:
m = re.search(r"vr pacing clock: session start mono=([\d.]+)", line)
if m and start is None:
start = float(m.group(1))
m = re.search(r"vr pacing: t=(\d+) ", line)
if m:
t_last = int(m.group(1))
if "entered RaceScene" in line:
race_open = t_last
elif "exited RaceScene" in line and race_open is not None:
races.append((race_open, t_last + 1))
race_open = None
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
if m and race_open is not None:
screens[race_open] = int(m.group(1))
m = HITCH.search(line)
if m:
v = [float(x) if x is not None else -1.0 for x in m.groups()]
hitches.append(dict(zip(("mono", "ms", "cpu", "runq", "begin", "worker", "pnew", "pwait", "pinl", "pinlms", "tex", "texkb", "upkb", "joins"), v)))
if race_open is not None:
races.append((race_open, 1e9))
# Split-screen demo races (2 and 4 players) are reported apart: the owner races alone.
races = [(a, b, screens.get(a, 1)) for a, b in races]
io = []
p = os.path.join(d, "io.tsv")
if os.path.exists(p):
for line in open(p):
if line.startswith("#"):
continue
c = line.rstrip("\n").split("\t")
io.append((float(c[0]), int(c[1]), int(c[2]), c[3] if len(c) > 3 else ""))
return hitches, races, start, io
def io_between(io, a, b):
"""Bytes read and files opened between monotonic times a and b (50 ms samples, so widened by one)."""
if not io:
return 0, []
times = [x[0] for x in io]
i = max(bisect.bisect_left(times, a) - 1, 0)
j = min(bisect.bisect_right(times, b), len(io) - 1)
files = [f for x in io[i + 1:j + 1] for f in x[3].split(",") if f]
return io[j][1] - io[i][1], files
def cause(h, rbytes, files):
excess = h["ms"] - 16.7
pipe = h["pwait"] + h["pinlms"]
if files or rbytes > 256 * 1024:
return "disc"
if pipe >= 0.5 * excess:
return "pipelines"
if h["tex"] >= 0.5 * excess:
return "textures"
if h["worker"] >= 0.5 * excess:
return "worker"
if h["runq"] >= 0.5 * excess:
return "no cpu"
if h["begin"] >= 0.5 * excess:
return "aurora begin"
if h["cpu"] >= 0.8 * 16.7:
return "game cpu"
return "blocked" if h["cpu"] >= 0 else "other"
def main(d, window, limit=60):
hitches, races, start, io = load(d)
if start is None:
print("no 'vr pacing clock' line; is this a run of a build with lane E's diagnostics?")
return 1
groups = {"menus": [], "race": [], "race start (first %ds)" % window: []}
for tag in sorted({" %dp" % n for _, _, n in races if n > 1}):
groups["race" + tag] = []
groups["race start (first %ds)" % window + tag] = []
rows = []
for h in hitches:
t = h["mono"] - start
part = "menus"
for a, b, n in races:
if a <= t < b:
tag = "" if n <= 1 else " %dp" % n
part = ("race start (first %ds)" % window if t - a < window else "race") + tag
rb, files = io_between(io, h["mono"] - h["ms"] / 1000.0, h["mono"])
c = cause(h, rb, files)
groups[part].append((h, c))
rows.append((t, part, h, c, rb, files))
print("races at session seconds: %s" % ", ".join("%d-%s (%dp)" % (a, "end" if b >= 1e9 else int(b), n) for a, b, n in races))
print()
print("| part | frames > 20 ms | " + " | ".join("%d-%s ms" % (a, "" if b >= 1e9 else int(b)) for a, b in BUCKETS) +
" | " + " | ".join(CAUSES) + " |")
print("| --- " * (len(BUCKETS) + 2 + len(CAUSES)) + "|")
for name, items in groups.items():
hist = [sum(1 for h, _ in items if a <= h["ms"] < b) for a, b in BUCKETS]
causes = [sum(1 for _, c in items if c == k) for k in CAUSES]
print("| %s | %d | %s | %s |" % (name, len(items), " | ".join(map(str, hist)), " | ".join(map(str, causes))))
print()
print("frames over 33 ms (t = session seconds):")
shown = 0
for t, part, h, c, rb, files in rows:
if h["ms"] >= 33 and shown < limit:
shown += 1
print(" t=%.1f %s %.0f ms (cpu %.0f, runq %.0f, begin %.0f): %s; worker %.1f, pipelines new %d wait %.1f inline %d/%.1f ms, tex %.1f ms %d KiB, "
"upload %d KiB, read %d KiB%s" % (t, part, h["ms"], h["cpu"], h["runq"], h["begin"], c, h["worker"], h["pnew"], h["pwait"], h["pinl"], h["pinlms"],
h["tex"], h["texkb"], h["upkb"], rb // 1024,
(", opened " + " ".join(files[:4])) if files else ""))
return 0
if __name__ == "__main__":
if not sys.argv[1:]:
print(__doc__)
sys.exit(1)
sys.exit(main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 10))
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# A real race in the mock, with the owner's HUD settings, and eye images to look at (lane G).
#
# frame/perf/hudrace.sh NAME [SECONDS] [VAR=value ...]
#
# Scripted presses of the right A button (MOCK_XR_PRESS) walk from the title screen through license,
# Grand Prix, character, kart and cup to Luigi Circuit (about 30 s in; the scratch home keeps the
# license, so later runs are a little faster); the race HUD then sits on the virtual screen
# (hud_virtual_screen = true, hand_hud = false, as the owner plays). Eye images are written at 65 and
# 75 s (two frames each, every 4th pixel) and converted to PNG in the run directory's dump/.
# Look at them: TIME, LAP, the position and the course map must be on the screen in front of the kart.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
NAME=${1:?name}; SECS=${2:-80}; shift; [ $# -gt 0 ] && shift
OUT=${KF_PERF_OUT:-$HOME/.cache/kf-laneH/runs}
DUMP=$OUT/$NAME/dump
export KF_PERF_OUT=$OUT KF_PERF_HOME=${KF_PERF_HOME:-$HOME/.cache/kf-laneH/home-race}
TMP=$OUT/.$NAME-dump; rm -rf "$TMP"; mkdir -p "$TMP" # run.sh empties the run directory first
bash "$HERE/run.sh" "$NAME" "$SECS" MOCK_XR_SKIP=1 TU_DEBUG=sysmem "KF_MOCKRUN_VR=hud_virtual_screen = true
hand_hud = false" "MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5" MOCK_XR_HANDS=1 \
MOCK_XR_DUMP=$TMP MOCK_XR_DUMP_AT=65,75 MOCK_XR_DUMP_FRAMES=2 "$@" || true
mkdir -p "$DUMP"
mv "$TMP"/*.ppm "$TMP"/*.pgm "$DUMP"/ 2>/dev/null || true; rm -rf "${TMP:?}"
# PPM (RGB) and, with MOCK_XR_DUMP_ALPHA=1, PGM (alpha) to PNG.
python3 -I - "$DUMP"/*.ppm "$DUMP"/*.pgm <<'PY'
import os
import sys, zlib, struct
for src in sys.argv[1:]:
if not os.path.exists(src): continue
data = open(src, 'rb').read()
head = data.split(b'\n', 3)
w, h = map(int, head[1].split())
n = 1 if head[0] == b'P5' else 3
px = data[len(data) - w * h * n:]
raw = b''.join(b'\0' + px[y * w * n:(y + 1) * w * n] for y in range(h))
chunk = lambda t, d: struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)
open(src[:-4] + '.png', 'wb').write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2 if n == 3 else 0, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b''))
PY
rm -f "${DUMP:?}"/*.ppm "${DUMP:?}"/*.pgm
grep -a 'entered RaceScene\|screen clip self-check' "$OUT/$NAME/game.log" | tail -3
echo "hudrace: eye images in $DUMP"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Samples the game's read I/O every 50 ms: KF_IOSAMPLE_EXE=EXE iosample.py OUT.tsv (lane E, frame/perf/run.sh).
Lines: CLOCK_MONOTONIC seconds, rchar (bytes read by read()-type calls, cached or not), read_bytes
(bytes that came from the block device), and the game data files newly open since the last sample
(paths under a DATA/files directory). Waits for a process of EXE that starts after the sampler."""
import os
import sys
import time
def find_game(exe, before):
for pid in os.listdir("/proc"):
if pid.isdigit() and pid not in before:
try:
if os.readlink("/proc/%s/exe" % pid) == exe:
return pid
except OSError:
pass
return None
def main(out, exe):
exe = os.path.realpath(exe)
before = set(p for p in os.listdir("/proc") if p.isdigit())
pid = None
while pid is None:
pid = find_game(exe, before)
if pid is None:
time.sleep(0.2)
seen = set()
with open(out, "w") as f:
f.write("# pid %s\n# mono\trchar\tread_bytes\tnew_files\n" % pid)
while True:
try:
io = dict(l.split(": ") for l in open("/proc/%s/io" % pid).read().split("\n") if l)
files = set()
for fd in os.listdir("/proc/%s/fd" % pid):
try:
p = os.readlink("/proc/%s/fd/%s" % (pid, fd))
except OSError:
continue
if "/files/" in p:
files.add(p.split("/files/", 1)[1])
except (OSError, ValueError):
return
new = sorted(files - seen)
seen = files
f.write("%.3f\t%s\t%s\t%s\n" % (time.monotonic(), io.get("rchar", "0"), io.get("read_bytes", "0"), ",".join(new)))
f.flush()
time.sleep(0.05)
if __name__ == "__main__":
main(sys.argv[1], os.environ["KF_IOSAMPLE_EXE"])
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Consecutive eye images of a scripted mock race (lane S): frame/perf/mockdump/dumprace.sh NAME [VAR=value ...]
#
# hudrace.sh's Grand Prix to Moo Moo Meadows with: menu taps until 46 s (they also skip the course intro),
# the head still until 51 s and then swaying 25 deg with a 1.5 s period (MOCK_XR_SWAY), A held from 56 s
# (driving), and 240-set windows of both eyes' released images (MOCK_XR_DUMP_QUEUE: no stalls) at
# 48.5 s (kart and head still: the noise floor), 52.5 s (kart standing, head swaying), 58.5, 62.5 and
# 66.5 s (driving). Images in $KF_DUMP_DIR (default ~/.cache/kf-laneS/dump-NAME), 432 px across
# (render scale 0.75, every 3rd pixel, linear filter: the game runs its race at 60 fps, as on the headset;
# at 1.0 the mock's race drops to 50-53 fps and the scene clock clamps). Then:
# python3 -I frame/perf/mockdump/zigzag.py <dump dir>
# Env: SECS (70), DUMP_AT (the window times), KF_PERF_OUT / KF_PERF_HOME as for run.sh.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
NAME=${1:?name}; shift
DD=${KF_DUMP_DIR:-$HOME/.cache/kf-laneS/dump-$NAME}
rm -rf "${DD:?}"; mkdir -p "$DD"
export KF_PERF_OUT=${KF_PERF_OUT:-$HOME/.cache/kf-laneS/runs} KF_PERF_HOME=${KF_PERF_HOME:-$HOME/.cache/kf-laneS/home-race}
bash "$HERE/../hudrace.sh" "$NAME" "${SECS:-70}" MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1 \
"MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5/0.15/46;/user/hand/right/input/a/click@56/1000/1000" \
MOCK_XR_SWAY=25,1.5,51 KF_MOCKRUN_RENDER_SCALE=0.75 MOCK_XR_DUMP="$DD" MOCK_XR_DUMP_AT="${DUMP_AT:-48.5,52.5,58.5,62.5,66.5}" \
MOCK_XR_DUMP_FRAMES=80 MOCK_XR_DUMP_STEP=3 MOCK_XR_DUMP_QUEUE=512 "$@"
echo "dumprace: $(wc -l < "$DD/index.txt") images in $DD"
+221
View File
@@ -0,0 +1,221 @@
# zigzag.py DUMPDIR [X0 Y0 W H]: the panel zigzag test (H7 section 1, session-h12 zig2.py) on mock eye images.
# DUMPDIR: MOCK_XR_DUMP output with index.txt (xrmock with MOCK_XR_STAMP: eye and display time per image).
# Per dump window (t<seconds> prefix) and eye: the global image shift between consecutive sets from column
# projections of a region of the upper image (motion.py's fit), the position track, H7's zigzag rule on
# sets n-1, n, n+1 one refresh apart (moving: |speed| >= 2 px a refresh over n-4..n+5; zigzag: |pos(n) -
# mean(pos(n-1), pos(n+1))| >= 1 px and >= 0.75 refresh of motion), the lag-1 autocorrelation of that
# deviation (period 2: near -1), the two eyes' deviations against each other (in phase: +1, against: -1),
# and a lagdiff: per set the RMS difference of a central region against the same eye's set 1 and 2 before,
# plus how often the image sits where set n-2 was (|pos(n) - pos(n-2)| < 0.25 px while moving).
# Pixels: the dump's (MOCK_XR_DUMP_STEP 4: 432 px across 1728).
import os, sys, audioop, collections, statistics, warnings
warnings.filterwarnings('ignore')
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'panels'))
from pgray import to16, rmsdiff16
D = sys.argv[1]
M = 24
def ppm_gray(path):
data = open(path, 'rb').read()
head = data.split(b'\n', 3)
w, h = map(int, head[1].split())
return w, h, data[len(data) - w * h * 3:][1::3] # green channel
def region(img, W, x0, y0, w, h):
return b''.join(img[(y0 + y) * W + x0:(y0 + y) * W + x0 + w] for y in range(h))
def cols(reg, w, h):
c = [0] * w
for y in range(h):
r = reg[y * w:(y + 1) * w]
for x in range(w):
c[x] += r[x]
return c
def to16v(vals):
m = sum(vals) / len(vals)
s = max(1.0, max(abs(v - m) for v in vals))
return b''.join(int((v - m) * 12000 / s).to_bytes(2, 'little', signed=True) for v in vals)
def fit(prev, cur):
n = len(cur)
ref = to16v(cur[M:n - M])
frag = to16v(prev)
off, _ = audioop.findfit(frag, ref)
def err(o):
if o < 0 or o > 2 * M:
return None
part = frag[2 * o:2 * (o + n - 2 * M)]
f = audioop.findfactor(part, ref)
return audioop.rms(audioop.add(part, audioop.mul(ref, 2, -f), 2), 2)
e0, em, ep = err(off), err(off - 1), err(off + 1)
sub = 0.0
if em is not None and ep is not None and em + ep - 2 * e0 > 0:
sub = 0.5 * (em - ep) / (em + ep - 2 * e0)
return (M - off) - sub
def corr(a, b):
if len(a) < 3:
return float('nan')
ma, mb = statistics.fmean(a), statistics.fmean(b)
sa = sum((x - ma) ** 2 for x in a) ** 0.5
sb = sum((y - mb) ** 2 for y in b) ** 0.5
return sum((x - ma) * (y - mb) for x, y in zip(a, b)) / (sa * sb) if sa > 0 and sb > 0 else float('nan')
# index.txt: file sc frame eye displayns
wins = collections.defaultdict(dict) # window -> eye -> {display ns: file}
scene = collections.defaultdict(dict) # window -> {display ns: (weight, previous, current game frame)} (left eye)
for line in open(os.path.join(D, 'index.txt')):
parts = line.split()
f, sc, fr, eye, val = parts[:5]
if int(eye) < 0 or not os.path.exists(os.path.join(D, f)):
continue
wins[f.split('-')[0]].setdefault('LR'[int(eye)], {})[int(val)] = f
if len(parts) >= 7 and int(eye) == 0 and (int(parts[5]) >> 48) & 1:
a = int(parts[5])
scene[f.split('-')[0]][int(val)] = ((a & 0xffff) / 65535.0, (a >> 16) & 0xffff, (a >> 32) & 0xffff)
PERIOD = 1e9 / float(os.environ.get('MOCK_XR_HZ', '120'))
def rule(pos):
"""H7's zigzag rule on a position track {refresh: px}: (tests, zigzags, mean |dev| in refreshes of motion,
lag-1 autocorrelation of the deviation, speed median, |dev| p95 over all sets, deviations by refresh)."""
tests = zz = 0
amps, z, vs, allz = [], {}, [], []
for n in sorted(pos):
if n - 1 not in pos or n + 1 not in pos:
continue
zp = pos[n] - 0.5 * (pos[n - 1] + pos[n + 1])
allz.append(abs(zp))
ks = [k for k in range(n - 4, n + 6) if k in pos]
if ks[-1] - ks[0] < 4:
continue
v = (pos[ks[-1]] - pos[ks[0]]) / (ks[-1] - ks[0])
vs.append(abs(v))
if abs(v) < 2:
continue
tests += 1
z[n] = zp
if abs(zp) >= 1 and abs(zp / v) >= 0.75:
zz += 1
amps.append(abs(zp / v))
a1 = [(z[k], z[k + 1]) for k in sorted(z) if k + 1 in z]
q = sorted(allz)
return dict(tests=tests, zz=zz, amp=statistics.fmean(amps) if amps else 0.0, ac1=corr([x for x, _ in a1], [y for _, y in a1]),
speed=statistics.median(vs) if vs else 0.0, p95=q[int(0.95 * (len(q) - 1))] if q else float('nan'),
ge1=sum(1 for x in q if x >= 1), n=len(q), z=z)
def track(items, profs, axis):
pos, p, prevn = {}, 0.0, None
for n, _ in items:
if prevn is not None:
p += fit(profs[prevn][axis], profs[n][axis])
pos[n] = p
prevn = n
return pos
for win in sorted(wins):
eyes = wins[win]
if 'L' not in eyes or 'R' not in eyes:
continue
v0 = min(min(eyes['L']), min(eyes['R']))
tracks, lag, btw = {}, {}, {}
for e in 'LR':
items = sorted((round((v - v0) / PERIOD), f) for v, f in eyes[e].items())
if os.environ.get('ZZ_INJECT') and e == 'L':
# Positive control: every odd set of the left eye shows the image of k sets before (the panels'
# zigzag: every second refresh about two refreshes of motion off the track).
k = int(os.environ['ZZ_INJECT'])
byn = dict(items)
items = [(n, byn[n - k] if n % 2 and n - k in byn else f) for n, f in items]
profs, cen, low = {}, {}, {}
for n, f in items:
W, H, g = ppm_gray(os.path.join(D, f))
# view: columns of the sky, hills and track edge (head and kart turning; clear of the stamp);
# left / right: columns of the track-side strips at the horizon (fences, banners): forward motion
# moves them apart (scene: (right - left) / 2), turning moves them together.
x0, y0, rw, rh = W // 10, H // 12, W * 8 // 10, H * 3 // 10
sy0, sh, sw = H * 28 // 100, H * 18 // 100, W * 30 // 100
profs[n] = (cols(region(g, W, x0, y0, rw, rh), rw, rh), cols(region(g, W, 2, sy0, sw, sh), sw, sh),
cols(region(g, W, W - 2 - sw, sy0, sw, sh), sw, sh))
cen[n] = to16(region(g, W, W // 4, H // 8, W // 2, H // 4))
# Betweenness regions (any motion, also forward): the ground left and right of the kart.
low[n] = (to16(region(g, W, 2, H * 55 // 100, W * 30 // 100, H * 40 // 100)),
to16(region(g, W, W - 2 - W * 30 // 100, H * 55 // 100, W * 30 // 100, H * 40 // 100)))
tl, tr = track(items, profs, 1), track(items, profs, 2)
tracks[e] = {'view': track(items, profs, 0), 'scene': {n: 0.5 * (tr[n] - tl[n]) for n in tl}}
lag[e] = {n: (rmsdiff16(cen[n], cen[n - 1]) if n - 1 in cen else None, rmsdiff16(cen[n], cen[n - 2]) if n - 2 in cen else None) for n in cen}
# Betweenness r(n) = D(n-1, n+1) / mean(D(n, n-1), D(n, n+1)) per region (RMS differences, no motion
# compensation): smooth motion > 1 (two steps differ more than one); an image displaced about two
# refreshes off the track (the panels' zigzag) < 1.
btw[e] = {}
for name, get in (('upper', lambda n: cen[n]), ('ground L', lambda n: low[n][0]), ('ground R', lambda n: low[n][1])):
rs = {}
for n in cen:
if n - 1 in cen and n + 1 in cen:
a, b, c = rmsdiff16(get(n), get(n - 1)), rmsdiff16(get(n), get(n + 1)), rmsdiff16(get(n - 1), get(n + 1))
if a + b > 2.0: # moving (still images: codec-free, but blending noise)
rs[n] = c / (0.5 * (a + b))
btw[e][name] = rs
span = max(max(tracks['L']['view']), max(tracks['R']['view'])) + 1
print(f"== window {win}: sets L {len(tracks['L']['view'])}, R {len(tracks['R']['view'])} over {span} refreshes")
# Scene position per set from the left eye's stamp (game frames).
sp = {}
for v, (w, prev, cur) in scene.get(win, {}).items():
span_f = (cur - prev) & 0xffff
sp[round((v - v0) / PERIOD)] = (cur - span_f + w * span_f, w, cur)
pos_s = {}
if sp:
ks = sorted(sp)
base = sp[ks[0]][2]
pos_s = {k: sp[k][0] + 65536 * round((base - sp[k][2]) / 65536) for k in ks}
steps = [pos_s[k] - pos_s[k - 1] for k in ks if k - 1 in pos_s]
alt = sum(1 for k in ks if k - 1 in pos_s and k - 2 in pos_s and abs((pos_s[k] - pos_s[k - 1]) - (pos_s[k - 1] - pos_s[k - 2])) >= 0.3)
ws = collections.Counter(round(sp[k][1], 2) for k in ks)
print(f" scene stamp: steps between sets one refresh apart mean {statistics.fmean(steps):.3f} sd {statistics.pstdev(steps):.3f} game frames; "
f"consecutive steps differing by 0.3+ {alt} of {len(steps)}; weight 1.0 in {ws.get(1.0, 0)}, 0.0 in {ws.get(0.0, 0)} sets")
devs = {}
for kind in ('view', 'scene'):
for e in 'LR':
r = rule(tracks[e][kind])
devs[kind, e] = r['z']
pos = tracks[e][kind]
st = [(pos[k] - pos[k - 1], pos_s[k] - pos_s[k - 1]) for k in pos if k - 1 in pos and k in pos_s and k - 1 in pos_s]
cs = corr([a for a, _ in st], [b for _, b in st]) if st else float('nan')
print(f" {kind:6s} {e}: speed p50 {r['speed']:.2f} px/refresh; |dev| p95 {r['p95']:.2f} px (>=1 px {r['ge1']} of {r['n']}); moving {r['tests']}, "
f"zigzag {r['zz']} ({100.0 * r['zz'] / max(r['tests'], 1):.1f} %, mean {r['amp']:.2f} refreshes); lag-1 autocorr {r['ac1']:+.2f}; "
f"step vs scene step corr {cs:+.2f}")
common = [n for n in devs[kind, 'L'] if n in devs[kind, 'R']]
print(f" {kind:6s} eyes: deviation L vs R corr {corr([devs[kind, 'L'][n] for n in common], [devs[kind, 'R'][n] for n in common]):+.2f} over {len(common)} moving sets")
for e in 'LR':
l1 = [x for x, y in lag[e].values() if x is not None]
l2 = [y for x, y in lag[e].values() if y is not None]
stale = sum(1 for x, y in lag[e].values() if x is not None and y is not None and y < 0.5 * x)
ident = sum(1 for x, y in lag[e].values() if (x is not None and x < 0.5) or (y is not None and y < 0.5))
print(f" lagdiff {e}: vs set before p50 {statistics.median(l1) if l1 else 0:.1f}, two before p50 {statistics.median(l2) if l2 else 0:.1f} "
f"gray levels; nearer n-2 than half of n-1: {stale}; identical (<0.5) to n-1 or n-2: {ident}")
for name in ('upper', 'ground L', 'ground R'):
out = []
for e in 'LR':
rs = btw[e][name]
v = sorted(rs.values())
ks = sorted(rs)
pairs = [(rs[k], rs[k + 1]) for k in ks if k + 1 in rs]
out.append(f"{e} {len(v)} sets, r p50 {statistics.median(v) if v else float('nan'):.2f} p5 {v[int(0.05 * (len(v) - 1))] if v else float('nan'):.2f}, "
f"r < 1: {sum(1 for x in v if x < 1.0)}, lag-1 autocorr {corr([a for a, _ in pairs], [b for _, b in pairs]):+.2f}")
common = [k for k in btw['L'][name] if k in btw['R'][name]]
print(f" between {name:8s}: " + "; ".join(out) + f"; L vs R corr {corr([btw['L'][name][k] for k in common], [btw['R'][name][k] for k in common]):+.2f}")
if os.environ.get('ZZ_TRACK'):
for n in range(span):
print(' ', n, ' '.join(f"{tracks[e][k].get(n, float('nan')):8.2f}" for k in ('view', 'scene') for e in 'LR'),
f"{pos_s.get(n, float('nan')):.3f}", sp.get(n, ('',))[1:2])
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Menus and race apart, with exact percentiles: pacing-parts.py RUN_DIR [RUN_DIR ...] (lane E).
A run dir is frame/perf/run.sh's output: game.log (the "vr pacing:" lines, "entered/exited
RaceScene", the mock's summary) and samples.txt (KARTFRAME_PACING_SAMPLES: every set's GPU and
encode ms per pacing second). A second belongs to the race from the pacing line after "entered
RaceScene" up to the one after "exited RaceScene" (session-report.py's rule). Prints one markdown row
per run and part: sets/s, late % (of sets due), repeats % (of ticks), missed refreshes, half-rate
switches, GPU ms per set p50/p99, encode ms p50/p99, game frames/s (KARTFRAME_FPSLOG lines), then the mock's own counts against the game's."""
import os
import re
import sys
PACING = re.compile(r"vr pacing: t=(\d+) hz=([\d.]+) mode=(\S+) ticks=(\d+) rendered=(\d+) interpolated=(\d+) "
r"late=(\d+) missed=(\d+) repeats=(\d+)")
TOTALS = re.compile(r"vr pacing totals: .* ticks=(\d+) rendered=(\d+) interpolated=(\d+) late=(\d+) missed=(\d+) repeats=(\d+)")
FPS = re.compile(r"Game frame rate [\d.]+ FPS \((\d+) frames in ([\d.]+) s\); per frame the producer waited ([\d.]+) ms for DONE")
MOCK = re.compile(r"xrmock: frames: (\d+) ended; (\d+) showed newly released eyes .*?\), (\d+) repeated a projection layer, "
r"(\d+) a quad, (\d+) no layer")
def pct(values, p):
if not values:
return None
v = sorted(values)
return v[min(len(v) - 1, int(p * (len(v) - 1) + 0.5))]
def race_part(in_race, screens):
if not in_race:
return "menus"
return "race" if screens <= 1 else ("race 2p" if screens == 2 else "race 4p")
def run(d):
# The attract-mode demo alternates 1, 2 and 4 player races (MKW runs 3 and 4 players at 30 fps):
# "race" is the 1-player race (the owner's case), "race Np" the split-screen ones.
parts = {k: {"s": 0, "ticks": 0, "rendered": 0, "late": 0, "missed": 0, "repeats": 0, "gpu": [], "enc": [], "half": 0,
"frames": 0, "fsecs": 0.0, "done_ms": 0.0}
for k in ("menus", "race", "race 2p", "race 4p")}
screens = 1
second_part = {}
in_race = False
totals = mock = None
half = []
with open(os.path.join(d, "game.log"), errors="replace") as f:
for line in f:
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
if m:
screens = int(m.group(1))
if "entered RaceScene" in line or "presentation=immersive-race" in line:
in_race = True
elif "exited RaceScene" in line or "presentation=virtual-screen" in line or "presentation=desktop" in line:
in_race = False
elif "rendering every second refresh" in line:
half.append(("down", in_race))
elif "rendering every refresh again" in line:
half.append(("up", in_race))
m = PACING.search(line)
if m:
t = int(m.group(1))
p = parts[race_part(in_race, screens)]
second_part[t] = p
p["s"] += 1
for i, k in ((4, "ticks"), (5, "rendered"), (7, "late"), (8, "missed"), (9, "repeats")):
p[k] += int(m.group(i))
continue
m = FPS.search(line)
if m:
p = parts[race_part(in_race, screens)]
p["frames"] += int(m.group(1))
p["fsecs"] += float(m.group(2))
p["done_ms"] += float(m.group(3)) * int(m.group(1))
continue
m = TOTALS.search(line)
if m:
totals = [int(x) for x in m.groups()]
m = MOCK.search(line)
if m:
mock = [int(x) for x in m.groups()]
sp = os.path.join(d, "samples.txt")
if os.path.exists(sp):
for line in open(sp):
g = re.match(r"t=(\d+) gpu=(\S*) encode=(\S*)", line)
if not g or int(g.group(1)) not in second_part:
continue
p = second_part[int(g.group(1))]
p["gpu"] += [float(x) for x in g.group(2).split(",") if x]
p["enc"] += [float(x) for x in g.group(3).split(",") if x]
return parts, totals, mock, half
def fmt(x):
return "-" if x is None else "%.2f" % x
def main(dirs):
print("| run | part | s | sets/s | late % | repeats % | missed | GPU ms p50 / p99 | encode ms p50 / p99 | game fps |")
print("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |")
checks = []
for d in dirs:
name = os.path.basename(os.path.normpath(d))
parts, totals, mock, half = run(d)
for k in ("menus", "race", "race 2p", "race 4p"):
p = parts[k]
if not p["s"]:
continue
due = p["rendered"] + p["late"]
fps = "%.1f" % (p["frames"] / p["fsecs"]) if p["fsecs"] else "-"
print("| %s | %s | %d | %.1f | %.2f | %.2f | %d | %s / %s | %s / %s | %s |" % (
name, k, p["s"], p["rendered"] / p["s"], 100.0 * p["late"] / max(due, 1),
100.0 * p["repeats"] / max(p["ticks"], 1), p["missed"], fmt(pct(p["gpu"], .5)), fmt(pct(p["gpu"], .99)),
fmt(pct(p["enc"], .5)), fmt(pct(p["enc"], .99)), fps))
hs = ", ".join("%s in %s" % (a, "race" if r else "menus") for a, r in half) or "none"
if totals and mock:
ticks, rendered, interp, late, missed, repeats = totals
ended, fresh, rep, quad, empty = mock
checks.append("%s: half-rate switches: %s. game: ticks %d, rendered %d, late %d, missed %d, repeats %d; mock: ended %d, "
"fresh %d, repeated layer %d, no layer %d (repeated + no layer = %d)" % (
name, hs, ticks, rendered, late, missed, repeats, ended, fresh, rep, empty, rep + empty))
else:
checks.append("%s: half-rate switches: %s; no totals or mock summary in the log" % (name, hs))
print()
for c in checks:
print("- " + c)
if __name__ == "__main__":
if not sys.argv[1:]:
print(__doc__)
sys.exit(1)
main(sys.argv[1:])
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# analyse.sh SESSION_DIR [OUT_DIR] [RACE_FROM_S]: the whole panel analysis of a session recorded with
# KARTFRAME_RECORD=raw (panels.mkv): decode per eye (extract.sh), compare each refresh with the 8 before
# it (lagdiff.py), global image motion (motion.py), then cadence.py's summary into OUT_DIR/cadence.txt.
# About 4 minutes for a 2.5 minute recording; OUT_DIR (default ~/.cache/kf-panels/<session>) holds 4 GB.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
S=${1:?session dir}; OUT=${2:-$HOME/.cache/kf-panels/$(basename "$S")}; FROM=${3:-30}
[ -s "$S/panels.mkv" ] || { echo "no $S/panels.mkv" >&2; exit 1; }
"$HERE/extract.sh" "$S/panels.mkv" "$OUT"
python3 -I "$HERE/lagdiff.py" "$OUT" > "$OUT/lagdiff.txt"
python3 -I "$HERE/motion.py" "$OUT" > "$OUT/motion.txt" 2>/dev/null
python3 -I "$HERE/cadence.py" "$OUT/lagdiff.txt" "$OUT/motion.txt" "$FROM" | tee "$OUT/cadence.txt"
+79
View File
@@ -0,0 +1,79 @@
# cadence.py LAGDIFF [MOTION] [RACE_FROM_S]: per eye, how each recorded refresh relates to the ones before it
# (from lagdiff.py's table; MOTION from motion.py adds the split by image motion):
# new differs from every earlier refresh it was compared with,
# rep the previous refresh again (a held frame; only seen while nothing moves on the panel),
# stale an image the eye showed 2-8 refreshes before, pixel for pixel, while the previous refresh
# differed: an old image shown again where it was shown then, i.e. with a newer frame's pose.
# Also both eyes together (out of step: one stale or held while the other is new) and the lag of the stale ones.
import collections, sys
lag = sys.argv[1]
mot_path = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != '-' else None
race_from = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0
D, T = {}, {}
for l in open(lag):
r = l.split()
D[(int(r[1]), r[2])] = [float(x) for x in r[3:]]
T[int(r[1])] = float(r[0])
def cls(i, e):
d = D.get((i, e))
if d is None or d[0] < 0:
return None, 0
if d[0] < 2.0:
return 'rep', 1
for k in range(1, 8):
if 0 <= d[k] <= 4.0 and d[k] < 0.35 * d[0]:
return 'stale', k + 1
return 'new', 0
mot = {}
if mot_path:
for l in open(mot_path):
t, i, e, g, dx, dy = l.split()
if g == '1':
mot[(int(i), e)] = abs(float(dx))
idx = sorted(T)
for name, keep in (('before ' + str(race_from) + ' s', lambda t: t < race_from), ('from ' + str(race_from) + ' s', lambda t: t >= race_from)):
sel = [i for i in idx if keep(T[i])]
print('==', name)
for e in 'LR':
c = collections.Counter(); lags = collections.Counter(); holds = collections.Counter(); run = 1
speed = collections.defaultdict(collections.Counter)
for i in sel:
k, lg = cls(i, e)
if k is None:
run = 1
continue
c[k] += 1
if k == 'stale':
lags[lg] += 1
if k == 'rep':
run += 1
else:
holds[run] += 1
run = 1
if mot:
v = [mot[(j, e)] for j in range(i - 4, i + 1) if (j, e) in mot]
if v:
s = sum(v) / len(v)
b = 'image still (<0.5 px/refresh)' if s < 0.5 else 'slow (0.5-2)' if s < 2 else 'moving (>=2)'
speed[b][k] += 1
n = sum(c.values()) or 1
print('%s: %d refreshes; new %.1f %%, held %.1f %%, stale %.1f %% (lags %s); holds %s' % (
e, n, 100 * c['new'] / n, 100 * c['rep'] / n, 100 * c['stale'] / n,
' '.join('%d:%d' % kv for kv in sorted(lags.items())), ' '.join('%d:%d' % kv for kv in sorted(holds.items())[:6])))
for b, cc in sorted(speed.items()):
m = sum(cc.values())
print(' %-30s %6d refreshes, stale %.1f %%' % (b, m, 100 * cc['stale'] / m))
both = collections.Counter()
for i in sel:
a, _ = cls(i, 'L'); b, _ = cls(i, 'R')
if a and b:
both[(a, b)] += 1
n = sum(both.values()) or 1
out = sum(v for (a, b), v in both.items() if a != b)
print('both eyes: %d refreshes, out of step %.1f %%, stale together %.2f %%; %s' % (
n, 100 * out / n, 100 * both[('stale', 'stale')] / n, ' '.join('%s/%s:%d' % (a, b, v) for (a, b), v in sorted(both.items()))))
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# extract.sh PANELS.MKV OUTDIR [W H]: decodes a framecorder --view raw recording (both panels side by side)
# once into per-eye grayscale raw files (OUTDIR/L.gray, OUTDIR/R.gray, W x H bytes a frame, default 480x270,
# the eye halves scaled with area averaging) and OUTDIR/pts.txt (one presentation time in seconds per frame,
# the panel vblank the frame was grabbed at). Frames the recorder missed leave gaps in pts.txt.
set -euo pipefail
in=$1; out=$2; W=${3:-480}; H=${4:-270}
mkdir -p "$out"
ffprobe -v error -select_streams v:0 -show_entries frame=pts_time -of csv=p=0 "$in" > "$out/pts.txt" &
ffmpeg -hide_banner -loglevel error -threads 3 -i "$in" -an -fps_mode passthrough -filter_complex \
"[0:v]format=gray,split[a][b];[a]crop=iw/2:ih:0:0,scale=$W:$H:flags=area[l];[b]crop=iw/2:ih:iw/2:0,scale=$W:$H:flags=area[r]" \
-map '[l]' -f rawvideo -fps_mode passthrough "$out/L.gray" -map '[r]' -f rawvideo -fps_mode passthrough "$out/R.gray" -y
wait
echo "$W $H" > "$out/geometry.txt"
echo "frames: $(wc -l < "$out/pts.txt"), L $(stat -c %s "$out/L.gray") bytes"
+29
View File
@@ -0,0 +1,29 @@
# lagdiff.py DIR [T0 T1] > table: per eye and captured frame, the RMS difference (gray levels) of a central
# region against the frames 1..8 refreshes earlier (when the recorder caught them), without motion
# compensation. Columns: t idx eye d1..d8 (-1 = not captured).
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pgray import Eyes, to16, rmsdiff16
d = sys.argv[1]
t0 = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
t1 = float(sys.argv[3]) if len(sys.argv) > 3 else 1e9
E = Eyes(d)
X0, Y0, RW, RH = 100, 40, 280, 110 # above the head-locked overlay, inside both lens circles
for e in 'LR':
cache = {}
for i in range(E.n):
t = E.pts[i]
if t < t0 - 0.1 or t > t1:
continue
cache[E.idx[i]] = to16(E.region(e, i, X0, Y0, RW, RH))
for k in [k for k in cache if k < E.idx[i] - 8]:
del cache[k]
if t < t0:
continue
cur = cache[E.idx[i]]
row = []
for lag in range(1, 9):
p = cache.get(E.idx[i] - lag)
row.append('%.2f' % rmsdiff16(cur, p) if p is not None else '-1')
print('%.3f %d %s %s' % (t, E.idx[i], e, ' '.join(row)))
+62
View File
@@ -0,0 +1,62 @@
# motion.py DIR > table: per eye and captured frame, the global image shift (x, y in extract pixels: 480x270
# per eye, so 1 px = 4.5 panel px across, 8 down) against the previous captured frame, from column and row
# projections of the upper part of the lens circle (tunnel walls, sky: the world, not the HUD overlay).
# Columns: t idx eye gap dx dy. gap = refreshes since the previous captured frame.
import os, sys, audioop
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pgray import Eyes
E = Eyes(sys.argv[1])
X0, Y0, RW, RH = 60, 20, 360, 130
M = 24 # search +-M px
def profiles(e, i):
reg = E.region(e, i, X0, Y0, RW, RH)
cols = [0] * RW
rows = []
for y in range(RH):
r = reg[y * RW:(y + 1) * RW]
rows.append(sum(r))
for x in range(0, RW):
cols[x] += r[x]
return cols, rows
def to16(vals):
m = sum(vals) / len(vals)
s = max(1.0, max(abs(v - m) for v in vals))
return b''.join(int((v - m) * 12000 / s).to_bytes(2, 'little', signed=True) for v in vals)
def fit(prev, cur):
"""Shift of cur against prev (positive: content moved right/down), with sub-pixel refinement."""
n = len(cur)
ref = to16(cur[M:n - M])
frag = to16(prev)
off, _ = audioop.findfit(frag, ref) # ref best matches frag[off:off+n-2M]
def err(o):
if o < 0 or o > 2 * M:
return None
part = frag[2 * o:2 * (o + n - 2 * M)]
f = audioop.findfactor(part, ref)
return audioop.rms(audioop.add(part, audioop.mul(ref, 2, -f), 2), 2)
e0, em, ep = err(off), err(off - 1), err(off + 1)
sub = 0.0
if em is not None and ep is not None and em + ep - 2 * e0 > 0:
sub = 0.5 * (em - ep) / (em + ep - 2 * e0)
return (M - off) - sub
import warnings
warnings.filterwarnings('ignore')
for e in 'LR':
prev = None
for i in range(E.n):
cols, rows = profiles(e, i)
if prev is not None:
dx = fit(prev[0], cols)
dy = fit(prev[1], rows) if len(rows) > 2 * M + 8 else 0.0
print('%.3f %d %s %d %.2f %.2f' % (E.pts[i], E.idx[i], e, E.idx[i] - pidx, dx, dy))
prev = (cols, rows)
pidx = E.idx[i]
+42
View File
@@ -0,0 +1,42 @@
# Shared helpers for the panel recording analysis (no numpy/PIL: audioop does the per-pixel work in C).
# Input: a directory made by extract.sh (L.gray, R.gray, pts.txt, geometry.txt).
import audioop, mmap, os, warnings
warnings.filterwarnings('ignore', category=DeprecationWarning)
class Eyes:
def __init__(self, d):
self.w, self.h = map(int, open(os.path.join(d, 'geometry.txt')).read().split())
self.fs = self.w * self.h
self.pts = [float(l) for l in open(os.path.join(d, 'pts.txt')) if l.strip()]
self.maps = {}
for e in 'LR':
f = open(os.path.join(d, e + '.gray'), 'rb')
self.maps[e] = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
self.n = min(len(self.pts), len(self.maps['L']) // self.fs, len(self.maps['R']) // self.fs)
# Refresh index of each frame (the recorder stamps frames with their vblank; 120 Hz panels).
self.idx = [round(t * 120.0) for t in self.pts[:self.n]]
def frame(self, e, i):
o = i * self.fs
return self.maps[e][o:o + self.fs]
def region(self, e, i, x0, y0, w, h):
f = self.frame(e, i)
W = self.w
return b''.join(f[(y0 + y) * W + x0:(y0 + y) * W + x0 + w] for y in range(h))
def to16(b):
"""unsigned 8-bit gray -> signed 16-bit samples halved (so differences never overflow)."""
return audioop.mul(audioop.lin2lin(audioop.bias(b, 1, -128), 1, 2), 2, 0.5)
def rmsdiff16(a, b):
"""RMS difference in gray levels of two to16() fragments."""
return audioop.rms(audioop.add(a, audioop.mul(b, 2, -1), 2), 2) / 128.0
def shifted(img, W, x0, y0, w, h, dx, dy):
return b''.join(img[(y0 + dy + y) * W + x0 + dx:(y0 + dy + y) * W + x0 + dx + w] for y in range(h))
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# One measured mock run (lane E): frame/perf/run.sh NAME SECONDS [VAR=value ...]
#
# Runs frame/tools/mockrun.sh under the shared build lock (one heavy GPU/CPU job at a time) with the
# given environment, samples the game's read I/O beside it (iosample.py), and keeps the game log, the
# mock trace and the samples in $KF_PERF_OUT/NAME (default ~/.cache/kf-laneE/runs). The user state is
# one scratch home ($KF_PERF_HOME, default ~/.cache/kf-laneE/home), so Dawn's and Aurora's pipeline
# caches are warm after the first run; KF_PERF_COLD=1 starts from an empty home instead.
# Variables of interest: TU_DEBUG=sysmem, KARTFRAME_PACING, KARTFRAME_REFRESH_HZ, MOCK_XR_HZ,
# KF_MOCKRUN_RENDER_SCALE, DISABLE_VULKAN_FDM_INJECTION_LAYER=1, MOCK_XR_SKIP=1.
# Then: frame/tools/session-report.py <dir>/game.log; frame/perf/hitches.py <dir>.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
NAME=${1:?name}; SECS=${2:?seconds}; shift 2
OUTROOT=${KF_PERF_OUT:-$HOME/.cache/kf-laneE/runs}
HOMEDIR=${KF_PERF_HOME:-$HOME/.cache/kf-laneE/home}
D=$OUTROOT/$NAME
rm -rf "$D"; mkdir -p "$D"
[ "${KF_PERF_COLD:-0}" = 1 ] && rm -rf "$HOMEDIR"
mkdir -p "$HOMEDIR"
for kv in "$@"; do export "$kv"; done
export KF_MOCKRUN_HOME=$HOMEDIR KARTFRAME_PACING_SAMPLES=$D/samples.txt KARTFRAME_FPSLOG=${KARTFRAME_FPSLOG:-1}
printf '%s\n' "$@" > "$D/env.txt"
EXE=${KF_BUILD:-$(cd "$HERE/../.." && pwd)/build-linux}/kartframe
# A game process (the binary itself, a Steam launch, Lepton/Waydroid), not a shell whose command line
# merely mentions one (an agent's "kfbuild.sh . kartframe" kept this waiting forever).
busy() { pgrep -x kartframe || pgrep -f '^[^ ]*reaper SteamLaunch' || pgrep -x waydroid || pgrep -f '^/bin/bash [^ ]*/lepton '; }
# Timing runs hold KartFrame's build lock and PrimeFrame's (another project shares this machine's CPU
# and GPU), the PrimeFrame one first, as `flock PrimeFrame flock KartFrame` does.
PF_LOCK=$HOME/Games/PrimeFrame/.build.lock
[ -e "$PF_LOCK" ] && exec 8>"$PF_LOCK" || exec 8>/dev/null
exec 9>"$HOME/Games/KartFrame/.build.lock"
while :; do
while busy >/dev/null; do echo "perf: $NAME: a game is running, retrying in 5 min"; sleep 300; done
echo "perf: $NAME waiting for the build locks"; [ -e "$PF_LOCK" ] && flock 8; flock 9
busy >/dev/null || break
flock -u 9; flock -u 8 2>/dev/null || true
done
echo "perf: $NAME running ${SECS}s ($*)"
KF_IOSAMPLE_EXE=$EXE python3 -I "$HERE/iosample.py" "$D/io.tsv" &
SAMPLER=$!
# GPU clock (devfreq) and the hottest thermal zone every 0.5 s (lane I: per-set GPU times follow the clock).
( while :; do
printf '%s %s %s %s\n' "$(date +%s.%N)" "$(cat /sys/class/devfreq/*.gpu/cur_freq 2>/dev/null | head -1)" \
"$(cat /sys/class/devfreq/*.gpu/max_freq 2>/dev/null | head -1)" "$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -n | tail -1)"
sleep 0.5; done > "$D/gpufreq.tsv" ) &
FREQ=$!
set +e
bash "$HERE/../tools/mockrun.sh" --seconds "$SECS" --exe "$EXE" > "$D/mockrun.out" 2>&1
RC=$?
set -e
kill $SAMPLER $FREQ 2>/dev/null || true
LOG=$(ls -t "$HOMEDIR"/logs/*-mock.log 2>/dev/null | head -1)
[ -n "$LOG" ] || { cat "$D/mockrun.out"; exit 1; }
cp "$LOG" "$D/game.log"
[ -f "${LOG%.log}.trace" ] && mv "${LOG%.log}.trace" "$D/mock.trace"
rm -f "$LOG"
tail -3 "$D/mockrun.out"
exit $RC
+12
View File
@@ -0,0 +1,12 @@
# Headset session 10, launch 1 of 2 (lane O, frame/perf/H9.md): everything KartFrame hands SteamVR the way
# GalaxyFrame does (GalaxyFrame was clean on this headset): R8G8B8A8_SRGB eye swapchains (VkFormat 43),
# COLOR_ATTACHMENT|SAMPLED usage, an xrLocateViews for the frame's own time before each xrEndFrame (the
# first three are the build's new defaults) and GalaxyFrame's OpenXR extension list.
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
KARTFRAME_XR_EXTENSIONS=galaxy
KARTFRAME_XR_FORMAT=rgba
KARTFRAME_XR_USAGE=galaxy
KARTFRAME_END_LOCATE=1
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
@@ -0,0 +1,10 @@
# Headset session 10, launch 2 of 2 (lane O, frame/perf/H9.md; only if launch 1 was clean): the eye
# swapchain format alone (R8G8B8A8_SRGB, GalaxyFrame's), everything else KartFrame's old way (usage
# COLOR_ATTACHMENT|TRANSFER_DST, no extra locate, KartFrame's extension list).
# Routine as launch 1.
KARTFRAME_XR_FORMAT=rgba
KARTFRAME_XR_USAGE=kart
KARTFRAME_END_LOCATE=0
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+17
View File
@@ -0,0 +1,17 @@
# Headset session 10 (lane O): KartFrame's hand-over to SteamVR made GalaxyFrame's
GalaxyFrame was clean on this headset; KartFrame doubles on head turns. frame/perf/H9.md lists every
difference in what the two apps hand SteamVR. This build makes the eye swapchains GalaxyFrame's by default
(R8G8B8A8_SRGB instead of B8G8R8A8_SRGB, COLOR_ATTACHMENT|SAMPLED instead of |TRANSFER_DST, a locate for the
frame's own time right before xrEndFrame); launch 1 also asks for GalaxyFrame's OpenXR extension list.
Install `01-galaxy-way.env` into `$KARTFRAME_HOME/session.queue/`. Add `02-format-alone.env` only after
launch 1 was clean (it then tells whether the format alone does it); if launch 1 still doubles, do not run
02 (report back instead).
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
standing still and while driving; and whether the colours look normal (sky blue, not orange).
What the logs must show is in H9.md section 5.
+10
View File
@@ -0,0 +1,10 @@
# Headset session 11, launch 1 of 2 (lane Q, frame/perf/session-h11/README.md): hypothesis A. Every frame
# whose only layer would be the projection (races, loading) also gets a tiny fully transparent quad layer
# (16x16, 1 cm, 2 m ahead, alpha blended), so SteamVR never composites a projection layer alone, as in
# KartFrame's menus and in GalaxyFrame (both clean). The first-write barrier stays Dawn's own.
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
KARTFRAME_RACE_QUAD=1
KARTFRAME_FIRST_WRITE_BARRIER=dawn
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+10
View File
@@ -0,0 +1,10 @@
# Headset session 11, launch 2 of 2 (lane Q, frame/perf/session-h11/README.md): hypothesis B. No race quad
# (the race submits its projection layer alone, as in every earlier session); before each set's first
# write to its swapchain images, GalaxyFrame's barrier (ALL_COMMANDS / MEMORY_WRITE to ALL_COMMANDS /
# MEMORY_READ|MEMORY_WRITE).
# Routine: the same as launch 1.
KARTFRAME_RACE_QUAD=0
KARTFRAME_FIRST_WRITE_BARRIER=all
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+124
View File
@@ -0,0 +1,124 @@
# Headset session 11 (lane Q): the projection layer alone, and GalaxyFrame's first-write barrier
Replaces lane P's plan (its eye-pose instrumentation stays in the build: with `KARTFRAME_FRAME_LOG=1` the
XRS/XRP lines and the "VR eye poses (10 s)" / "VR eye uniform probe (10 s)" summaries are still logged and
must still read 0).
Inside the app everything is proven correct headless (lane P: both eyes from the same packet and pose;
lanes before: pacing, finished releases, exact stamps and end times), and the hand-over was made
GalaxyFrame's way (format, usage, end locate, extensions; H9.md) without effect. Two candidates are left:
- **A, the layer list.** KartFrame's menus (a projection layer, the menu environment, plus quad layers:
screen, panel) are clean; its races (a projection layer alone) double. GalaxyFrame always submits its
projection with quads (HUD, pointer) and is clean; PrimeFrame submits a projection alone and doubles.
If SteamVR on the Frame takes a frame whose only layer is a projection on another path (a per-eye
direct path with its own timing), the right eye could suffer there.
`KARTFRAME_RACE_QUAD=1` adds one more layer to every such frame: a 16x16 fully transparent quad
(alpha 0, `XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT`), in the app space 2 m ahead, 1 cm across,
after the projection. Its three images are cleared once (acquired, cleared, the GPU waited for, released);
every frame then shows the last released one. Invisible.
- **B, the first write.** GalaxyFrame's first write to an eye image after xrWaitSwapchainImage waits on
ALL prior commands and memory writes (SteamVR's client's acquire work included); Dawn's tracked barrier
waits on colour-attachment output only. `KARTFRAME_FIRST_WRITE_BARRIER=all` submits that barrier
(src ALL_COMMANDS / MEMORY_WRITE, dst ALL_COMMANDS / MEMORY_READ|MEMORY_WRITE, layout
COLOR_ATTACHMENT_OPTIMAL kept, queue families ignored) on every image of a set (eyes; in menus the screen,
the environment, the panel) right after their waits, on Dawn's queue under its lock, before Aurora's set.
No Dawn change (a barrier's first scope is everything earlier in submission order on the queue).
Build defaults: `KARTFRAME_RACE_QUAD=1`, `KARTFRAME_FIRST_WRITE_BARRIER=all` (both on). Other values:
`KARTFRAME_RACE_QUAD=0` (none), `=cycle` (also acquire, wait and release one quad image every frame, no
GPU work: GalaxyFrame's per-frame pattern); `KARTFRAME_FIRST_WRITE_BARRIER=dawn` (Dawn's barrier alone).
Each launch below switches exactly one of the two on.
## Install
Build: lane Q's branch, installed with `install --dev`. Put both `01-race-quad.env` and `02-barrier.env`
into `$KARTFRAME_HOME/session.queue/`; the wrapper takes them one launch each, in name order. Both record
the panels (`KARTFRAME_RECORD=raw`) and log every frame (`KARTFRAME_FRAME_LOG=1`).
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
standing still and while driving, and whether the menus are still clean.
## Launch 1: `01-race-quad.env` (race quad on, Dawn's barrier)
What the owner should see: nothing new (the quad is transparent and 1 cm at 2 m). If hypothesis A holds,
the race is clean like the menus.
game.log:
- `OpenXR Vulkan swapchains ready: ... first-write barrier dawn (KARTFRAME_FIRST_WRITE_BARRIER), race quad
on (KARTFRAME_RACE_QUAD), ...`
- once, at the first projection-only frame (the first-run guide, a loading screen or the race start):
`OpenXR race quad layer ready: 16x16 VkFormat 43, 3 images cleared to transparent in <about 1.5> ms; a
1 cm quad 2 m ahead after every projection-only layer (KARTFRAME_RACE_QUAD=1)`. No `race quad could not
be made` warning (with it, the frames went out with the projection alone and the launch tests nothing).
- XRF lines end in `n=<layers>`: `n=2` on every race frame (projection + quad); menu frames `n=2` (environment
+ screen) or `n=3` (with the panel). No `n=1` once the race quad is ready.
- pacing as session 10 (`mode=pipelined`, `end_ms` p50 about 0.2, late near 0); lane P's counters 0.
`~/.local/share/Steam/logs/xrclient_kartframe.txt`:
- one more swapchain: `CreateSwapchain` with format 43 and 16x16, and its `Created remote Vulkan image ...,
dimensions 16 x 16, format 43` lines (three images), after the eye swapchains and menu environment.
- if SteamVR logs layers per frame, a quad beside the projection on race frames. It may turn the quad into an
overlay as it did the menus' screen (`VRMsg_CompositorNewSharedImage`, `openxr_quadlayer_<n>`, H9.md
section 1); note whether it does: GalaxyFrame's quads were not overlays.
## Launch 2: `02-barrier.env` (no race quad, GalaxyFrame's barrier)
What the owner should see: the race as in sessions 1 to 10 (projection alone), unless hypothesis B holds.
game.log:
- `... first-write barrier all (KARTFRAME_FIRST_WRITE_BARRIER), race quad off (KARTFRAME_RACE_QUAD), ...`;
no `first-write barrier could not be submitted` warning; no `race quad layer ready` line.
- XRF lines: `n=1` on every race frame.
- pacing as launch 1 (the barrier is one small submission per set; mock: no timing change).
`xrclient_kartframe.txt`: no 16x16 swapchain; otherwise as session 10.
## What it decides
- Launch 1 clean, launch 2 doubled: the projection layer alone is the cause (A). Keep the race quad on
(default) and tell PrimeFrame (it submits a projection alone and doubles too).
- Launch 2 clean, launch 1 doubled: the first write ordered only after colour-attachment output was the
cause (B). Keep the barrier on (default) and consider making it Dawn's own barrier.
- Both clean: either one is enough; both defaults stay on; a later session can tell them apart with
`KARTFRAME_RACE_QUAD=0 KARTFRAME_FIRST_WRITE_BARRIER=dawn` as the control.
- Both doubled: neither. What is left from H9.md: the order of the calls (acquire and render the next set
before releasing and ending, GalaxyFrame's order) and the eye size (render_scale 0.9 measured for the
record only). Panel analysis as before: `frame/perf/panels/analyse.sh <session>` per launch, H7 section
1's zigzag table (zigzag refreshes per eye, race against menus).
## Mock results (lane Q, build 6596f27)
`frame/perf/hudrace.sh q-race 180 MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
KF_MOCKRUN_END_GATE=2` (scripted Grand Prix, 120 Hz, defaults: race quad on, barrier all):
| check | result |
| --- | --- |
| mock errors | 0 |
| releases changed after the GPU passed them | 0 of 42792 |
| stamps for their poses' locate | 33144 of 33148 (4 unstamped), 0 for another locate, 0 with the other eye's |
| end times exact | 21481 of 21481 |
| ends later than 1 ms after the wake-up (gate 2 %) | 0.3 % |
| layer lists per xrEndFrame | PQ 16574 (race: projection + quad), EQ 4867 (menus: environment + screen), none 40; P alone 0; quads on a never-released swapchain 0 |
| XRF lines | 21441, all `n=2` |
| race quad made | 16x16 VkFormat 43, usage 0x31, 3 images cleared in 1.4 ms |
| lane P's counters | eye poses 0, uniform probe 0 / 0 |
| pacing | late 32 of 21363 ticks, end_ms p50 0.15, gpu 4.08 ms p50 |
Validation layer (`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, 80 s, menus and race with stamps, the
barrier on every set and the quad's clears): 0 messages (best-practices warnings in a check run showed the
layer's output reaches game.log), 0 mock errors. ctest 32 of 32. Eye images at 65 and 75 s as before.
Race timing, interleaved, 90 s each, `MOCK_XR_BUSY_MS=1`, no stamps (old = `KARTFRAME_RACE_QUAD=0
KARTFRAME_FIRST_WRITE_BARRIER=dawn`, the behaviour before this lane; new = the defaults):
| run | errors | end p50 / p99 ms | ends > 1 ms | late sets | gpu p50 / p99 ms | layer lists |
| --- | --- | --- | --- | --- | --- | --- |
| old 1 | 0 | 0.17 / 0.65 | 0.4 % | 21 | 4.06 / 5.03 | P 6683, EQ 3960 |
| new 1 | 0 | 0.18 / 0.64 | 0.3 % | 23 | 4.02 / 5.03 | PQ 6685, EQ 3958 |
| old 2 | 0 | 0.18 / 0.70 | 0.5 % | 24 | 3.97 / 5.00 | P 6697, EQ 3954 |
| new 2 | 0 | 0.17 / 0.70 | 0.4 % | 27 | 4.04 / 5.08 | PQ 6680, EQ 3960 |
No timing change. Scratch: `$HOME/.cache/kf-laneQ/runs/`.
+7
View File
@@ -0,0 +1,7 @@
# Headset session 12, launch 1 of 3 (lane R, frame/perf/session-h12/README.md): the measurement. Build
# defaults (sets two refreshes ahead, the rendered poses submitted). The game logs GalaxyFrame's pose check
# every 5 s: how far each shown set's eye poses are from a locate for the time it is shown.
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+8
View File
@@ -0,0 +1,8 @@
# Headset session 12, launch 2 of 3 (lane R, frame/perf/session-h12/README.md): lever 1. Each set is
# located for the next refresh and started at the wake-up before the frame is ended (GalaxyFrame's order):
# one refresh less pose prediction. Late sets repeat the retained layer; judge the doubling, not smoothness.
# Routine: the same as launch 1.
KARTFRAME_AHEAD_REFRESHES=1
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+8
View File
@@ -0,0 +1,8 @@
# Headset session 12, launch 3 of 3 (lane R, frame/perf/session-h12/README.md): lever 2. Sets rendered two
# refreshes ahead as in launch 1, but each race frame that shows a new set is submitted with the poses of a
# locate for its own display time (the images stay as rendered).
# Routine: the same as launch 1.
KARTFRAME_SUBMIT_POSE=fresh
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+171
View File
@@ -0,0 +1,171 @@
# Headset session 12 (lane R): how far the shown poses are from the head, and two levers
After eleven sessions one problem is left: in races the image doubles on head turns, the right eye worse
than the left, standing still as well as driving. Menus are clean, and so is GalaxyFrame on the same
SteamVR. Everything inside the app has been proven correct headless (pacing, finished releases, exact
stamps and end times, both eyes from one packet and pose; lanes A to Q).
One measurement was never made in KartFrame: the one GalaxyFrame logs every 5 s and passes. How far is
each shown frame's pose from a fresh xrLocateViews for the moment it is shown? GalaxyFrame's clean run:
"left eye 0.10 mm 0.024 deg avg, 0.79 mm 0.061 deg max; right eye 0.09 mm 0.024 deg avg, 0.98 mm 0.061 deg
max". GalaxyFrame locates each set at one wake-up for the next refresh, so its poses are predicted about
one refresh plus SteamVR's lead ahead. KartFrame's sets ahead are located two refreshes ahead, and
SteamVR's predicted display time is already about 25 ms after the wake-up: about 42 ms of prediction.
If SteamVR composes each eye against a fresh prediction of its own (the right eye later), a pose that far
ahead shows as a per-eye displacement the size of the head's unpredicted motion over those tens of ms:
the zigzag of one to two refreshes of motion seen on the recordings.
## What the build logs (all three launches)
game.log, every 5 s and whenever the scene changes between menus and race:
- `vr: views of N sets: M for the display time they went out at (off X ms avg, Y max); against a locate
then: left eye A mm B deg avg, C mm D deg max; right eye ...; eye distance E-F mm; race|menus, S s; that
locate G ms avg H max`. GalaxyFrame's check (the same words): at every xrEndFrame, one xrLocateViews for
the display time being ended, in the layer's space, after the release; per eye the distance (mm) and the
angle (deg) between the pose the shown images were rendered from and that locate. Counted once per set,
when it first goes out. "that locate" is the check's own cost.
- `vr: poses predicted ahead (N sets, S s, race|menus): display time minus the set's locate p50 X ms, max Y`.
- XRF lines (KARTFRAME_FRAME_LOG=1) end in `aL=<deg> aR=<deg>`: the same angle for every frame, repeats too.
`frame/tools/session-report.py <session>` shows all of it for menus and race separately.
## Install
Build: lane R's branch, `install --dev`. Put `01-measure.env`, `02-one-ahead.env` and `03-fresh-pose.env`
into `$KARTFRAME_HOME/session.queue/`; the wrapper takes one per launch, in name order. Each records the
panels (`KARTFRAME_RECORD=raw`) and logs every frame (`KARTFRAME_FRAME_LOG=1`).
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
standing still and while driving, and whether the menus are still clean.
## Launch 1: `01-measure.env` (build defaults)
What I should see: the race as in sessions 1 to 11. This launch is the measurement.
game.log: `poses predicted ahead` about 42 ms in the race (two refreshes plus SteamVR's lead; the menus the
same). The `views of` lines are the result.
## Launch 2: `02-one-ahead.env` (`KARTFRAME_AHEAD_REFRESHES=1`)
Each set is located for the next refresh instead of the one after, and started at the wake-up before the
frame is ended (GalaxyFrame's order). One refresh (8.3 ms) less prediction. If sets do not fit a refresh on
the headset, late sets repeat the last frame (a stutter); judge the doubling, not the smoothness.
game.log: `OpenXR pipelined pacing: sets rendered one refresh ahead (KARTFRAME_AHEAD_REFRESHES=1), each
started at the wake-up before the frame is ended, ...`, `OpenXR eye swapchain pairs: 4`; `poses predicted
ahead` about 33 ms; the pacing lines' `late=` and the race late % in session-report.py (mock: 0.6 %).
## Launch 3: `03-fresh-pose.env` (`KARTFRAME_SUBMIT_POSE=fresh`)
Sets two refreshes ahead as in launch 1, but every race frame that shows a new set is submitted with the
poses of the check's own locate for that frame's display time. The images stay as rendered. SteamVR then
has nothing left to correct but its own compose-time delta; the prediction error of the set (what the
check measures) stays in the image as a small swim of the whole picture with the head, the same in both
eyes. Menus and repeats of the last frame are unchanged.
game.log: as launch 1; the `views of` numbers are the error the images now carry.
## Reading it
- **Launch 1, per-eye error of a degree or more** (GalaxyFrame: 0.024 avg, 0.06 max), and the zigzag
smaller in launch 2 and/or 3: the prediction distance is the cause. Next: make the better launch the
default (launch 2's order, or fresh poses), then look at the one that remains.
- **Launch 1 errors like GalaxyFrame's** (a few hundredths of a degree avg, under about 0.1 max while
turning): the hypothesis is dead; launches 2 and 3 should look like launch 1. Next: an xrprobe session
that submits KartFrame's own eye images (recorded from a race) through GalaxyFrame's loop, to split the
images from the hand-over.
- Either way compare left and right in the `views of` lines: the doubling is worse in the right eye; a
right eye error clearly larger than the left would point at the eye poses themselves.
- Panel analysis as before: `frame/perf/panels/analyse.sh <session>` per launch, H7 section 1's zigzag
table (zigzag refreshes per eye, race against menus).
## Mock results (lane R)
Scripted Grand Prix (`frame/perf/hudrace.sh`, 120 Hz, `MOCK_XR_BUSY_MS=1`, `KF_MOCKRUN_END_GATE=2`). The mock's
head pose is a function of the display time, so the check reads 0 except for SteamVR-like prediction
jitter (0.02 ms) and sets shown later than their own time.
Defaults, 180 s, `MOCK_XR_STAMP=1 KARTFRAME_FRAME_LOG=1`:
| check | result |
| --- | --- |
| mock errors | 0; releases changed after the GPU passed them 0 of 42799 |
| stamps for their poses' locate | 34938 of 34946 (8 unstamped), 0 for another locate, 0 with the other eye's |
| end times exact | 21475 of 21475; ends > 1 ms after the wake-up 0.8 % (gate 2 %) |
| lane P's counters | eye poses 0, uniform probe 0 / 0, pose stamps 34938 < 0.01 deg |
| `views of` lines, steady menus and race | 0.00 mm 0.000 deg avg, 0.00 mm 0.002 deg max per eye; off 0.01 avg 0.04 max ms |
| `views of`, the two windows at the race start | sets shown up to 233 ms after their own time (loading stalls): up to 5.4 mm 9.6 deg |
| `poses predicted ahead` | 41.4 ms p50, 41.6 max (the mock's lead is SteamVR's 3 refreshes, 24.9 ms, plus 2 refreshes) |
| the check's locate | 0.001 ms avg, 0.1 ms max (mock; the headset's is in the line) |
ctest 32 of 32.
`KARTFRAME_AHEAD_REFRESHES=1` against the default, interleaved, 90 s each, both locks (run.sh):
| run | errors | end p50 / p99 ms | ends > 1 ms | race late sets | gpu p50 / p99 ms | predicted | GPU clock avg, SoC max |
| --- | --- | --- | --- | --- | --- | --- | --- |
| two ahead 1 | 0 | 0.18 / 0.80 | 0.6 % | 0.3 % | 4.11 / 5.13 | 41.5 ms | 780 MHz, 96 C |
| one ahead 1 | 0 | 0.25 / 0.85 | 0.7 % | 0.7 % | 4.08 / 5.15 | 33.2 ms | 781 MHz, 95 C |
| two ahead 2 | 0 | 0.19 / 0.69 | 0.4 % | 0.1 % | 4.06 / 5.10 | 41.5 ms | 786 MHz, 95 C |
| one ahead 2 | 0 | 0.25 / 0.91 | 0.9 % | 0.7 % | 4.09 / 5.05 | 33.2 ms | 767 MHz, 88 C |
One ahead, started before the end: 58 and 61 late sets of about 10500 (lane M's order: about half late).
With stamps (120 s): 0 errors, 20486 of 20496 stamps exact, end times exact.
`KARTFRAME_SUBMIT_POSE=fresh`, `MOCK_XR_STAMP=fresh` (fresh-pose layers must carry a locate for their own
display time; each image is checked against its own stamp's locate), 180 s: 0 errors; 17448 layers with new
images, all posed by a locate for their display time; their images rendered for that display time 34856,
1 to 2 refreshes before 10, 3 or more 26 (race start); pose stamps 34916 < 0.01 deg; ends > 1 ms 0.3 %.
Validation layer (`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, 80 s, menus and race, fresh poses and
stamps): 0 messages, 0 mock errors.
Scratch: `$HOME/.cache/kf-laneR/runs/`.
## Headset results (2026-10-15, sessions 055840 / 060012 / 060244; orchestrator's reading)
Owner: launch 1 "started really well but the doubling started again"; launch 2 "started poorly with bad
doubling in the right eye and occasional in the left, got worse over time"; launch 3 "bad for both eyes the
whole time". The Time Trial ghost rendered flat in all three (known, phase D).
Pose check, race, per 5 s window (left and right eyes read the same angle, as one head pose must):
| launch | avg deg | max deg | sets shown at their own time | poses predicted ahead | race late sets |
| --- | --- | --- | --- | --- | --- |
| 1 measure | 0.07-0.19 | 0.26-1.10 | 4097 of 4150 | 41.4 ms | 1.8 % |
| 2 one ahead | 0.08-0.30 | 0.80-1.61 | 2012 of 3265 (off 3.2 ms avg) | 33.4 ms | 39.6 %, last window 88 of 543 on time |
| 3 fresh pose | 0.05-0.11 | 0.16-0.50 | 5802 of 5900 | 41.5 ms | 2.0 % |
GalaxyFrame on this headset (44 windows in its own log): avg 0.023 deg median, max 0.078 median, 0.20 worst.
Launch 1 is 3-8x GalaxyFrame's average and 2-5x its max, well under "a degree or more" on average.
Menus (quad layer): 0.04-0.05 avg, 0.1-0.7 max in all launches.
Panels (`frame/perf/panels/analyse.sh`, then H7's zigzag rule on sparse captures, `~/.cache/kf-h12/zig2.py`;
the recorder captured every second refresh much of the time, so counts are small): zigzag share of moving
test refreshes in the race, left / right:
| launch | left | right | owner |
| --- | --- | --- | --- |
| 1 | 55 % (114 of 209) | 54 % (107 of 198) | doubled |
| 2 | 22 % (49 of 221) | 79 % (165 of 208) | right bad, left occasional |
| 3 | 79 % (103 of 130) | 28 % (36 of 127) | both bad |
| H7 launch 1 (same rule, for scale) | 42 % | 99 % | right worse |
Launch 1 by time: the first 6 s of moving refreshes after the race loaded (video 24-30 s, kart standing,
head turning) 1 of 22 zigzag; from video 32 s on (driving) 55-95 % per 2 s window. That is the owner's
"started well, then the doubling started". Pacing in launch 1 after the course load: 0 late, GPU 4.6-5.3 ms,
end p50 0.3-0.4 ms; nothing in the pacing changes where the zigzag starts.
Reading: launch 3 handed SteamVR a pose for the very display time of each race frame and the doubling stayed
(both eyes per the owner, left-dominant per the panels), so the prediction distance of the hand-over is not
the cause; launch 2 did not fit a refresh on the headset (26 % late sets overall, 13.7 % retained repeats,
rising through the race) and says nothing beyond that. New facts for the next lane: (1) the affected eye is
not fixed: right in launch 2 and H7, left in launch 3, both in launch 1, i.e. decided per launch (a parity
of something set at start-up); (2) the zigzag was absent while the kart stood still with the head turning
and began with driving (scene motion), in launch 1; (3) the pose error is identical for both eyes, so the
eye poses are not it. Next per the plan: an xrprobe session that submits KartFrame's own race eye images
through GalaxyFrame's loop; and, headless first, a mock run that dumps 2 s of consecutive eye images while
driving with the mock's head sway, measured with the panel scripts, to see whether the period-2 jump is in
the images KartFrame renders (interpolation phase per eye) before any headset time is spent.
+155
View File
@@ -0,0 +1,155 @@
# Headset session 13 (ready, not run): KartFrame's own race images through GalaxyFrame's loop (xrprobe replay)
Lane S (`frame/perf/LANE_S.md`) found the race eye images KartFrame renders smooth in both eyes, standing
and driving, with the head turning (H7's zigzag rule 0 % per eye; an injected period-2 jump is flagged in
99-100 % of moving sets). What is left is what SteamVR does with them. This session shows SteamVR the same
kind of images from a minimal loop that is known to be clean on this headset (GalaxyFrame's), and changes one
hand-over detail at a time.
## Recording (headless, before the session)
```
KF_DUMP_DIR=~/.cache/kf-laneT/dump-h13rec KF_PERF_OUT=~/.cache/kf-laneT/runs KF_PERF_HOME=~/.cache/kf-laneT/home-race \
SECS=61 DUMP_AT=52.5,56.5 frame/perf/mockdump/dumprace.sh h13rec MOCK_XR_SWAY=0 MOCK_XR_DUMP_STEP=2 \
"MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5/0.15/46;/user/hand/right/input/a/click@54/1000/1000"
```
Head still (`MOCK_XR_SWAY=0`: every image rendered for yaw 0, eyes at x = -+32 mm, the Frame's own per-eye
fields of view, the mock's defaults), render scale 0.75 (the race at 60 fps, scene stamps 0.25/0.75 as on the
headset), 648 px per eye, two windows of 240 consecutive sets (lane T's recording: 960 images, 1.0 GB, mock 0
errors; not in the repo, the images come from the disc). `SECS=58` as first planned cut the second window to 175
sets; 61 gives all 240. Checked on the images: 52.5 s is the race after GO (timer 0:10) with the kart still
until set 172 (the A hold at 54 s), then driving off; 56.5 s drives through the start gate. Case 1 therefore
plays the first 168 sets only (`--replay-sets 168`, 1.4 s with no scene motion beyond the HUD).
## xrprobe `--replay DIR` (built, lane T)
`frame/tools/xrprobe/xrprobe.c` (`runReplay`): loads index.txt, takes one window's sets (both eyes, ordered by
display time), uploads every image once into an R8G8B8A8_SRGB array (2 layers a set, 0.8 GB for 240 sets).
Frame n shows set k = (hold x floor(slot / hold) + offset) mod N, slot = round((predicted display time - first
one) / period): a missed refresh skips a set. Each eye's image is blitted (linear) into its 1728 swapchain image,
a label strip (case label, the set number with a stepping hue and ticker) is drawn over it 1.5 m ahead and 0.75
m up in the image's own frame, and the projection layer carries the recorded pose (identity, x = -+0.032 m,
LOCAL) and fov, not the locate's: SteamVR places the race world-fixed in front of the startup direction and
reprojects it as the head turns.
- Default (GalaxyFrame's order, one swapchain per eye): xrWaitFrame, xrBeginFrame, xrLocateViews (LOCAL, the
frame's own time), acquire both, blit, fence wait, release both, xrEndFrame.
- `--replay-pairs 3` (KartFrame's sets ahead): xrWaitFrame, xrBeginFrame, release the newest finished set due by
this display time (else repeat the retained layer), xrEndFrame; then xrLocateViews for two refreshes ahead,
acquire the next pair (first in, first out, never the one on display or in flight), blit, submit, no wait.
- `--replay-quad`: KartFrame's race quad (16x16, cleared transparent once, 1 cm, 2 m ahead, alpha blended)
after every projection layer. `--replay-offset 1`, `--replay-hold 2`, `--replay-window`, `--replay-sets`,
`--replay-label` as in `xrprobe --help`.
- Log (`--log`): one `R` line a frame (n, display time, slot, set shown, set wanted for this display time,
its recorded display time, new or repeated, pair, set started) and SUMMARY lines: frames with a layer, new
against repeated, the set matching its display time, set steps between frames, display time steps, fence wait.
## Session (six launches, one case each; framecorder `--view raw` recording)
| case | label | content | loop | expected if SteamVR is clean |
| --- | --- | --- | --- | --- |
| 1 | 1 StAnd | standing (168 sets) | GalaxyFrame order, one swapchain per eye | clean (control) |
| 2 | 2 drivE | driving | same | clean |
| 3 | 3 PHASE | driving, `--replay-offset 1` | same | clean |
| 4 | 4 PAIrS | driving | `--replay-pairs 3` | clean |
| 5 | 5 qUAd | driving | `--replay-pairs 3 --replay-quad` | clean |
| 6 | 6 HOLd | driving | `--replay-hold 2` | judder (each image twice), no doubling |
Install and queue (the orchestrator; nothing else changes, the game stays installed as it is):
```
cmake -S frame/tools/xrprobe -B ~/.cache/kf-xrprobe -G Ninja -DCMAKE_C_COMPILER=clang
flock ~/Games/KartFrame/.build.lock cmake --build ~/.cache/kf-xrprobe
frame/perf/session-h13/queue.sh ~/.cache/kf-xrprobe/xrprobe ~/.cache/kf-laneT/dump-h13rec
```
queue.sh copies the binary and `frame/tools/xrprobe/replay-case.sh` into `$KARTFRAME_HOME/h13/` and writes
`h13-01-standing.env` .. `h13-06-hold.env` into `$KARTFRAME_HOME/session.queue/`. Each makes the KartFrame
wrapper run the case instead of the game (`KARTFRAME_EXE`), with the panel recording (`KARTFRAME_RECORD=raw`),
SteamVR's per-app settings (120 Hz, 1728), its own session folder (`xrprobe.log`, `stdout.log`,
`vrtiming.log`, `panels.mkv`) and `KARTFRAME_SESSION_ENV_ONCE=1`. A case runs 45 s of visible time, then ends
by itself (back to the Steam library).
Owner, each launch from the KartFrame shortcut (the label above the image names the case):
1. Wait until the race image is up (the recording starts 8 s after the launch).
2. Look at a track-side edge (fence posts, the banner, the gate) and turn the head slowly left and right for
ten seconds, then hold still for five.
3. The same with the left eye closed, then with the right eye closed.
4. Say: doubled or not, which eye. Case 6 judders by design; the question there is doubling only.
The driving window loops every 2 s (a jump back at the loop); the standing one every 1.4 s (no visible jump).
## Reading it
- Per launch: `$KARTFRAME_HOME/sessions/<time>/xrprobe.log` (`R` lines, SUMMARY: frames with a layer, repeats,
the set against its display time; with pairs also sets released unshown and late), `vrtiming.log`.
- Panels: `frame/perf/panels/analyse.sh <session>`, then H7's zigzag rule per eye and case (session-h12's
zig2.py), menus-like floor 4-6 %. The number panel beside the label (set k, hue and ticker) gives the set
shown on each panel refresh.
- **Case 2 doubled like the game**: SteamVR does it to these images however they are handed over; the content
(60 Hz scene motion shown at 120 Hz, interpolated) is what matters. Then case 3 tells whether the affected
eye follows the content's phase (the per-launch eye: right in H7, left in H12 launch 3), case 6 whether
plain 60 Hz content doubles too, and case 1 (no scene motion) should stay clean as in H12 launch 1's
standing seconds.
- **Case 2 clean, 4 or 5 doubled**: the hand-over detail that case adds (the three-pair rotation, the race quad
after the projection layer) is it: A/B it in the game next (`KARTFRAME_EYE_PAIRS`, `KARTFRAME_RACE_QUAD=0`).
- **All clean**: the images and the hand-over are fine; what remains is the live game's timing on the headset
(when sets are released and ended relative to SteamVR's composition). Next: KartFrame launches that move
toward this loop one switch at a time.
## Mock verification (lane T)
```
XRPROBE=<binary> PROBE_MOCK=1 PROBE_SET=replay PROBE_SECONDS=20 PROBE_OUT=<dir> KF_REPLAY_DIR=<recording> \
frame/tools/xrprobe/run-probes.sh # + MOCK_XR_DUMP_AT=4 MOCK_XR_DUMP_FRAMES=6 MOCK_XR_DUMP_STEP=8: dumps
python3 -I frame/perf/session-h13/replaycheck.py <dir>
```
The mock runtime (never SteamVR), 120 Hz, 1728 eyes, `MOCK_XR_TRACE_DETAIL=1`, `MOCK_XR_BUSY_MS=1`. 20 s a case
(2401 frames); images from a second 8 s run with dumps of 6 releases per swapchain at 4 s.
| case | mock summary | frame order in the trace (share of frames) | sets (probe) | images: dump vs shown set (k+-1) |
| --- | --- | --- | --- | --- |
| 1 | 0 errors, 4802 releases finished | wait, begin, locate +0, acquire L R, release L R, end [proj] 100 % | 2401 of 2401 = display-time set, steps 1: 2400 (loop of 168) | 0.19 (0.22: no motion) |
| 2 | 0 errors | same, 100 % | 2401 of 2401, steps 1: 2400 | 0.22 (7.54) |
| 3 | 0 errors | same, 100 % | 2401 of 2401 (offset 1), steps 1: 2400 | 0.22 (6.93) |
| 4 | 0 errors, 4750 releases finished | wait, begin, release L R, end [proj], locate +2, acquire L R: 98.4 % | 2359 of 2399; 31 repeats, 7 unshown, 24 late | 0.23 (7.60), 36 images |
| 5 | 0 errors | same with end [proj quad]: 98.6 % | 2367 of 2399; 26 repeats, 6 unshown, 20 late | 0.23 (7.60), 36 images |
| 6 | 0 errors | as case 1, 100 % | 2401 of 2401, steps 0: 1200, 2: 1200 (each set twice) | 0.22 (8.19) |
- Display times: every xrEndFrame carried its frame's predicted display time (2401 of 2401 per case, "end
display time = predicted"); the layer's display time steps one refresh in 2400 (cases 4, 5: 2398 of 2398).
- Poses: every projection layer "poses from no known locate" (the recorded ones, not a locate's), fov exactly
the recorded L -1.0311, 0.8795, 0.8634, -1.0472 / R -0.8777, 1.0325, 0.8706, -1.0393 rad; locates for the
frame's own time (cases 1-3, 6) or two refreshes ahead (4, 5); the quad on its own 16x16 swapchain, LOCAL,
size 0.010, alpha-blend flag.
- Images: every dumped released eye image matches the recorded image of the set the probe logged for that
frame and eye, resampled as the linear blit does (mean absolute difference 0.19-0.23 of 255 outside the label
strip), nearer it than to sets k-1 and k+1 in 12 of 12 (cases 1-3, 6) and 36 of 36 (4, 5).
- Cases 4, 5: the repeats, late and unshown sets all fall within four frames after an xrWaitFrame that returned
less than 2 ms after the last (the probe scheduled late on a busy machine, the mock then catching up): 40 of 40
and 32 of 32 frames. KartFrame's loop does the same there (no wait inside the cycle); on the headset these
show in the SUMMARY's late / unshown counts.
- The wrapper path: queue.sh into a scratch KARTFRAME_HOME, then `frame/kartframe.sh` with the mock's
XR_RUNTIME_JSON: the queue file became session.env, the wrapper ran replay-case.sh, xrprobe.log landed in the
session folder, the queue advanced to the next case.
## Headset results (2026-10-15, orchestrator's reading)
Owner, six launches in order: launch 1 "no doubling at all when moving my head"; launches 2 to 5 "no
doubling"; launch 6 "no doubling". Remark: a quality difference, "not full 3D all the way around" (expected:
the replay shows fixed recorded images, half resolution (dump step 2), moved with the head, so nothing new
appears at the edges on a head turn).
Reading: KartFrame's race eye images, through GalaxyFrame's loop AND through KartFrame's own order (three
pairs, locate two refreshes ahead, the race quad), with the 60 Hz content phase either way, and held two
refreshes, are all clean on this SteamVR. So the images (lane S), the poses and prediction distance (H12),
the layer structure, pair count, quad and content phase (this session) are all cleared. What is left is what
differs between the probe and the live game at submission: the game's GPU work on the swapchain image and
when SteamVR sees it (the probe's images are uploaded once; the game renders into the image every set and
releases after its fence), Dawn's queue and the point at which its commands are really submitted relative to
the fence the release waits on, the Vulkan layer chain of the game process, and GPU contention from the
game's own ~5 ms per set. Strongest old clue in this light: H7 found the jump size follows the pair count
(2 pairs: clean +-2-refresh jumps; 6 pairs: 1 to 8 refreshes), i.e. the swapchain image reuse distance.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Headset session 13: installs the replay beside KartFrame and queues its six launches, one case each.
# frame/perf/session-h13/queue.sh XRPROBE_BINARY [RECORDING_DIR]
# Copies the binary and replay-case.sh into $KARTFRAME_HOME/h13/ and writes h13-01..06-<case>.env into
# $KARTFRAME_HOME/session.queue/. The KartFrame shortcut's wrapper then takes one per launch, in name order,
# and runs the case instead of the game (KARTFRAME_EXE), with its own session folder (xrprobe.log, stdout.log,
# vrtiming.log) and the panel recording (KARTFRAME_RECORD=raw). Nothing else changes.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
BIN=${1:?xrprobe binary}
REC=$(cd "${2:-$HOME/.cache/kf-laneT/dump-h13rec}" && pwd)
[ -x "$BIN" ] || { echo "no xrprobe binary at $BIN" >&2; exit 1; }
[ -s "$REC/index.txt" ] || { echo "no recording (index.txt) in $REC" >&2; exit 1; }
mkdir -p "$KFHOME/h13" "$KFHOME/session.queue"
cp "$BIN" "$KFHOME/h13/xrprobe"
cp "$HERE/../../tools/xrprobe/replay-case.sh" "$KFHOME/h13/"
OTHER=$(ls "$KFHOME/session.queue"/*.env 2> /dev/null | grep -v '/h13-' || true)
[ -n "$OTHER" ] && echo "note: other queued launches come first or in between (name order): $OTHER"
[ -f "$KFHOME/session.env" ] && echo "note: $KFHOME/session.env is waiting and goes before the queue"
while IFS='|' read -r num name label args; do
F=$KFHOME/session.queue/h13-0$num-${name#*-}.env
cat > "$F" << ENV
# Headset session 13, launch $num of 6 (frame/perf/session-h13/README.md): xrprobe --replay case "$label"
# ($args). Look at a track-side edge, turn the head slowly left and right for ten seconds, hold still;
# again with one eye closed, then the other. Doubled or not, which eye?
KARTFRAME_EXE=$KFHOME/h13/replay-case.sh
KF_REPLAY_CASE=$num
KF_REPLAY_DIR=$REC
XRPROBE=$KFHOME/h13/xrprobe
PROBE_LOG=$KFHOME/sessions/latest/xrprobe.log
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
ENV
echo "queued $F"
done < <("$HERE/../../tools/xrprobe/replay-case.sh" --list)
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Mock check of the session-13 replay: python3 -I replaycheck.py <run-probes PROBE_MOCK=1 output dir> [case ...]
Per case (xrprobe log <case>.log, mock trace <case>.trace, dumps <case>.dump/ with MOCK_XR_DUMP_AT):
- frame order: the calls of each frame in the mock trace (waitFrame, beginFrame, locate +refreshes, acquire,
release, endFrame with its layers), the commonest patterns and their share;
- display times: endFrame display time against the frame's predicted one; layer poses (from no locate:
the recorded ones) and fields of view as submitted;
- images: each dumped (released) eye image against the recorded image of the set the probe showed in that
frame (R lines), resampled as the probe's linear blit does (in linear light), mean absolute difference in
8-bit steps outside the label strip; the neighbouring sets k-1 and k+1 for scale.
"""
import os, re, sys
from collections import Counter
D = sys.argv[1]
CASES = sys.argv[2:] or sorted(f[:-4] for f in os.listdir(D) if f.endswith('.log'))
TO_LIN = [((c / 255) / 12.92 if c / 255 <= 0.04045 else ((c / 255 + 0.055) / 1.055) ** 2.4) for c in range(256)]
def to_srgb(v):
v = min(max(v, 0.0), 1.0)
return 255 * (v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055)
def ppm(path):
b = open(path, 'rb').read()
m = re.match(rb'P6\s+(\d+)\s+(\d+)\s+255\s', b)
w, h = int(m.group(1)), int(m.group(2))
return w, h, b[m.end():m.end() + w * h * 3]
for case in CASES:
log = open(os.path.join(D, case + '.log')).read()
m = re.search(r'replay: window (\S+), (\d+) sets of (\d+)x(\d+)', log)
win, nsets, rw, rh = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
shown = {int(a): int(b) for a, b in re.findall(r'R n=(\d+) \S+ \S+ set=(-?\d+)', log)}
# frame order
frames, cur = {}, None
for line in open(os.path.join(D, case + '.trace')):
mm = re.match(r'f(\d+) (\S+)(.*?)( @[-\d.]+)?$', line.rstrip('\n'))
if not mm or line.startswith('f0 ') or mm.group(2) in ('dump',):
continue
f, call, rest = int(mm.group(1)), mm.group(2), mm.group(3)
if call == 'locate':
call += re.search(r'display=(\S+)', rest).group(1)
elif call == 'endFrame':
call += '[' + ' '.join(re.findall(r'(proj|quad)\[', rest)) + ']'
elif call in ('acquire', 'release', 'waitImage'):
call += ' ' + rest.split()[0]
elif call in ('endInfo', 'layer0', 'layer1', 'view0', 'view1') or call.startswith(('view', 'layer')):
continue
frames.setdefault(f, []).append(call)
pats = Counter(' > '.join(re.sub(r' sc\d+', '', c) for c in v) for f, v in frames.items() if f > 5)
total = sum(pats.values())
tr = open(os.path.join(D, case + '.trace')).read()
ends = len(re.findall(r'endInfo .*display=\+0\.0000ms', tr)), len(re.findall(r'endInfo ', tr))
nopose = len(re.findall(r'proj space=s0:LOCAL .* poses from no known locate', tr)), len(re.findall(r'layer0 proj ', tr))
fovs = Counter(re.findall(r'(view\d) sc\d+ .* fov=(\S+) @', tr))
print(f'== {case}: window {win}, {nsets} sets of {rw}x{rh}, {len(frames)} frames in the trace')
for p, c in pats.most_common(3):
print(f' {100 * c / total:5.1f} % {p}')
print(f' end display time = predicted: {ends[0]} of {ends[1]}; projection layers with poses from no locate '
f'(the recorded ones): {nopose[0]} of {nopose[1]}; fov ' + ', '.join(f'{v} {f} x{c}' for (v, f), c in sorted(fovs.items())))
# images
dd = os.path.join(D, case + '.dump')
if not os.path.isdir(dd):
continue
idx_src = re.search(r'recording (\S+),', open(os.path.join(D, case + '.stdout')).read()).group(1)
sets = {}
for line in open(os.path.join(idx_src, 'index.txt')):
p = line.split()
if p[0].startswith(win + '-') and p[3] in '01' and int(p[4]) > 0:
sets.setdefault(int(p[4]), {})[int(p[3])] = p[0]
order = [sets[t] for t in sorted(sets) if len(sets[t]) == 2][:nsets]
res = []
for line in open(os.path.join(dd, 'index.txt')):
f, sc, fr = line.split()[:3]
sc, fr = int(sc[2:]), int(fr[1:])
k = shown.get(fr, -1)
if sc >= 2 * 8 or k < 0:
continue
w, h, px = ppm(os.path.join(dd, f))
if w * 8 != 1728 or rw != 648:
continue
eye = sc % 2
scores = []
for kk in (k, (k - 1) % nsets, (k + 1) % nsets):
_, _, src = ppm(os.path.join(idx_src, order[kk][eye]))
tot, cnt = 0.0, 0
for j in range(0, h, 2):
if 0.15 * h <= j <= 0.30 * h:
continue
y0, y1 = max(3 * j - 1, 0), 3 * j
for i in range(0, w, 2):
x0, x1 = max(3 * i - 1, 0), 3 * i
for c in range(3):
a = lambda y, x: TO_LIN[src[(y * rw + x) * 3 + c]]
v = (0.3125 * (0.3125 * a(y0, x0) + 0.6875 * a(y0, x1)) + 0.6875 * (0.3125 * a(y1, x0) + 0.6875 * a(y1, x1)))
tot += abs(to_srgb(v) - px[(j * w + i) * 3 + c])
cnt += 1
scores.append(tot / cnt)
res.append((fr, eye, k, scores))
if res:
worst = max(r[3][0] for r in res)
best = sum(1 for r in res if r[3][0] < min(r[3][1:]))
print(f' dumped eye images: {len(res)} (frames {min(r[0] for r in res)}-{max(r[0] for r in res)}); against the shown set: '
f'mean abs diff max {worst:.2f}, closer than k-1 and k+1 in {best} of {len(res)}; '
f'k+-1 mean {sum(sum(r[3][1:]) / 2 for r in res) / len(res):.2f}')
+8
View File
@@ -0,0 +1,8 @@
# Headset session 14, launch 1 of 2 (lane U, frame/perf/session-h14/README.md): no desktop window swapchain.
# KARTFRAME_HEADLESS=1: aurora never configures or presents its Gamescope WSI swapchain (the 60 Hz present on
# the eyes' queue that neither the clean xrprobe replay nor any mock run has). Everything else as built.
# Routine: menus, then a Time Trial; head turns during the countdown (standing), then while driving.
KARTFRAME_HEADLESS=1
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
@@ -0,0 +1,8 @@
# Headset session 14, launch 2 of 2 (lane U, frame/perf/session-h14/README.md): desktop window as usual, Dawn's
# queue drained before each set's releases and its xrEndFrame (KARTFRAME_RELEASE=idle), so SteamVR's client
# submissions there wait behind nothing of ours; game.log reports the drain every 10 s ("release idle drain").
# Routine: the same as launch 1.
KARTFRAME_RELEASE=idle
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+69
View File
@@ -0,0 +1,69 @@
# Headset session 14 (lane U): the desktop present and the queue at SteamVR's submissions
Lane U (`frame/perf/LANE_U.md`) proved headless that every set is released only after the GPU finished
writing it (Dawn's render submission precedes the timeline signal; a new strict mock check: 0 of about
143000 released images changed between release and next acquire, positive control 97 %), and that at the
release our queue is nearly empty (a submission made there completes in 0.3-0.45 ms p50), as in session 13's
clean xrprobe replay. What the live game has and the probe and every mock run lack: a desktop window
swapchain (Gamescope WSI, Mailbox) presented once per game frame, 60 Hz = every second refresh, on the
same VkQueue as the eyes and SteamVR's client. Two launches test it from both sides.
| launch | env file | change | logs to read |
| --- | --- | --- | --- |
| 1 | `01-no-desktop.env` | `KARTFRAME_HEADLESS=1`: no desktop swapchain, no presents (as in every mock run) | panels, XRF lines, vr pacing |
| 2 | `02-release-idle.env` | `KARTFRAME_RELEASE=idle`: desktop as usual; vkQueueWaitIdle on Dawn's queue before the releases and before xrEndFrame | plus `release idle drain` every 10 s: how much of ours was still queued at those points on the headset |
Both: `KARTFRAME_FRAME_LOG=1`, `KARTFRAME_RECORD=raw` (panels.mkv), `KARTFRAME_SESSION_ENV_ONCE=1`. Session 12
launch 1 (`frame/perf/session-h12/01-measure.env`) is the control if one is wanted (doubled in all 13
sessions so far).
## Install
Build lane U's branch, `install --dev` (the switch is in the binary; launch 1 needs nothing new). Copy both
env files into `$KARTFRAME_HOME/session.queue/` (default
`~/.local/share/KartFrame/session.queue/`); the wrapper takes one per launch, in name order.
Owner routine, each launch: menus, then straight into a Time Trial. During the countdown (kart standing) look at
a track-side edge and turn the head slowly left and right for ten seconds, then the same while driving, then
each with one eye closed. Say: doubled or not, which eye.
Launch 1 note: the desktop window stays empty (never configured). If Steam or gamescope objects to a window
that never presents (no image in the headset, a stuck launch), end the launch and report it; launch 2 does
not depend on it.
## Reading it
- `frame/tools/session-report.py <session>` (pacing, late, GPU ms), `frame/perf/panels/analyse.sh <session>`
and H7's zigzag rule per eye on the race (session-h12's zig2.py); menus-like floor 4-6 %.
- **Launch 1 clean, launch 2 doubled**: the desktop present is it (and not by queue backlog at the
releases). Fix: no desktop swapchain in VR (make KARTFRAME_HEADLESS's path the VR default, or stop presenting
while the XR session runs). Launch 2's drain line then says whether the present at least delayed our queue.
- **Launch 2 clean** (either way for launch 1): a backlog on our queue at SteamVR's release or end submissions,
which the mock does not have; launch 2's drain line gives its size (mean / max / >1 ms / >4 ms per 10 s;
the mock: 0.3-0.4 ms before the releases, 0.1 before the end, that is the call's own round trip). Next:
find what is queued there (the drain size by frame parity) and order it after the release cheaply.
- **Both doubled**: neither the present nor our queue at SteamVR's submissions. Next candidate (LANE_U.md,
rank 3): GPU contention with SteamVR's compositor from the game's 3-5 ms a set, heavy and light sets
alternating at 60 Hz; test with a render-scale-0.5 measurement launch or an xrprobe replay with an added
60 Hz GPU load.
- Pacing in launch 2: expect xrEndFrame later than 1 ms after the wake-up in about 10-13 % of frames (the
drain's round trip; mock), no late sets. Late sets above a few percent make launch 2 unreadable.
## Headset results (2026-10-15, orchestrator's reading)
Owner: launch 1 (no desktop) "bad, right eye in particular doubling"; launch 2 (release idle) "bad too, but
the left eye didn't have doubling, just a lag spike every 3-4 seconds; the right eye was bad".
Logs: launch 1 ran headless (aurora "headless: N frames presented to the offscreen target"; no desktop
swapchain), race 119.6 sets/s, 0.7 % late, GPU 5.3 ms p50, ends > 1 ms 3.3 %: the desktop present is NOT
the cause. Launch 2: the drain before the releases took 4-4.9 ms mean (> 4 ms in ~85 % of releases: the
set's own render, not a backlog), before the end 0.4-1.3 ms; cost: 36.8 % late sets, 36 % retained repeats,
ends > 1 ms 91 %, 76.6 sets/s (the lag spikes). SteamVR's compositor timing (vrtiming) is identical between
the doubled launch 1 and session 13's clean probe case 4: 120 frames/s, 0 reused/dropped, compositor GPU
0.85 vs 0.77 ms, vsyncs to photons 1.00.
Reading: candidates 1 (desktop present) and 2 (backlog at release) are out. What remains is candidate 3,
our GPU work (3-5 ms per set, heavy and light sets alternating at 60 Hz) against the compositor's per-eye
compose inside the refresh; launch 2 shifted our render by ~4 ms and cleared the LEFT eye only, which fits
a phase collision that hits one eye's compose window. Session 15 tests the load from both sides: 90 Hz at
1728 (11.1 ms refresh) and 120 Hz at 1200 px (half the work, measurement only).
+9
View File
@@ -0,0 +1,9 @@
# Headset session 15, launch 1 of 2: the race at 90 Hz, 1728 per eye (the owner's accepted last fallback).
# Session 14: the desktop present and queued GPU work at the release are not the cause; a drained queue
# cleared the left eye only. Candidate: our 3-5 ms GPU work per set (heavy/light alternating at 60 Hz)
# colliding with the compositor's per-eye compose inside the 8.3 ms refresh. 90 Hz gives 11.1 ms.
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
KARTFRAME_REFRESH_HZ=90
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+6
View File
@@ -0,0 +1,6 @@
# Headset session 15, launch 2 of 2 (measurement only, never a shipping setting): 120 Hz with the eyes at
# 1200 px, about half the GPU work per set. Clean here and doubled at 1728 = the GPU load is the cause.
KARTFRAME_EYE_PX=1200
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+16
View File
@@ -0,0 +1,16 @@
# Headset session 15: GPU load from both sides (90 Hz at 1728; 120 Hz at 1200 px)
Two launches after session 14 (desktop present and release backlog ruled out; a drained queue cleared the
left eye only): `01-90hz.env` (KARTFRAME_REFRESH_HZ=90) and `02-eye1200.env` (new wrapper knob
KARTFRAME_EYE_PX=1200, measurement only). Both settings took effect (read-back 5 of 5; 90 Hz; 1200x1200
eye swapchains). Pacing: 90.7 sets/s and 120.5 sets/s, 0.0 % late in both; GPU per set 5.2 ms at 90 Hz
(1728) and 3.1 ms at 1200 px; ends > 1 ms 3.5 % and 3.1 %.
Owner: "bad again for both, but the second launch had less doubling with the right eye".
Reading: neither the refresh period (11.1 ms) nor halving the GPU work removes the doubling, so the SIZE of
our GPU work and the refresh rate are not the cause. Two default launches played by mistake just before
(no session file) doubled as always. What is left between the clean probe (session 13) and the game: the
game process itself around the hand-over, i.e. its threads. The priority boost (nice -15, 673408a) and the
utilisation clamp (4b87fcf) both landed after session 3's build (ff9b277, the only clean session) and were
in every doubled session since; session 16 turns them off (`session-h16/01-noprio.env`).
+13
View File
@@ -0,0 +1,13 @@
# Headset session 16, launch 1 of 1: the game WITHOUT its thread priority boost (RealtimeKit nice -15 on the
# threads a headset frame waits on, commit 673408a) and WITHOUT the utilisation clamp on the game thread and
# frame worker (commit 4b87fcf). Both landed on 2026-10-13 after session 3's build ff9b277, the only session
# without doubling, and were in every doubled session since (H4 build 2fe42c4 onwards). Everything else the
# game does has been cleared (sessions 12-15: poses, images, layers, pairs, quad, desktop present, release
# ordering, GPU load size, refresh rate). Candidate: the boosted threads delay SteamVR's compositor on the
# CPU inside the refresh, hitting one eye's compose. Expect the game thread to run a little less smoothly.
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
KARTFRAME_THREADPRIO=off
KARTFRAME_UCLAMP=0
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
@@ -0,0 +1,8 @@
# Headset session 16, launch 2: each set's render starts 4 ms after the wake-up instead of at once
# (KARTFRAME_AHEAD_START_US; sets are still two refreshes ahead, ends still at the wake-up). Session 14's
# drained launch shifted our GPU work by ~4 ms and cleared the left eye; this moves the work's phase inside
# the refresh without the drain's lateness. Routine as always.
KARTFRAME_AHEAD_START_US=4000
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+7
View File
@@ -0,0 +1,7 @@
# Headset session 16, launch 3: each frame is ended 4 ms after SteamVR's wake-up instead of at once
# (KARTFRAME_END_DELAY_US; the same set is shown, the next set still starts at the wake-up). Sessions 3 and
# 14 had frames ended 4-5 ms late and showed less double image; this tests it without session 14's queue drain.
KARTFRAME_END_DELAY_US=4000
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
+46
View File
@@ -0,0 +1,46 @@
# Headset session 16
Three launches, each one a single test of a CPU-side timing cause for the right-eye double image: 01 thread
priority boost and utilisation clamp off (01-noprio.env), 02 each set's render start delayed 4 ms after the
wake-up (02-ahead-start.env, KARTFRAME_AHEAD_START_US), 03 each frame ended 4 ms after the wake-up
(03-end-delay.env, KARTFRAME_END_DELAY_US). Routine for each: menus, Time Trial, head turns at the countdown,
driving, hold still, quit; analyse the KARTFRAME_RECORD=raw recording with frame/perf/panels/analyse.sh.
## Lane V mock check (launch 03)
Scripted race, frame/perf/hudrace.sh 90 s, MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
KF_MOCKRUN_END_GATE=100 KARTFRAME_END_DELAY_US=4000, against the same race with no delay (60 s):
| | end delay 4000 us | default |
|---|---|---|
| mock errors | 0 | 0 |
| end after wake-up p50 / p99 | 4.38 / 4.92 ms | 0.28 / 0.85 ms |
| ends with the predicted display time | 10659 of 10659 | 7198 of 7198 |
| sets rendered a second (late, repeats) | 118 of 120 (0 late, ~0 repeats) | 120 (0, 0) |
| end_late= a second | all (by design; the end gate was set to 100) | 0 |
The delayed end starts the next set before the sleep, as KARTFRAME_AHEAD_REFRESHES=1 does, which holds one
more eye pair until the end: the switch therefore defaults to 4 eye pairs. With 3 pairs the early start
found no free pair, the set started after the end instead and the race fell to 80 sets a second with 40
repeats and pairwait=40 a second.
## Headset results (2026-10-15, orchestrator's reading)
Owner: launch 1 (priority boost and uclamp off) "both eyes bad and doubling"; launch 2 (set start delayed
4 ms) "left eye good, right eye doubling"; launch 3 (frame ended 4 ms after the wake-up) "BOTH EYES GOOD,
NOT DOUBLING; the right eye had a slight jitter, more sensitive to head movements, the left eye dead on".
The first clean race since session 3. Logs, launch 3: ends 4.50 ms after the wake-up p50 (XRF e: 7083 of
7324 race frames in 4-5 ms, 210 in 5-6, 31 later than 6), race 120.3 sets/s, 0.2 % late, 2.3 % retained
repeats, 4 eye pairs; GPU per set 7.0-7.3 ms p50 (launch 1: 4.8; the next set now starts before the end).
Launch 2: 117.9 sets/s, 3.2 % late. Launch 1: 119.8, 0.7 % late, ends at 0.35 ms.
Reading: WHEN the frame is ended inside the refresh decides the doubling. Ended at the wake-up (within
~0.4 ms; every doubled session 4-15): doubled. Ended ~4.5 ms after the wake-up: clean in both eyes. Session
14's drained launch (ends 5.5 ms, left eye clean) and session 3 (late ends, clean) were the same effect.
Model: SteamVR's compositor on the Frame picks up the app's layer for each eye at its own point inside the
refresh, the right eye later than the left; an end that lands near a pick-up point is seen by one eye for
this refresh and by the other for the next, so consecutive refreshes disagree (the period-2 zigzag, worse in
the eye whose pick-up it straddles, which eye depending on where the end lands). The right eye's residual
jitter at 4.5 ms says its pick-up is near there. Session 17 sweeps the delay (2.5 ms and 6 ms) to find the
window; then the clean value becomes the default and its latency cost is measured.
@@ -0,0 +1,7 @@
# Headset session 17, launch 01 of 2: frames ended 2500 us after the wake-up (session 16: 4000 us was clean in
# both eyes with a slight right-eye jitter; at the wake-up every session doubled). Finds the window's edges.
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
KARTFRAME_END_DELAY_US=2500
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
@@ -0,0 +1,7 @@
# Headset session 17, launch 02 of 2: frames ended 6000 us after the wake-up (session 16: 4000 us was clean in
# both eyes with a slight right-eye jitter; at the wake-up every session doubled). Finds the window's edges.
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
KARTFRAME_END_DELAY_US=6000
KARTFRAME_FRAME_LOG=1
KARTFRAME_RECORD=raw
KARTFRAME_SESSION_ENV_ONCE=1
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more