mommysgoodpuppy 45c304f65d Validate portal capture and publish experimental ARM64 executables
Pin the helper with access-scoped PipeWire connections and safe BGRA packing.
Add helper-only capture session overrides and a reproducible isolated KWin/KDE
portal test through PetPlay's real receiver. Document passing native Linux
capture checks and the remaining physical Frame validation.

Run capture buffer tests on both Linux CI architectures and attach the bundled
ARM64 executable to releases with desktop executables and SHA256 checksums.

Agent: Codex 0.163.0; model gpt-6.1-sol; thread 01a145fa-213b-7510-83c0-48dd3e275c9a
2026-10-17 00:28:56 +03:00
2026-04-25 03:51:15 +03:00
2026-08-19 21:34:50 +03:00
2026-09-19 21:02:12 +03:00
2026-07-12 22:46:19 +03:00
2025-04-03 18:40:00 +03:00
2025-10-17 18:35:57 +03:00
2025-05-08 00:09:07 +03:00

A modular overlay augmented reality system

Join the discord! https://discord.gg/Ms24sS9kEa

How the pieces fit together — process topology, every actor and its messages, the scene/render split, and the supervision state machine — is in docs/ARCHITECTURE.md. The Steam Frame investigation records the ARM64 image, QEMU results, and remaining hardware checks.

YouTube demo

Demo

Reqs: Npm, Deno, SteamVR, Windows or Linux, optionally vscode. Building releases also needs Rust and the native capture build dependencies described below.

Clone: git clone -v --recurse-submodules --progress "https://github.com/goodpuppies/petplay.git"

The capture helper source is in submodules/screen-streamer; classes/ScreenCapturer/ is PetPlay's canonical TypeScript client. Release builds compile this helper from source and embed the matching native executable.

Setup:

  1. deno run preconf
  2. deno install

Usage:

  1. deno run dev

Building a release binary

deno run -A utils/preconf.ts
deno install --allow-import
deno task build:native   # source/dev capture helper; requires Rust
deno task build          # builds WebXR and the capture helper, then compiles into dist/

utils/build.ts wraps deno compile and branches only where Deno itself does not: --icon is Windows-only (it is rejected outright on Linux), and dist/ keeps the output clear of the petplay/ source directory on the OSes where the binary carries no extension. The compiled launcher boots straight into dev, so runtime flags still apply — ./dist/petplay --novr, ./dist/petplay --novr --desktop, and so on. It is compiled with --env-file, so run it from a directory with the .env it should use.

Release builds require Node.js 20+ and pnpm 10.33.4 for the pinned WebXR runtime, plus Rust for the capture helper. These tools are only needed when building from source.

On Linux, native capture builds require pkg-config, Clang, PipeWire development headers and D-Bus development headers (Ubuntu: libpipewire-0.3-dev libdbus-1-dev). Windows builds use the Rust MSVC toolchain and Windows SDK. Development checkouts run deno task build:native once before using desktop capture; release builds do this automatically.

For a Steam Frame ARM64 build, run deno task build --target=aarch64-unknown-linux-gnu to create dist/petplay-aarch64. This cross build leaves the developer machine's .env out; supply runtime settings on the headset. ARM64 builds require Zig 0.13.0 (PETPLAY_ZIG can select the binary); local cross builds also need PETPLAY_RAYLIB_SYSROOT pointing to target GLFW development libraries. Native ARM64 CI builds a test-only GLFW 3.4 fixture. See the raylib build recipe. The native helper builds and remaining hardware checks are in docs/STEAM_FRAME.md.

CI checks types, runs unit tests and a software-OpenGL smoke playtest, and builds Windows x64, Linux x64 and experimental Linux ARM64 artifacts. The ARM64 executable embeds its source-built capture helper and raylib 5.5 (built with pinned Zig and external GLFW). It requires the Frame runtime’s GLFW/OpenGL/OpenVR libraries. Normal version-tag releases contain desktop builds only while device validation continues. See the Frame testing runbook for Quest/DroidFrame testing and known limits. Default Steam Frame controller bindings are included, alongside Oculus Touch and the separate DroidFrame engineering profile.

Child processes work the same as from a checkout: a compiled build has no Deno CLI to run a module with, so it re-execs its own binary and imports the child module out of its embedded snapshot (classes/childModule.ts), keeping the desktop control surface, the decoupled view and the display overlay host identical in both forms.

Linux / SteamVR

PetPlay discovers the native OpenVR library from /usr/lib or SteamVR and uses the bundled raylib 5.5 shared library. Both paths can be overridden when needed:

PETPLAY_OPENVR_LIBRARY=/path/to/libopenvr_api.so \
PETPLAY_RAYLIB_LIBRARY=/path/to/libraylib.so \
deno task dev

