22 KiB
Pixel VR Player — TODO & Handoff
Native C++/OpenXR/Vulkan/FFmpeg VR video player for the Valve Steam Frame (Snapdragon 8 Gen 3, Adreno 750 / Turnip, SteamOS/aarch64). This file is the handoff for the next agent. Deep device/build facts live in repo memory
/memories/repo/steamframe-facts.md— read that too.
Active backlog (2026-10-20 — Completed)
- Virtual Keyboard Tilt Inversion, Raycast Auto-Focus & UI Hide Refinements
- Symptom 1: Keyboard spawned tilted with the top edge pitched forward (slanted away from user).
- Resolution 1: Corrected tilt angle to -0.38f\ (-21.8 degrees). The top edge slants backward away from user; face normal tilts upward directly toward the user's eyes.
- Symptom 2: Laser beam shot 2.5m past the keyboard with no cursor dot on the keys; laser wasn't focused.
- Resolution 2: Added keyboard plane collision test in \VulkanRenderer::updatePointer()\ using \KeyboardModalData::transform. Laser ray terminates directly on the keyboard surface with glowing cursor dot on hovered keys. Also ensured active controller laser turns on automatically when keyboard opens, and guarded background menus from eating keyboard trigger clicks.
- Symptom 3: Trigger pull while HUD dismissed didn't reactivate the UI unless hitting a hidden button; empty-space clicks re-dismissed the UI in the same frame.
- Resolution 3: Recorded \wasHudDismissed\ at frame start and guarded dock interactions with !wasHudDismissed. Trigger click cleanly un-hides the HUD without clicking hidden buttons or re-dismissing in empty space.
- Symptom 4: Analog grip squeeze was overly sensitive and accidentally reactivated the dismissed UI.
- Resolution 4: Ignored soft analog grip (\in.gripDown\ and \BtnGrip); only digital full grip click stop (\in.gripFullPressDown) un-hides the HUD.
Active backlog (2026-10-19 — Completed)
-
1. Fix Virtual Keyboard Laser Pointer Focus & Tracking
- Symptom: Virtual keyboard opened, but laser pointer disappeared or wasn't focused on the keyboard; user had to squeeze/press the controller grip fully to activate it. Also, the keyboard centered where the Steam menu was centered when the app launched instead of where the user centered their view.
- Root Cause:
- SteamVR's virtual keyboard overlay key is
VRKeyboard_uid0(from CEFwebhelper), notvalve.steam.gamepadui.keyboard. Previous lookups failed, soMakeOverlaysInteractiveIfVisibleandVROverlayInputMethod_Mousewere never applied to the actual keyboard overlay. VulkanRenderer::updatePointer()was returning early (if (keyboardModal_.visible) return;), which prevented raycasts to the modal plane and leftpointerActive_false with no cursor dot.SetKeyboardTransformAbsolutewas never called with the user's current centered view orientation/position, so SteamVR defaulted to the launch standing origin.
- SteamVR's virtual keyboard overlay key is
- Resolution:
- Implemented dynamic overlay discovery across handles 1..128 and known keys (
VRKeyboard*,valve.steam.gamepadui.keyboard,system.keyboard). - Configured
MakeOverlaysInteractiveIfVisible,VROverlayInputMethod_Mouse, andSendVRDiscreteScrollEventson the keyboard overlay. - Calculated world matrix in
TrackingUniverseStandingdirectly from the user's HMD pose (or OpenXRheadPosandheadForward): placed 1.05m in front of eyes, 0.38m below eye level, tilted 28° up toward the eyes. - Applied transform via
SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMat)andSetOverlayTransformAbsolute. - Updated
VulkanRenderer::updatePointer()to raycast against the modal plane whenkeyboardModal_.visibleis active, ensuring sharp laser pointer focus and a visible cursor dot. - Guarded
VrKeyboard::shutdown()to prevent crash on exit.
- Implemented dynamic overlay discovery across handles 1..128 and known keys (
-
2. Controller Movement UI Dismissal Fix
- Symptom: When the user clicked "Hide UI" or clicked outside controls, any hand movement immediately reactivated the UI.
- Requirement: Once dismissed, the UI must stay hidden regardless of controller movement/rotation, and only reactivate when the user explicitly presses a physical button on the controller.
- Resolution:
- In
App::run(), whenhudDismissed_is true, controller movement (moveDist) and rotation (rotDiff) are blocked from clearinghudDismissed_or settingactivity = true. - Only physical button presses (
in.togglePlay,in.browserToggle,in.recenter,in.buttonDown[...],in.gripDown,in.gripFullPressDown,in.selectoutside cooldown, or thumbstick movement) reactivate the HUD.
- In
-
2. Strict IPv4 Validation & Live Sanitization
- Symptom: Host field accepted invalid characters, spaces, and did not strip leading zeros.
- Resolution:
- Created
src/util/ipv4.hppwithsanitizeIpv4()andisValidIpv4(). - Live typing sanitization: automatically strips non-digits/dots, discards duplicate dots, clamps octets to 255, drops leading zeros in each octet (e.g.
001->1,010->10,00->0), limits to 4 octets, and preserves trailing dots while typing. - Added automated unit test suite
tests/test_ipv4.cppand verified all test cases pass on the device. - Verified strict
isValidIpv4()validation before saving an SMB share insaveSmbModal().
- Created
-
3. Fix
???Glyphs (Unicode Star Elimination)- Symptom: Sidebar and header button displayed
???instead of the star icon. - Root Cause: FreeType SDF text atlas was initialized only for ASCII codepoints 32..127. UTF-8 multi-byte sequence
★(\xe2\x98\x85) produced 3 replacement?characters. - Resolution: Replaced all UTF-8 star glyphs with clean ASCII strings:
"Bookmarks","[ Bookmarked ]", and"[ + Bookmark ]". Adjusted header breadcrumb max width to0.46fto prevent collision with the header button.
- Symptom: Sidebar and header button displayed
-
4. Rename Quick Path to Bookmarks & Implement Multi-Bookmark Overview (
bookmarks://)- Requirement: Rename Quick Path to Bookmarks, and have the Bookmarks sidebar tab open an overview showing all bookmarked folders and files in regular browser views.
- Resolution:
- Added
Bookmarkstruct and multi-bookmark storage inBrowserwith persistent JSON serialization to~/.config/pixelvr/bookmarks.json. - Implemented
bookmarks://virtual directory navigation displaying all bookmarked paths with full cards (BigCards, MiniCards) and CompactList support, thumbnails, and direct activation. - Sidebar subtab 4 navigates to
bookmarks://with proper active tab highlighting. - Header button toggles bookmark for the current open directory (
[ + Bookmark ]/[ Bookmarked ]). - Action Menu (Y button) provides
"Add to Bookmarks"/"Remove from Bookmarks"/"Go to Bookmarks", and inbookmarks://provides"Open Bookmark"/"Remove Bookmark". - Updated Settings Menu shortcut label to
"Bookmarks Shortcut".
- Added
-
5. Seamless 1-Click Update System & Streamlined Distribution
- Requirement: Provide easy, friction-free updating so users do not need to memorize terminal commands or worry about losing their settings.
- Resolution:
- Added standalone local updater
tools/update.shand--updateflag totools/install.sh. - Added KDE Plasma
Update Pixel VR Player.desktopdesktop shortcut deployed automatically to~/Desktop/. - Added remote Wi-Fi 1-click updaters for PC users (
tools/update-from-pc.batfor Windows andtools/update-from-pc.shfor macOS/Linux). - Updated
tools/package-release.shto package updater scripts in the release tarball. - Updated
README.mdwith comprehensive update instructions across all platforms.
- Added standalone local updater
-
6. Fix UI Dismissal Flicker and Recessed Island Alignment
- Symptom: Clicking outside the controls or clicking the Hide UI button caused the UI to briefly flicker for 1 frame and immediately reappear. The Hide UI button was also visually misaligned (half outside the dark island).
- Root Cause:
in.selectHeldand trigger activity handling inApp::tick()continuously resethudDismissed_ = falseand setactivity = trueon subsequent frames of the trigger pull, fighting the dismissal call.- Controller activity anchors were not reset on dismissal, and there was no dismissal grace cooldown, causing slight hand tremor during trigger release to immediately re-trigger movement thresholds.
- The center transport recessed island background in
VulkanRendererwas rendered atcx = -0.05f, hw = 0.30f, clipping through the center of the Hide UI button at+0.26f.
- Resolution:
- Created
App::dismissHud()which setshudDismissed_ = true, recordshudDismissTime_, zeroes out activity, hides the control bar and laser ray, and snaps controller activity anchors to their exact current positions. - Added a 500ms dismissal cooldown preventing accidental re-engagement from trigger release tremors.
- Blocked
in.selectandin.selectHeldfrom clearinghudDismissed_while the HUD is dismissed. - Re-centered the transport recessed island background to
cx = 0.0f, hw = 0.34f, creating perfect 34mm symmetrical padding for all 5 buttons (Stop,SeekBack,PlayPause,SeekFwd,HideHud). - Added hover tooltip pills for
[Hide UI]and[Stop].
- Created
Carried over & Completed
- A. Full-pitch recenter (usable lying down) — reorient screen + controls + 360/180 to full head orientation, not just horizon yaw.
- B. Real controller models (glTF) rendered with depth.
- C. Dynamic controller button labels from the user's bindings.
Completed (2026-10-18):
- I. Laser auto-release when pressing Steam button (unfocused session gates controller pointer).
- J. Native SteamVR / SteamOS Virtual Keyboard integration (OpenVR GamepadTextInput via dlopen).
- K. Protected SMB credentials manager: hardware-keyed AES-GCM encryption, strict permissions, full CRUD.
- L. Settings > Network & SMB Shares: manual IP/hostname input, authentication settings, auto-listed in Network browser.
- M. UI Follow Mode: Mode 0 (Rock-solid World Lock default, zero drift/tilt) and Mode 1 (Smooth Follow).
- N. Player UI dismissal:
[ ✕ Hide ]button, empty space click-outside dismissal, B/Y toggle, and micro-tremor filtering (>0.06m threshold). - O. Quick Path Whirligig-style bookmarking header button + R3 controller shortcut button.
1. Build / deploy / test (the loop that works)
Build NATIVELY ON THE DEVICE (cross-compiling risks a libstdc++/FFmpeg SONAME
mismatch — device is GCC 15 + FFmpeg n7.0 libavcodec.so.61).
- Device:
steamos@192.168.178.61(mDNSframemay not resolve from WSL — use IP). - SSH key (WSL):
$HOME/.config/steamos-devkit/devkit_rsa(from Windows%LOCALAPPDATA%\steamos-devkit\steamos-devkit\devkit_rsa). - All commands run from Windows via
wsl -d Ubuntu -- bash -lc '...'.
Deploy one-liner (rsync → build → deploy):
wsl -d Ubuntu -- bash -lc 'rsync -az -e "ssh -i $HOME/.config/steamos-devkit/devkit_rsa -o BatchMode=yes" \
--exclude "build*" --exclude dist --exclude sysroot --exclude .git --exclude .vscode \
"/mnt/c/Projects/Pixel VR Player/" steamos@192.168.178.61:~/pixelvr-src/ && \
ssh -i "$HOME/.config/steamos-devkit/devkit_rsa" -o BatchMode=yes steamos@192.168.178.61 \
"cd ~/pixelvr-src && cmake --build build --parallel 2>&1 | tail -3; \
pkill -x pixelvr.real 2>/dev/null; sleep 1; \
cp build/bin/pixelvr ~/devkit-game/PixelVRPlayer/pixelvr.real; \
cp build/shaders/*.spv ~/devkit-game/PixelVRPlayer/shaders/; echo DEPLOYED"'
(First time only: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo.)
Fast compile-check without the device (WSL x86): cmake --build ~/pixelvr-build --parallel
(configure once: cmake -S . -B ~/pixelvr-build -G Ninja -DPIXELVR_ENABLE_MEDIA=ON).
Launch (needs headset AWAKE — see gotchas):
ssh … "python3 ~/devkit-utils/steam-devkit-rpc run-game gameid=PixelVRPlayer"
run-gamesometimes BLOCKS → run it in an async terminal, read logs with a separate ssh. App logs to~/pixelvr-run.log(wrapper setsPIXELVR_LOG=debug).~/devkit-game/PixelVRPlayer/pixelvris a sh WRAPPER; real binary ispixelvr.real. Initial clip = path in~/media/which.txt.
Headless diagnostics (NO headset needed):
pixelvr --probe <path-or-smb-url>→ opens media, samples 5 frames, prints chromamin/max/mean(mean ~128 good, 0 = broken). Alsotools/probe_smb.sh,tools/chroma_check.py.pixelvr --xrext→ lists OpenXR runtime instance extensions.
Gotchas (bit us repeatedly):
- NEVER
pkill -f pixelvrfrom the ssh cmd — the pattern matches the ssh session's own args and kills it (no output, exit 1). Usepkill -x pixelvr.real. - Triple-nested quoting (WSL
'…'> ssh"…"> python/labels) breaks. Put scripts in a file andscpthem (seetools/chroma_check.py). - Intermediate shell vars in
bash -lcdon't expand reliably — INLINE"$HOME/.config/…"andsteamos@192.168.178.61every time. - The run_in_terminal tool rewrites
&&/||to;. - Video frames only decode with the headset WORN — remote launches with the
HMD asleep reach "Session running" but
process_videonever runs. Use--probefor headless decode testing. - OpenXR
apiVersionMUST beXR_MAKE_VERSION(1,0,0)(SteamVR rejects 1.1 with XrResult(-4)).
2. DONE (deployed & verified)
- VR playback: HW decode (
h264_v4l2m2m→ NV12), PulseAudio audio, MSAA 4x. - GREEN BUG FIXED — libswscale drops chroma on NV12→NV12 on this platform.
Fix in
media_engine.cpp process_video: whensrcFmt==AV_PIX_FMT_NV12, repack planes with a directmemcpyhonoringlinesize(skip sws; sws only for real conversions like SW yuv420p). Verified via--probe(chroma 0/255/mean126). - SMB streaming by default —
media_engineopenssmb://via a customAVIOContextbacked by libsmbclient (SmbIO, guest auth). Browseractivate()streamssmb://directly;downloadFocused()(A) is the explicit download. Verified headless (streamed a 15 GB file off the NAS). - In-VR file browser (FreeType text, local + SMB scan + avahi/mDNS discovery).
- Controls remapped: A/X=play, B/Y=menu, Trigger=select, Grip=recenter, thumbstick=nav/seek/distance.
- Transport HUD world-locked below the flat video (was head-locked), auto-hide.
- Tracked controllers — OpenXR aim-pose actions + per-hand action spaces
(
xr_context.cpp);XrInputState.controllers[2]. Laser pointer + cursor + (basic) controller stub invulkan_renderer.cpp. Pointer raycasts the browser panel → hover row; Trigger selects. Math helpers added inmath.hpp. - Media control bar — point-and-click
<< · play/pause · >> · Filesbelow the flat video; always-visible laser (2.5 m, snaps to UI hit). Files button opens the browser via the pointer (no reliance on face-button bindings).
3. TODO — BUGS (from latest in-headset testing)
B1. Video fringing / white-pixel aliasing when the screen is farther away
- Symptom: high-res video shows white-pixel fringing around objects when the screen is a bit further; gone when moved much closer.
- Cause: the NV12 video textures have NO mipmaps (
mipLevels=1) and the sampler is linear-only → minification aliasing when the plane is small on screen. - Fix: create luma/chroma images with a full mip chain; generate mipmaps after
each upload (
vkCmdBlitImagedown the chain inuploadCmd_— R8/R8G8 support LINEAR blit on Turnip); samplermipmapMode=LINEAR,maxLod=mipCount, and enable anisotropy (samplerAnisotropyfeature +maxAnisotropy). Files:vulkan_renderer.cppcreateVideoResources(mipLevels + image views),updateVideoTexture(blit mip gen),createSamplerAndDescriptors(sampler). Watch the per-frame blit cost; only regenerate when a new frame is uploaded.
B2. High-res 360/180 video won't play + stale frame + controls disappear
- Symptom: a ~3600p video (meant to be 180°/360°) shows the LAST FRAME of the
previous test clip and never plays; and the control bar/transport vanish (they
only render for
ProjectionMode::Flat). - Likely causes / actions:
- Decode fails at that resolution (HW decoder session limits, or HEVC — see K1).
Run
pixelvr --probe <file>to see codec/res and whether frames decode. Probably need a software-decode fallback for too-high-res / HEVC, or reject with a message. (Iris HW decode is fragile ≥6K and floods POLLERR on 4K HEVC). - Stale frame on failure: when
media_.open()fails, the renderer keeps the last uploaded frame (hasVideo_stays true).app.loadMediamust detect the failure, clear the video (add arenderer_.clearVideo()), and show an error. - Controls in 360/180 (DONE): Added floating, gaze-anchored control panel & transport HUD for 360/180 at distance 1.6 m, tilted towards eye level, fully raycastable by the laser pointer with clickable transport buttons.
- Decode fails at that resolution (HW decoder session limits, or HEVC — see K1).
Run
B3. SMB browser scrolling is buggy (feedback loop) — DONE
- Fixed: decoupled pointer hover from selection and scroll offset. Added
browserHovered_for laser aiming andbrowserFirst_for stable windowing. Clamped pointer row mapping to the currently-visible window. Added a sleek, visible scrollbar track and thumb indicator on the right side of the panel.
4. TODO — FEATURES
F1. Real controller models (XR_EXT_render_model) — user's top ask
Runtime supports XR_EXT_render_model + XR_EXT_interaction_render_model (verified
via --xrext). Our pinned OpenXR SDK 1.1.43 only declares the older
XR_FB_render_model (which this runtime does NOT support). Phased plan:
- Bump OpenXR SDK —
CMakeLists.txtPIXELVR_OPENXR_TAG(currentlyrelease-1.1.43) → a release that declaresXR_EXT_render_model(verify by grepping the fetchedopenxr.hforxrCreateRenderModelEXT). Or hand-declare it. - Add a DEPTH buffer to the render pass (3D models must self-occlude; current UI is painter-ordered, no depth). Add depth image/attachment + clear; give the model pipeline depth test+write, leave the flat UI pipelines depth-test-off.
- Lit model pipeline — pos+normal vertex input + N·L shader (
model.vert/frag), register incmake/CompileShaders+CMakeListsshader list. - Load models — enable exts; enumerate per-controller interaction render models; fetch the glTF/GLB asset; parse with cgltf (single-header) → positions/normals/ indices (bake node transforms); render at the grip pose each frame. Skip textures first (flat gray + lighting). Big + blind → iterate in-headset.
- Also consider adding the native
/interaction_profiles/valve/frame_controllerbindings (runtime listsXR_VALVE_frame_controller_interaction) for correct Frame button mapping — but a single wrong component path fails the WHOLE profile suggestion (graceful fallback), so get all paths right or skip.
F2. Better button / UI graphics — DONE
- Replaced text labels (
<<,II,>>,Files) with GPU-procedural Signed Distance Field (SDF) vector icons rendered inshaders/ui.frag(Rewind, Play, Pause, Fast-Forward, Folder/Files, Settings Gear, Recenter Target, Reticle Cursor). - Upgraded button styling to rounded glass capsules with antialiased borders and glowing hover states.
- Transport HUD redesigned as a rounded pill progress track with glowing fill and thumb position dot.
- Laser pointer cursor upgraded from a flat quad to a high-tech circular reticle.
F3. Controller remap settings UI — DONE
- Implemented
SettingsMenumodel (src/ui/settings_menu.{hpp,cpp}) and pointer-driven in-VR Settings panel overlay. - Users can view and cycle button bindings (Play/Pause, Menu, Select, Recenter) and player options (Projection Mode, Stereo 3D Mode, Screen Distance, Reset View).
- Settings automatically persist to
~/.config/pixelvr/settings.confacross restarts.
F4. Variable-speed joystick fast-forward/rewind
- Replace the discrete ±10 s edge-triggered seek (thumbstick X) with CONTINUOUS seek
whose rate scales with deflection:
rate = sign(x) * (deadzone(|x|))^2 * maxRate, applied per-frame (* dt). Show the seek speed on the HUD. Files:app.cpp(per- framedt, continuous seek),media_engine(seekRelativeexists; may want a scrub that doesn't thrash the decoder — coalesce seeks).
5. Known issues / notes
- K1. HEVC 4K HW decode is broken —
hevc_v4l2m2mfloodsoutput POLLERR+ STREAMOFF on 2160p HEVC (e.g. the NAS "…2160p HEVC….mkv"); only a few frames decode. Many NAS movies are 4K HEVC. Needs SW fallback for HEVC/4K (overlaps B2). - Sharpness — partly the mipmap issue (B1); 1080p on a big VR screen is inherently soft (magnification). Re-judge after B1.
- The
pixelvrdeploy target is still a wrapper (pixelvr→pixelvr.real, reads~/media/which.txt). Restore a plain binary for shipping. - Temporary diagnostics:
--probe/--xrextmodes inmain.cpp(harmless, keep or remove later). The green-bug per-frame diagnostics were already removed frommedia_engine.cpp.
6. Source map (where things live)
src/xr/xr_context.{hpp,cpp}— OpenXR instance/session/actions;XrInputState(buttons +controllers[2]aim poses); aim-pose action + action spaces; renderFrame.src/gfx/vulkan_renderer.{hpp,cpp}— all Vulkan. NV12 pipeline, MSAA, pipelines (quad/sphere/ui/text), transport HUD, control bar, browser panel, controller lasers/ cursor,updatePointerraycast, screen placement (screenPivot_/Forward_/Yaw_/Distance_).src/media/media_engine.{hpp,cpp}— FFmpeg PIMPL; HW/SW decode;process_video(NV12 direct-copy fix); SMB AVIO streaming; pause/seek/position; PulseAudio.src/ui/browser.{hpp,cpp}— file browser model (local + SMB + mDNS, async download).src/app/app.{hpp,cpp}— run loop, input handling, browser + control-bar wiring.src/main.cpp— entry +--probe/--xrextdiag modes.src/util/math.hpp— Mat4/Quat/Vec3 + ray/billboard helpers.shaders/— quad (NV12 YUV→RGB), sphere (360/180), ui (solid), text (glyph atlas).CMakeLists.txt—PIXELVR_OPENXR_TAG, shader list, FFmpeg/FreeType/smbclient/pulse.