Nested WebXR dependencies require their generated checkout artifacts once after cloning:

deno task build:webxr
(cd submodules/threewebxrwebgpudeno/submodules/uikit && pnpm install --filter @pmndrs/uikit... --ignore-scripts && pnpm --filter @pmndrs/uikit generate)

With SteamVR running, deno task openvr:probe is a lightweight native runtime check. deno task dev starts the full RayThree/WebXR raylib overlay path.

For desktop-only development without SteamVR, run deno task dev:desktop. This starts the Raylib desktop-control window, WebXR scene, wrist menu, VRC camera OSC receiver, and agent REPL. Desktop capture/display presentation is currently disabled in this mode because it is still backed by an OpenVR overlay.

Dev install: follow SteamVR (Linux)

deno task install:dev installs the checkout as a systemd user service (~/.config/systemd/user/petplay-dev.service) running utils/steamvr-watch.ts: PetPlay starts when SteamVR starts and stops when SteamVR goes away, with no terminal attached. deno task dev:steamvr runs the same watcher in the foreground.

deno task install:dev                 # server follows SteamVR
deno task install:dev -- --desktop    # ... and brings up the desktop control surface
deno task dev:steamvr -- --desktop    # the same watcher, foreground, this shell only
deno task dev:status                  # unit path, state, and the command it runs
deno task uninstall:dev               # stop, disable and delete it

Presence is a process watch, not an OpenVR probe. The watcher looks for the runtime's own process (vrserver; --steamvr-process=<name>[,<name>] overrides it), because VR_IsRuntimeInstalled stays true for an installed-but-not-running SteamVR, and a probe would keep a server alive forever polling a runtime that is not there. Servers started by this watcher also use attach-only OpenVR initialization: after the observed vrserver exits, PetPlay cannot relaunch SteamVR and waits for a new manually started runtime process instead. Arguments the watcher does not recognise go to the server, so --novr, --desktop, --keyboard-locale=fi and --agent-repl-port=<n> all work as they do with deno task server.

A server already answering on the REPL port is left alone: the watcher adopts nothing and stops only what it started, so a hand-started deno task server is never killed by SteamVR quitting. Server flags are baked into the unit at install time, so changing them means re-installing; environment knobs (PETPLAY_AGENT_REPL_PORT, PETPLAY_KEYBOARD_LOCALE, …) belong in the checkout's .env, which both the watcher and the server load. The unit is ordered after graphical-session.target — SteamVR and the desktop surfaces need the session's WAYLAND_DISPLAY/XDG_RUNTIME_DIR — journalctl --user -u petplay-dev.service -f has the launcher, and the server's own log stays in logs/ as usual.

On KDE Wayland, select Full Workspace in the initial ScreenCast portal dialog. PetPlay requests persistent capture access and stores the rotating, single-use restore token under $XDG_CONFIG_HOME/petplay (normally ~/.config/petplay), so later launches restore the workspace without prompting unless access was revoked or the workspace configuration is no longer valid. Keyboard and pointer injection use /dev/uinput and therefore do not require a remote-control portal dialog. The capture helper starts with the display actor and remains alive independently of virtual display creation or visibility. Future virtual display units are crop views into this one workspace stream rather than separate portal captures.

Stageforge networking and its Iroh worker wrapper are disabled by default, so local actors use native Deno workers. To connect to the Stageforge signaling server, use deno task dev:network, pass --stageforge-networking to a launch, or set PETPLAY_STAGEFORGE_NETWORKING=1.

Server and clients

A server is a long-lived PetPlay process that owns the scene, the XR frame loop and SteamVR supervision. Shell commands are clients of it: they message the server's agent REPL rather than starting their own scene, so the desktop view is an actor the server starts, not a second process rendering its own copy of the world.

deno task server             # long-lived: scene + supervision + agent REPL on 3987
deno task status             # supervisor / scene / desktop view readout
deno task reboot             # stop the server, start it again with the same arguments
deno task desktop            # decoupled view: a window rendering the server's scene IR
deno task desktop -- --control   # interactive surface: its own scene, can grab (legacy path)
deno task desktop -- --stop      # tear the selected view down
deno task client capture -- --path=shot.png   # PNG of the scene, no window left running
deno task client detach-vr   # ordered SteamVR detach; the supervisor reattaches when it is back
deno task dev                # start a server if none is running, then start the desktop view

The decoupled view is an actor of the server (petplay/desktopView.ts), not a client scene: the server extracts the scene to raythree IR and ships each frame to the view (REGISTERWEBXRVIEW → WEBXRVIEWFRAME → ack), which rasterizes it with the same WebXRRaythreeRaylibRenderer the OpenVR overlay uses. A VR session and one or more desktop windows therefore show the same scene simultaneously at independent render rates, with a single scene update between them.

The preview takes no viewpoint input. It used to forward window drag/wheel into SETDESKTOPVIEWOFFSET, which moves the rendered XR pose only — the OpenVR overlays (the display mirrors, placed in absolute tracking space by displayOverlayHost) stayed where they were, so the two coordinate frames drifted apart. Moving the shared viewpoint now needs both the legacy interactive surface (--control) and the scene's grant (desktopViewControlAllowed); everything else is refused and leaves the offset at zero.

client capture is the same render and capture path with nothing left behind: with a preview running it asks that view for a PNG (REQUESTCAPTURE); with none running it starts a hidden one for a single frame (STARTDESKTOPVIEW { capturePath, hidden, width, height }, which waits for a payload that actually carries geometry), writes the PNG through the shared screen-capture module and closes the window. No preview has to be running, and none is left running. --width=/--height= set that one frame's size — larger than the default 1280×720 is how you read fine UI text.

deno task app is the previous foreground launcher (log mirroring, signal forwarding) for when you want the server in your terminal; dev:novr and dev:desktop still boot the --novr app directly. petplay/client.ts resolves the control port exactly like the server does (--agent-repl-port=<n>, then PETPLAY_AGENT_REPL_PORT, then 3987), so a client can be pointed at a server elsewhere on loopback.

deno task reboot is the restart path that does not need the terminal that started the server. It asks main for an ordered DETACHOPENVR while the old process is still alive, finds that process through /proc (the utils/dev-runner.ts launcher when there is one, so one SIGTERM becomes a cooperative shutdown), waits for that process to actually exit, starts a fresh server with the same launch arguments (so --novr, --webxr-raythree-debug=1, locale overrides and the like survive), then brings back whichever desktop surface was running. --no-detach skips the VR detach. Actor-level POST /reload remains the cheaper option when only an actor's code changed.

--watch-webxr does that reload for you: the server watches every local file the webxr actor loads (its deno info graph) and reloads the actor about 300 ms after a save. A save that does not parse, or imports a file that does not exist, is not reloaded. The old actor keeps running, and a headset toast names the file, because a worker that fails to load would leave no webxr at all. A SteamVR-launched server only gets the flag if it was registered with it (deno task steamvr:register -- --watch-webxr).

Self-healing presentation

The XR image can stop being drawn without any error surfacing — the pump keeps uploading into a texture SteamVR no longer imports, or the compositor stops drawing an overlay we still call present() on. webxr watches what SteamVR actually draws while an overlay is configured:

  • an overlay the compositor reports invisible for 2 s is re-shown, and its handle is rebuilt if showing does not stick;
  • no presented frame for 2.5 s restarts the rendering stack inside the actor (stop, then re-post the last STARTWEBXR) — up to 5 times, no process restart and no user action;
  • a failed overlay frame is no longer fatal: the handle is dropped, the XR tick keeps running, and the retry backs off (retrying at frame rate recreates the overlay dozens of times a second, which corrupted native state);
  • rebuilding the raylib render targets drops the overlay handle too, so SteamVR re-imports the new texture instead of drawing a freed one.

Every repair is counted and readable: deno task status shows presentation=ok repairs=1+0restarts (last: overlay invisible to the compositor), and deno task client repair (add --restart for the full stack) forces the same path by hand.

Overlay keys are claimed per host process (petplay.webxr.overlay.raylib.<pid>): two running instances no longer fight over one SteamVR overlay, and a session that died without cleanup leaves its key in tmp/webxr-overlay-claims.json so the next instance destroys that ghost overlay instead of drawing into a handle nobody can see.

SteamVR supervision

A server does not require SteamVR to be up, and it does not exit when SteamVR goes away. main is the supervisor: the control plane (wrist menu, VRC camera origin, WebXR scene, agent REPL, desktop view actors) starts immediately, and the SteamVR-facing actors — hmd, VRCOrigin, displayOverlayHost — are created when the runtime appears and murdered when it disappears. Start the server with SteamVR closed, open it later (or restart it) and the scene attaches on its own; between attachments the WebXR session keeps running with the same null-pointer payload --novr uses, so the desktop view and the spatial layout survive.

Ordering is the load-bearing part: the OpenVR client runtime must outlive every wrapper built from its interface pointers, so the supervisor stops webxr (overlay, pacer, IVRInput), then murders the dependants, waits for them to stop answering, and only then releases the runtime. Do not send RELEASE to the openvr actor while actors are attached; use deno task client detach-vr, which takes the ordered path.

Stop the PetPlay process itself (Ctrl-C, or kill -TERM <pid>) rather than its whole process group: a group signal kills the capture helper and the overlay children mid-flight, and the parent then tears down native state that its children were still using.

Keyboard locales

The world keyboard's cap legends come from a locale table, not from a hardcoded US layout:

deno task dev                                  # follows the host layout (see below)
deno task dev -- --keyboard-locale=fi          # explicit: --keyboard-locale / PETPLAY_KEYBOARD_LOCALE

Resolution order is --keyboard-locale=<id>, then PETPLAY_KEYBOARD_LOCALE, then the host's own keyboard layout (localectl status → X11 Layout/VC Keymap on Linux, because a Wayland session's setxkbmap reports XWayland's copy), then us. So a Finnish machine gets fi legends with no configuration, and a locale with no legend table simply leaves the default alone rather than relabeling every key. deno task status prints what was chosen and why: keyboard=fi (host-layout).

us (ANSI) and fi (Finnish KBDFI / SFS 5966 — ISO rows, å/ö/ä, the digit row's §½!"#¤%&/()=+´ legends and the AltGr (level 3) legends @£$€{[]}\|) are in classes/environment/keyboard/{usLayout,fiLayout}.ts. An unknown id throws instead of silently falling back, so a typo cannot relabel the caps over the host's keys.

Localization is limited to what is printed and reported: the OS input sinks still send scan codes, so the characters that reach applications are whatever the host layout makes of them. Keep the selected locale and the OS layout the same.

The active locale is switchable at runtime over the agent REPL, without a restart:

curl -s http://127.0.0.1:3987/eval -H 'content-type: application/json' -d "$(cat <<JSON
{"target":"webxr","timeoutMs":20000,"code":"(async () => { const m = await import('file://$PWD/classes/environment/keyboard/keyboardLocale.ts'); m.setKeyboardLocale('fi'); return m.getKeyboardLocaleId(); })()"}
JSON
)"

setKeyboardLocale notifies the React store, so the live keyboard relabels on the next frame; /reload {"actor":"webxr"} re-imports the modules and resolves the locale from the launch args again. classes/environment/keyboard/keyboardLocale.test.ts covers the legend tables, Caps Lock per locale, and AltGr resolution.

Overlay performance workflow

deno task overlay:perf measures the overlay's per-frame CPU from inside the webxr actor through the agent REPL. It launches a windowless run (--novr --agent-repl), drives synthetic controllers through the production input path (IWER → @pmndrs/xr pointers → @pmndrs/handle → r3f), and prints one row per interaction scenario:

deno task overlay:perf                                  # idle, hover, trigger, drag, sweep, scroll
deno task overlay:perf -- --scenario=hover,drag          # subset; `none` measures without input
deno task overlay:perf -- --seconds=6                    # seconds per scenario
deno task overlay:perf -- --profile                      # + V8 CPU profile of the worker
deno task overlay:perf -- --json                         # machine-readable report
deno task overlay:perf -- --attach --scenario=none --seconds=30   # watch your own running session

Use --attach against a session you already have open — including a headset run (deno task dev), where --scenario=none observes your own hands without installing synthetic input. Other scenarios temporarily override the hands, including with live IVRInput, and restore the controller bridge afterwards. r3f avg is the r3f advance() cost per frame (useFrame jobs + pointer raycasting) as reported by WebXRHost, r3f max the worst single frame in the phase, and ovl fps appears once the raylib overlay is presenting.

Both halves of the workflow are in utils/: overlay-perf.ts (launch/attach, REPL transport, report and profile analysis) and overlay-perf-harness.ts (runs inside the webxr worker: synthetic input, scenarios, metric sampling, optional profile capture). The same --eval workflow is available by hand against http://127.0.0.1:3987; see petplay/agentRepl.ts for the endpoints and utils/overlay-perf.ts for how it drives them. Useful live signals while doing this in VR: --webxr-frame-logs ([PERF] / [FPS] lines), --webxr-r3f-job-logs (per-scheduler-job cost) and --webxr-cpu-profile=<path> (V8 profile written by the webxr worker itself).

A perf run never touches your saved spatial layout: launched runs are pointed at tmp/overlay-perf-layout.json, and the harness snapshots and restores the layout file when scenarios run against a session it did not launch.

S
Description
GitHub pull mirror of goodpuppies/petplay. GitHub repository ID: 796366533. Gitea import mode: github-full.
https://github.com/goodpuppies/petplay Readme
68 MiB
0 Stars 1 Watchers 0 Forks
2026-10-16 23:33:15 +02:00
Languages
TypeScript 92.8%
Python 4.2%
C++ 1.8%
Shell 0.7%
GLSL 0.3%
Other 0.1%