On Frames whose Steam drops FramePort's shortcuts.vdf entries on every restart
(GitHub #41/#42), "Add to Steam library" restarted Steam twice and then failed
with "Steam removed the new library entry again after restarting". Play's
fallback (the devkit entry, the one Steam keeps) was never reached because
_add_then_play gave up first.
After the second loss the shortcuts worker now adds the devkit entry while
Steam runs, so the game ends up in the library as "Devkit Game: <title>" and
the job succeeds. Agent v89.
- Check the Steam shortcut on every update, also when nothing else changed: a connection lost during
"Add to Steam" or Steam putting its old shortcuts.vdf back left games without a library entry. The
agent restarts Steam only when the entry really changes.
- Artwork updates for installs without a recorded artwork manifest start from a clean folder again
(an old portrait.jpg next to a new portrait.png was picked at random for the Steam grid).
- OpenSSH uploads only rename .part files that are already complete (sftp's reput refuses them, which
sent every resume to the Paramiko fallback).
- frame.tbxr_vendor revision 2: GL4ES ports get "Update on Frame" for the #191 draw fix.
- Configuration-only updates send the library's current Steam tags.
- finalize drops fingerprints and the artwork record an older app didn't send.
- set_settings: malformed numbers are refused before anything is written; int settings keep a
fractional value.
- Display diagnostics use Windows PowerShell 5.1 without pwsh's PSModulePath; window diagnostics and
the instance owner file can't stop the GUI from starting.
Moving the mouse over the Library right after its cards were rebuilt (e.g.
the library changed at start, or a view switch) could deliver a hover event
for a card that was already off the page. Its handler called tile.update(),
Flet 1.0 raised "Container(…) Control must be added to the page first", and
an error in an event handler takes the whole app down to Flet's error
screen.
- Library grid and shelf card hover handlers use the safe C.update(tile),
which skips controls that aren't on the page any more (the convention
everywhere else in ui/)
- Settings' index: scroll_to on a page that has already left the screen
(or a closed window: "Session closed") is ignored instead of raising in
the task callback
Seen on the desktop app (Linux) while testing the a32 branch; the code is
the same on main. No automated reproduction (needs a real hover during a
redraw).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent 85: the game's container gets the host's /dev/input at /dev/fp-input (Lepton's /dev/input holds its own Wayland
input pipes) and the launcher's _gamepad_links worker keeps /dev/input/eventN links to Steam Input's virtual pads in
step while the game runs: controllers switched off and on again (Steam makes a new pad) work without a restart.
Android's EventHub adds and removes the links through inotify (dev Frame, uinput stand-in pad created after boot:
New device, Device removed, added again). Older launchers get the new gamepad line through upgrade_launchers.
The key layout also maps BTN_DPAD_* (a Steam Input layout that sends the D-pad as buttons, e.g. a stick set to
D-pad; the Frame's controllers have no D-pad of their own).
Pick the new APK from the game's right-click or … menu: pipeline.replace_apk checks it's the same app (refuses
another package, warns when its versionCode is older), points the entry at it and analyses it again; title, art, tags
and the user's own recipe stay, the game data stays unless the new APK's folder has its own. The game page then
offers Update on Frame.
- HostKeyChanged (paramiko BadHostKeyException): not treated as "can't reach" (jobs don't pause and retry); the
Steam Frame page explains it and offers Trust the new key (forget_host_key + reconnect); CLI frameport frame trust-key.
- Uninstall from the running app: instance.release() drops gui.lock before the data folder goes, and files still in
use are reported as left over instead of stopping the uninstall (Windows).
Both from Reddit reports (2026-10-12).
bHaptics' vest SDK asks for BLUETOOTH_CONNECT at start; on Lepton's Android 11 the permission screen takes the
foreground for a moment and the game pauses (Yupitergrad froze on its loading screen in the reporter's session).
PermissionUtils.requestBluetoothPermission returns at once (Dex.return_early). Suggested by analysis
bhaptics_permission (ANALYSIS_VERSION 14) and triage bhaptics-permission-prompt. On the dev Frame the prompt is gone;
whether it was the whole cause of the freeze is for the reporter to confirm.
Opt-in game setting (Picture -> Draw fades over the picture). Batman: Arkham Shadow fades by dimming its projection
layer with XR_KHR_composition_layer_color_scale_bias; with fade_quad=1 FrameBridge adds a black, alpha-blended quad
right after the dimmed layer (VIEW space, 1 m ahead), opacity = 1 - scale in 16 steps. One 8x8 swapchain, refilled
only when the step changes (Vulkan clear on the app's queue / GLES clear in its context); one swapchain per step hit
XR_ERROR_LIMIT_REACHED. fade_quad=2 also removes the layer's own scale. Device (headless, Batman): swapchain created,
level 16/16 on the first frame, no xrEndFrame errors, 72 fps.
The game showed a canvas being painted as its own compositor layer, which the Frame draws in front of everything.
PaintableMesh.HasCompositorLayerAvailable and ShouldUseCompositorOverlay return false (Cpp2IL), so the game's own
fallback draws the canvas in the scene (Klownicle's finding). Catalog recipe, PLAYBOOK row, test.
No Cpp2IL release reads Unity 6.5's metadata yet ("We support 23-106, got 107"), so the text-field and MSAA
fixes were skipped for Wander 0.3570 (#198). When the release reports an unsupported metadata version, the
development branch's CI build (nightly.link, as Cpp2IL's README links it) is fetched into tools/cpp2il-nightly/
and used; cached errors of that kind get one retry with it. Checked with Wander 0.3570: it reads the v107
metadata and writes TMP_InputField, OVRDisplay and OculusLoader.
Bionic allows 128 pthread keys per process. Unity 6 takes about 40, and with Gecko, Firebase and the Frame's own
libraries Wander 0.3570 ran out: Firestore aborted at start ("cannot create thread specific key for
__cxa_get_globals()", #198, #202). Opt-in patch, suggested for Unity 6 with Firebase C++ or Gecko: libunity.so's
pthread_key_create/_delete and pthread_get/setspecific imports are renamed in place to fp_tkey_* (new
elf.rename_imports: undefined symbols only, shared names refused, versions set global) and libfp_tkeys.so
(native/tlskeys) is added to its DT_NEEDED; keys without a destructor become generation-tagged slots behind one
real key, everything else keeps real keys. Approach and the first helper by Klownicle (#198), reimplemented here.
Triage signature pthread-keys-exhausted, PLAYBOOK row, Wander's recipe updated to 0.3570.
Installs the new build and leaves the game data on the Frame as it is (the CLI's --apk-only): a normal update
re-sends every OBB whose size differs from the PC's copy, which replaced OBBs changed on the Frame (mods). Asks
first; Quest games on the Frame only. Resume after a failure keeps the kind of update.
New games are checked against the drive chosen on the Frame page, installed ones against their own drive, from
the drive list fetched at connect and after install jobs (app.frame_drives). Internal storage's free space only
counts when no drive list is known. drives.target_drive/space_shortfalls are pure helpers with tests.
Flet's Linux picker asks the XDG desktop portal; window managers without one (IceWM, bare X11) answer
NameHasNoOwner and every pick raised. C.pick_files / C.pick_dir now fall back to zenity or kdialog (kdialog
first on KDE; core/pickers.py, run off the UI loop), and without either to a "type the full path" box. All
twelve pickers go through them.
Agent 83: deployment.json desktop_icon (default on); refresh_desktop_entries only updates a desktop copy that is
there; desktop_entry takes icon=. Game page: "In the app menu" and "On the desktop" switches.
- Agent 83: Valve's Lepton pastes the game's paths unquoted into shell commands (liblepton mounting.sh), so a game on
a card named "Steam Frame 512g" never got its internal storage (no saves, no SharedPreferences). The Quest launcher
now links the game folder to ~/.local/share/frameport/lepton-paths/<pkg> at every start and gives Lepton that path;
upgrade_launchers converts existing launchers, move and uninstall handle the link (#213, #214, #215, #221).
- frame.eos_keychain_loop (opt-in, offered by triage): Epic Online Services' resetKeychain returns at once, so a
keychain it can't write no longer loops forever (Beyond Sandbox, #214).
- Triage lepton-data-link-failed and eos-keychain-loop.
- CryEngine also from libAndroidLauncher.so + a .cryproject (Climb 1, #238); ANALYSIS_VERSION 12.
- Plain words: patch titles lose bracketed internals unless Show technical details is on (C.patch_title); a passed launch test just says so, how far it got shows only when it fails (C.test_line); Frame page readiness says Quest games / PC VR games and storage drops the file system (reason in a tooltip); the failure pop-up says where it stopped, that Resume continues and that saves and signing keys are kept. Engine and VR API stay on the game page; the OpenXR runtime and game launch capacity checks stay; the first start still names its tools.
- Readable dim text: TEXT_3 is #868A96 (Portal, Portal OLED) and #868B9C (Original), at least 4.5:1 on the window, sidebar, cards and raised panels (was 3.6-4.4:1); a test keeps the built-in themes there.
- Quieter empty tabs: the not-connected Files, Screenshots, Live view, Type on Frame and Monitor tabs show one sentence and Connect above a faint sketch of the tab (C.ghost_preview) instead of three feature cards; the Live view shows one Start button and no feature cards.
- The connect page has one way in: "Set up your Frame" up front (no password, nothing changes until you allow it), "Already set up?" below, the address and password behind "Connect by address…"; no 1/2/3 numbers.
- Consistency: every switch is C.switch, with the portal gradient painted under a transparent track (the real Switch keeps keyboard, focus and semantics), a dark thumb with a check when on and a grey one with a cross when off (the same size); where a patch came from is blue information, not orange; the platform filter says "Quest/Android"; the welcome screen says "Set up your Frame".
- Showcase: the live-view shot waits for "Ready when you are"; the install video clicks "Set up your Frame".
- Section labels name what you do there ("See how it works", "See what runs", "Get started") instead of 01-07; plain headlines ("What happens when you click Install on Frame", "FramePort patches the games that don't run as they are"); the hero's eyebrow is gone; nav "Install" -> "Get started" (no clash with the /install/ link page)
- Phones and narrow windows (below 1120 px) get a Menu panel instead of a hidden or wrapping nav; on phones the download buttons say "Get it on your PC"; no three.js on data saver or 2G/3G; the install log drops its detail column under 600 px
- The download page says why Windows and macOS warn on first start (self-signed) and where the SHA-256 sums are; Get started step 3 says nothing changes on the Frame until you click Allow, with a link to the script
- The app tour's screenshots and videos sit behind one disclosure; the game-maker panel shows the demo button on its own stage with the HTML behind "Show the HTML"; Compare without icon tiles, Free and open source stays the emphasized card
- Quieter motion: no cursor light, pointer-lit heading gradient or film grain; sections settle in with a 10 px rise, heading words rise without the tilt, labels decode once, a faint click ripple, a gentler hero lean, a lighter spring (--ease-spring); the warp field, the contributor dock and the gradient changelog version stay
- Readability: dim text #868a96 (4.77:1+), themed placeholders, pending install-log stages stay readable, no labels under 11px, the walkthrough shows each platform's real file and program (was "?os=linux" and FramePort.exe)
- Impeccable design-tool files (PRODUCT.md, DESIGN.md, .impeccable/) are ignored: they stay local
The title's grid-column 2 / -1 from the wide layout added an implicit second column in the one-column layout,
squeezing FramePort's text to one word per line beside the other tool's.
The search and buttons sat in an auto column that took the width and left "Libr" and one word per line for the
title. Narrow (icon rail): title, full-width search, buttons and Add games each wrap below. Tablet widths: the title
keeps its own width.
Stacked (<= 900 px) the scene sits below the hero text, so --sp measured from the page's scroll had it blurred and
enlarged by the time it reached the middle of the screen. There it now follows its own position (0 until its middle
passes 40 % of the screen height). Wide layouts keep the old mapping. The empty-space click rings and the install
badge's press ripple skip touch pointers.
Epic's DownloaderActivity checks the OBB's name and size and, with the manifest's
bVerifyOBBOnStartUp=true, CRC-checks the whole file; its screens are invisible in Lepton,
so games like Contractors seem to hang at 'Displayed .../.DownloaderActivity'. The patch
flips that manifest boolean (axml.set_meta_data_bool, one 4-byte value); analysis
unreal_verify_obb (ANALYSIS_VERSION 11) suggests it. Triage unreal-obb-check-stuck (fatal)
fails such launch tests; triage keeps system_server's lines about the game's own activities.
Game menu (Library right-click, game page "Name and artwork") and CLI 'frameport rename <pkg> "<name>"|--reset'.
The title is locked (title_locked + auto_title for the reset); rescans, Rift store matches and install links only
update auto_title; a chosen name gets no Quest/Rift twin suffix. Installed copies: the Frame's deployment.json title
changes and the shortcut is rewritten with the same appid (matched by Exe), one Steam restart; PC VR installs on this
PC keep their appid too (reinstalls and library updates now record the shortcut's real appid). A Frame that isn't
connected shows "Update name on Frame" on the game page until then or the next install.
SteamVR's auto-launch flag (SetApplicationAutoLaunch) didn't persist on the dev
Frame and a registered watch didn't start by itself. The deployment's overlay
autostart flag is now the source of truth: ensure_host_fixes installs the user
service frameport-vr-overlays.service while an installed overlay app has
autostart on (removed when none has, on uninstall/purge, or with the kill switch
~/.local/share/frameport/vr-overlays.disabled / FRAMEPORT_NO_OVERLAY_AUTOSTART).
It polls every 5 s for a new vrserver (pid + start time, remembered across agent
updates), waits until SteamVR answers, gives SteamVR's own auto-launch 15 s and
starts each autostart overlay app that isn't running (process of its folder or
GetApplicationProcessId) through LaunchApplication, in a child process with a
timeout. Log: ~/.local/share/frameport/vr-overlays.log (capped).
Godot 4.2-4.4's Kotlin code casts getSystemService(CLIPBOARD_SERVICE) with a
non-null 'as ClipboardManager'; Lepton has no clipboard service, so the app
crashes with a NullPointerException in Godot.<init> (4.3/4.4) or on first use
(4.2's lazy property). In place in classes*.dex, matched exactly: the
Intrinsics.checkNotNull before that check-cast becomes nops (older kotlinc
branch forms: the branch is fixed), and Godot's getClipboard/hasClipboard/
setClipboard return at once (Dex.return_early gains String -> "").
Analysis field godot_clipboard (ANALYSIS_VERSION 10), triage
godot-no-clipboard, PLAYBOOK row, unit tests on synthetic dex. Checked against
godot-lib 4.1.4-4.7.2 from Maven Central and the 4.3/4.4.1 export templates:
4.2.2, 4.3.0, 4.4.1 patched (D8 re-reads the result), the others untouched.
Host OpenVR overlays are composited over Lepton (Quest) games on the Frame;
Windows overlay apps register through Proton's vrclient. Detect overlay apps
(bundled .vrmanifest with is_dashboard_overlay, or OpenVR + IVROverlay +
self-registration), patch pcvr.vr_overlay and the Linux entry fields
vr_overlay/vr_overlay_autostart, agent register/unregister/launch_vr_overlay
(IVRApplications through SteamVR's libopenvr_api.so; manifest with
binary_path_linux_arm, the only key the Frame's vrserver reads). Overlay apps
skip launch tests and the Linux Steam-parent watchdog and don't count as a
running game. Probe sources in native/vroverlay_probe.
Some browsers (Flatpak ones on Linux) opened the issue form with the
title but every field empty. The issue links now also come as the
Markdown body GitHub's forms write (### sections, the recipe as a yaml
block, which catalog_from_issue.py reads) and a plain issue link with
that body, trimmed to the same URL limit. The GUI copies the body to the
clipboard when it opens the form and offers "Form empty? Open a plain
issue"; the CLI prints the plain link. A shared recipe is marked shared
only after the link was opened.
The {"modules": …} message and take_screenshot reached main with agent 73 (the ui-refresh
branch's v63-v66 numbers collided with main's). MIN_AGENT_MODULES was 65, so agents 65-72
were sent a message they ignore.
Network scans for Valve's pairing port now leave out docker/bridge/veth/tun/tap/wg/tailscale/
zerotier/vEthernet/utun-style interfaces and loopback, link-local and 100.64/10 addresses
(discovery.local_subnets, also used by the Frame search). Each scan that finds nothing doubles
the wait (30 s up to 5 min); another set of networks or opening the Frame page starts over.
Architecture: the module overview and the self-update flow are SVG diagrams
(docs/images, shown on GitHub and the site); the update details the old drawing
carried are a short list under it. FAQ: the game-folder layout is a tree block
the site draws as a real file tree (scripts/filetree.ts). Diagrams scroll sideways
on phones instead of shrinking. Docs pages on phones: the sidebar no longer kept
its desktop offset, which pushed it over the page title.
Open requests whose /wait polling stopped for more than 2 x WAIT and decided ones older than
DECIDED_KEEP are dropped (on /hello and every few seconds); the Frame page shows only open,
unexpired requests and redraws when one expires. setup.sh tells a full queue apart from an
unreachable PC. The fallback Frame name in a request is translated.
Agent v74: with an index.txt, collect_diag sends every module named in the
newest session (after the last '# start'), deduplicated, last used first,
each up to 4 MB and up to 30 MB in all, plus that session's index lines.
A reporter's GPU hang came ~10 s after the last new module, so the newest
written modules missed the culprit. Without an index (fp_spirv) nothing
changes. The bundle drops the least recently used modules last when the
zip would pass its size limit.
Cloudflare Web Analytics (cookieless) runs when the CF_ANALYTICS_TOKEN repo
variable is set. Google Analytics (GA_MEASUREMENT_ID) loads only after the
visitor clicks Allow on a small card with equal Allow/Decline buttons; the choice
is remembered, Cookie settings in the footer asks again, Decline removes the
_ga cookies and Global Privacy Control counts as Decline. Downloads are counted
as file_download events. New privacy page. Without the variables the site has
no analytics.
Conflicts kept main's features with the site branch's wording: the uninstall
dialog's new delete-saves option, hardware video decoding and last-session help,
new Game settings (plain labels), agent 73 (main's 72 plus the Take screenshot
command from v66). Main's new texts follow docs/STYLE.md (for example, patch/setting
instead of fix). Pages rebuilds on docs changes and daily; the CNAME file is
gone (the domain is a Pages setting).
Sitemap and robots.txt, complete Open Graph and Twitter tags, JSON-LD
(the app as a free SoftwareApplication with its current version; docs as
articles with breadcrumbs), a written description for each docs page, a
releases Atom feed, a 404 page, and titles that say what people search for.
/changelog/ shows every release's What's new part (the same parts the app's
changelog shows): the dev build as Coming next, the latest release large,
then a timeline; the home page shows the latest changes. Release jobs
restart the pages workflow so a new release appears on the site.
Plain words on every page, one label per action, no jargon on the home
page. The site moves to the custom domain frameport.app (CNAME, no
/frameport base); the setup line is curl -sL frameport.app/s | bash.
README is a short overview that links to the install guide; repeated
sections are said once; Share working recipe everywhere; the setup
script names the real buttons; a shorter notice, the same as the site's.
Dialogs, help texts and tooltips cut to what the user does or gets, one
name per action (Start setup, Uninstall FramePort, Update art on Frame,
Rebuild only), plain launch-test results (C.test_result), plain summaries
for every Game setting. The setup line now points at frameport.app.
Lepton's Android only gets keyboard, pointer and touch from the Wayland seat. The opt-in patch (2D apps; suggested
when the manifest declares android.hardware.gamepad or LEANBACK_LAUNCHER, ANALYSIS_VERSION 9) makes the agent record
steam_gamepad in deployment.json and give the launcher a line that exports SDL's hint
(LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD) and puts FramePort's own Podman wrapper (agent/bin/podman,
written by ensure_host_fixes, independent of the video codec) first on PATH. For the game's podman run it bind-mounts
Steam's virtual pads (uinput, 28de, gamepad buttons) at /dev/input/eventN plus an Xbox 360 key layout, then hands on
to the next Podman after itself (the codec wrapper or Podman), so both wrappers chain in either order.
FRAMEPORT_NO_GAMEPAD=1 turns it off for one start; failures keep the arguments unchanged. Verified headless on the
dev Frame with a uinput stand-in pad: Android's EventHub opens it with our layout and dispatches BUTTON_A.
Adapter setting vk_shader_dump=1: every distinct module once to files/fp_vk_shaders/<size>_<sha256>.spv, each
vkCreateShaderModule as one index.txt line (order, time), to find the shader behind a GPU hang for a vk_shader_fix.
Agent v72 collect_diag returns the newest dumped modules (Vulkan and OpenGL ES dumps); diagnostics bundle them.
Triage gpu-hang suggests the dump for the session's graphics API (FrameBridge's swapchain formats).
- Two more Frame-side worlds (envs-neon.ts): a rain-soaked neon megacity, and a Tron-like arena with light cycles.
- A game's world comes from a hash of its title (FNV-1a): with seven worlds the old palette mix gave every
title the same remainder, so one world never came up and another four times in ten.
- Download page: no round countdown ring; the countdown runs round the portal's own rim, a tunnel of rings
sinks away inside it (the rim is a masked band, so the way through stays dark).
- The trial's live view shows one eye, as the app's does.
- Headings reveal slower again.
The Steam Frame (or This PC) card gets a log button when the game's last launch test ran there: it opens the
saved log (last_test.log_path) in the launch log viewer. The showcase's demo library writes a short made-up log for
each passed test so the button shows in the docs pictures.
- Docs: search across every page (highlights where you land), definitions for jargon on hover, tables you can
filter and sort, step lists you can tick off, the tutorial video in place, zoomable pictures, section links that
copy, / [ ] keys, a reading path per goal on the overview and a mark on pages you've read.
- /download/: every Download button leads here; a countdown while the package goes through the portal, then the
download starts; a manual button stays, with version, size and SHA-256 from the release and the next steps.
- Two more Frame-side worlds: an aurora over a frozen lake, and a reef. Shared helpers moved to envkit.ts.
- Asteroids and floating islands no longer tear: corners shared by several faces move together (moveCorners).
- Built on: every project links to its page. Compare: no update row; Rift games not supported by the others.
- Headings reveal a little slower.
- The Frame side of the hero picks one of three worlds per game (grid, islands, space); the particles option is gone.
- The trial's Live view shows the same worlds through two lenses (vrscene.ts), one scene per stream, CSS fallback without WebGL.
- The trial looks more like the app: window bar, Library header/filters/shelf, game page, Frame card with now playing and power.
- The install log follows the game: package, conversion, its patches, version and note; the recipe opens under board rows.
- Calmer motion: no button drift, smaller click ring and cursor light; the gallery carousel shows the real screenshots again.
- Install tutorial video (docs/showcase/videos/install.yaml, re-recorded by record_video.py): Start setup, a card
for the setup line on the Frame (with the Developer Mode route in its note), the Frame asking and Allow clicked,
a card for what the Frame then does by itself. Showcase: the fake pairing server takes asks; hook `frame_asks`.
- Site: the Install section is a six-step walkthrough (download, Start setup, the line or Developer Mode on the
Frame, Allow, it sets itself up, add games and play), each step with a small live scene: the download unpacks,
Start setup shows the line, Konsole types it and finds FramePort, Allow moves on, the setup ticks off, a game
installs. The other ways link to /setup.
While no Frame is connected, FramePort watches for Frames in Developer Mode (Valve's devkit mDNS, else a scan every
30 s for Valve's pairing port, one entry per Frame by host key). One that already lets FramePort in is connected; one
that doesn't is offered FramePort's key through Valve's pairing every few seconds (refused at once until "Pair new
host" is open on the Frame, then the request shows in the headset; a 45 s pause after an unanswered one). So the user
only opens Pair new host and approves in the headset.
- frame/autopair.py (no Flet, callbacks), app._start_auto_pair, setting frame.auto_pair (switch on the Steam Frame
page, default on); never with FRAMEPORT_HOME or FRAMEPORT_NO_AUTO_PAIR (tests, screenshots).
- Checked on the dev Frame: found by the scan (this WSL PC doesn't hear its mDNS), FramePort's key recognized, no
pairing request sent. The headset approval path needs a Frame that doesn't know FramePort yet.
Iris returns an empty capture buffer (bytesused 0, no LAST flag, time 0) for every VP9 frame that isn't shown
(alt-ref frames split from superframes). FFmpeg's V4L2 wrapper takes any empty capture buffer during a drain
for the end of the stream, so the first hidden frame decoded after EOS ended it and the pictures still in the
driver were lost (two-pass VP9 4K: 573/600 and 617/640, always the last frames); outside a drain the empty
buffers were returned as pictures, which the component then guessed away by counting hidden frames.
build.py now patches v4l2_context.c to requeue empty capture buffers without LAST/ERROR; the drain ends at the
LAST buffer, or after a skipped empty buffer at one silent second, so it can't block. The component's
hidden-frame counting is removed. Dev Frame (Batman's container): two-pass VP9 4K 600/600 and 640/640 in
hardware with the same Y hashes as OMX.google.vp9.decoder (seek phase too), VP9 without hidden frames,
H.264 4K and HEVC 4K 600/600, VP9 surface output 600/600. Two builds (different dirs and NDK copies) give the
same libstagefrighthw.so 141de01f.
- Opt-in per game: only launchers of games whose recipe has frame.hw_video_decode (install stage, no APK change;
suggested from analysis media_codec, ANALYSIS_VERSION 8) get the codec line (deployment.json hw_video_decode,
finalize + upgrade_launchers). Games that had agent 70's per-game codec (Batman) keep it; their old
<base>/frameport-codec folders are removed afterwards. Batman: catalog + migration batman_video_patches
(frame.hw_video_decode + the hidden adapter setting surface_native, no more package check); 4XVR catalog.
- Off switch: Settings -> Installing "Hardware video decoding" (library video.hw_decode, default on) -> agent
video_codec_switch (video-codec/disabled, checked by the launcher line and the wrapper); FRAMEPORT_NO_HW_VIDEO=1
for one game.
- Connection: the codec is checked once per connection, also after a failed install; a Frame keeps the same or a
newer revision built elsewhere (logged) instead of flip-flopping between PCs.
- Wrapper: the merged media_codecs.xml goes to $XDG_RUNTIME_DIR/frameport-video, never into the version directory.
- Decoder: refused (busy) Iris sessions retry until 20 s after the plugin loaded (SteamVR's link holds a session
~12 s at game start), else 2 s, before the software fallback; VP9 capped at 4096x2304 (XML + component, larger
decodes in software); hidden VP9 frames' pictures dropped. Manifest revision 7.
- build.py builds from paths with spaces and from freshly extracted FFmpeg sources. Artifacts rebuilt here, identical
from two trees (ext4 and the NTFS repo path, two NDK copies); PR #128's own sources rebuild to its binary.
- AGENT_VERSION 71 (matches the connection's gate); triage hw-video-decoder-busy, PLAYBOOK row, docs.
INSTALL and FRAME_SETUP describe the setup line, the Allow step and the announcement; the setup command, USB cable
and Valve's pairing stay. FRAME_RUNTIME records what was checked on the dev Frame (Chromium via Steam's +, mDNS from
the Frame only on port 5353, avahi-browse not usable over SSH). README's quick start uses the line.
- /setup: the three steps (Start setup on the PC, the line on the Frame with Copy, Allow), a terminal that plays what
the script prints, what it changes, and every other way (setup command, USB cable, Valve's pairing, address).
- The site serves bootstrap/setup.sh as /s and /setup.sh (copied at build time; the workflow rebuilds when it changes).
- Install step 2 starts with a "Setup page" tab; the trial's Steam Frame page and guided tour use the same flow
(the Frame asks, matching code, Allow).
One line for every Frame and PC, served by the project page: curl -sL spoopyghosty0.github.io/frameport/s | bash.
- bootstrap/setup.sh finds FramePort (stdlib mDNS on 5353 for _frameport-pair._tcp; else the USB cable's address and
a /24 scan of /ping), asks with /hello, shows 4 digits, waits for Allow, then runs exactly the typed line's
`curl -fsS <pc>/<code> | bash`: bootstrap.sh is unchanged.
- PairingServer (no second server): /ping, /hello (max 3 open asks), /wait (long poll; code only once allowed),
decide(); announces itself over zeroconf while running (name, port, two words for this PC's key; never the code).
- Connect page: "Start setup" shows the static line, incoming asks as Allow / Deny cards (a toast elsewhere);
"Use the setup command" keeps the address + code line; USB setup unchanged.
- The app key is created under a lock (the announcement and the page asked for it at once on a first start).
- Tests: approval/deny/limits/timeouts, the code never in the announcement, setup.sh end to end against a real server
with a stub bootstrap (allowed and denied), digits match. ui_smoke --setup-url drives the page.
Checked on the dev Frame with a stub bootstrap: found by mDNS and by the scan fallback, allowed, code handed over.
- Hero, Frame side: the card is gone; the game opens into an abstract VR world (three.js, look around by dragging or
tilting the phone) or into particles that gather into a shape for the game. A TEMPORARY toggle under the scene
switches between the two for comparison. three.js loads only when the hero is on screen.
- A Steam Frame you can turn (the logo's visor, extruded) next to "Connect the Frame".
- No pictures of the app on the page: the carousel's slides are live copies of its screens (type, drop a file, end a
process, switch themes), the video tiles play short silent loops (made at build time with ffmpeg), the install
button is a live control.
- Scroll story: the hero flies toward its portal, the install log completes line by line, section lines draw in.
- Everything reacts: a portal light follows the pointer, clicks on empty space ripple blue then orange, headings rise
in word by word and light up under the pointer, labels unscramble, numbers count up, each icon moves its own way.
- Hero: the Frame-side card is the game running in the headset (one view per eye, the scene moving), switching on
as it lands while its frame rate climbs to 72 and the pacing line draws.
- App demo: working search, right-click menu, select several and install them (queued), install from a link (the
app's link rules), Screenshots tab with a viewer, Settings with the real themes switching live, Files that take
real files from the desktop (they never leave the tab), process table with End, Live view quality, launch-test
result, technical patch ids, toasts; Wi-Fi or USB cable when connecting. Progress ticks no longer rebuild the page.
- Screenshots: a carousel (arrows, keys, swipe, thumbnails, autoplay that pauses for the reader, full screen).
- Compared: four headline differences (free and open source first), every difference as user questions behind
"Show all differences"; the README says the same.
- Wi-Fi or USB cable said everywhere; the setup command explains itself part by part; first-start steps per system.
- Field notes removed, the notice kept in its own band.
- Built on: contributors wall (code, pull requests, issues; scripts/contributors.mjs, saved list as fallback).
- Docs: user-facing docs/*.md as pages (links rewritten, GitHub heading ids, contents with scroll position, copy
buttons).
- Games board rows open to show the recipe; the install log replays with another tested game.
- "Meta Quest" only as a description (headline about the Steam Frame), trademark line in the footer.
- Hero cards: generated covers in the style of the app's placeholder art (hue from the title), the same app icon on
both sides of the portal, a one-line APK name, stage ticker and progress, Play ripple, sparks; titles sized so
words never break. Click the portal for the next game; the scene leans toward the pointer.
- "The app": a clickable, simplified FramePort window (Library, game page with patch switches, Steam Frame, Files,
Live view, Type on Frame, Monitor with live sparklines) and a guided tour from a fresh start: connect the Frame,
scan a folder, install, play. The showcase videos play in a lightbox.
- Compared: FramePort, FrameDrop and Valve's own tools, from their own pages (checked 2026-10-09); the same table in
the README.
- record_video.py --size WxH (e.g. 2560x1440): films at that many device pixels; postprod reads the size at call
time and draws cards at the 1920 layout scaled up. Default output unchanged.
site/ (Astro 7, static, served at spoopyghosty0.github.io/frameport/) takes the Portal look of the UI refresh:
- Hero: a catalog game crosses the logo's tilted portal, from an APK on the PC's (blue) side to a game in the
Steam library on the Frame's (orange) side; reduced motion holds it halfway, like the logo.
- How it works as one real recipe's install log (PC stages blue, Frame stages orange), the showcase tour and
screenshots (synced from docs/ at build time, never by hand), field notes on what doesn't work, install steps,
credits.
- Games board from catalog/games: prebuilt at build time, then refreshed in the browser from main (one contents
API call, only changed files fetched, 6 h localStorage cache); any failure keeps the built list.
- /install/?manifest=…|?url=…: where an "Install with FramePort" button lands; opens frameport://install, and if
FramePort didn't take it, says how to get it (download for this OS, try again, copy the link). Link rules mirror
deeplink.check_url; node tests cover them and the board's rows.
.github/workflows/pages.yml builds and deploys on main (Settings → Pages → Source: GitHub Actions, once).
- Screenshots tab: Copy image (a button on the card under the pointer, the right-click menu and the viewer) puts
the full-size screenshot on this PC's clipboard (core/clipboard.py: Windows/WSL PowerShell bitmap + file,
macOS osascript, Linux wl-copy/xclip; Flet's set_image in web mode)
- Clicking a holiday badge showers the window: New Year confetti, winter snow, Valentine hearts, digits of pi,
clovers, hopping eggs, bouncing pumpkins (moved like the confetti: one page update per phase)
- Perfect pacing after 2.5 minutes on target; the Perfect pacing pill and line stay until the rate slips
- Full charge: only the ring pulses (with a glow behind it), the percentage stays put
- Upload hop a little slower
- Seven logo clicks: the glow, rings and the tossed cover come out of the wordmark's portal (between Frame and Port)
- Holiday badges dance on hover again: the logo's box grows to hold the badge on holidays (Flutter only hit-tests
inside a control's bounds); spins grow while they turn, bigger wobble and hop
- Perfect pacing: the game card glows and pulses blue/orange on an opaque card, the number grows in the portal
gradient, a Perfect pacing pill
- The logo's spin (searching for FramePort) is slower
- Hello: a headset peeks up from the window's corner and waves, outside the typing box
- Full charge: the battery ring pulses instead of sparkling
- Upload hop: a little lower and quicker, after 2.5 minutes instead of 5
- The fps mood tooltip opens above the number, clear of the pointer
- Seven logo clicks: the portal glows and rings, a cover is tossed out and falls across the window on a random arc
- Holiday badges dance on hover; new: Valentine's, Pi Day, St Patrick's, Easter weekend, April 1 (upside-down
logo until hovered), New Year's party popper
- Monitor: a minute within 0.5 fps of target turns the fps chart into the portal gradient
- Library search "frameport": the logo spins and says "That's me!"
- Type on Frame: typing "hello" makes a headset wave (keys still go to the Frame)
- The battery ring sparkles on reaching 100 % on the charger
- Uploads over five minutes: the transit cover hops now and then
- Live view tab: a blinking ON AIR sign while streaming
- Showcase hooks perfect_pacing, battery, long_upload, press_keys
Adapter rebuilt here; the codec plugin rebuilt here with native/hevc/build.py (NDK r27c, Lepton SoftOMX fingerprint 456e912c) is byte-identical to the contributed one (c1c2a73e). Agent version 70 -> 69 (main was at 68).
- ui/easter.py: seven quick clicks on the sidebar logo send a game's cover out of its portal for a somersault over
the wordmark; the Monitor's frame rate has a mood tooltip; holiday badges on the logo (a pumpkin Oct 24-31, a
snowflake Dec 20 - Jan 2); install milestones (confetti in the portal colours and a message at the 1st, 10th and
100th different game on the Frame). Milestones show once ever: they count different games (updates and
reinstalls don't), are stored as shown before they're shown, and the first use seeds from the games already on a
Frame (no "first game" after an app update). Off with setting ui.easter_eggs; Reduce motion keeps the words only.
- C.kv puts control values on the left; C.link (no indent) for the About page's source link.
- Showcase: the demo library has the easter eggs off; steps hooks easter_eggs / fps_dip and a clicks action;
record_video.py takes a storyboard file path (demos outside docs/).
- The install tutorial (app screen and card) shows curl -fsS <your-PC-address>:8765/<one-time-code> | bash, so
nobody takes the example for their own command; the card says FramePort fills them in.
- Videos: opening the Monitor tab no longer replays two minutes of samples (its charts raced to catch up); the
charts start there and grow live, as in the app. The pretend frame rate holds ~72 fps with a short, shallow dip
now and then (it sat at 55-61 fps for seconds). Both videos re-recorded.
Mesa enforces OVR_multiview's rule that a draw's program declares as many views as the draw framebuffer has and drops the draw silently; Doom3Quest compiles every vertex shader with layout(num_views=2) and draws its HUD/PDA into 2D-texture FBOs, which stay black on the Frame.
native/glmv (libfpglmv.so, same length as libGLESv3.so): the engine library's dlopen string is rewritten to it and it becomes the first DT_NEEDED. It records stage sources at link time and, when a multiview program draws into a framebuffer without views, draws with a lazily built single-view twin (num_views layout blanked, gl_ViewID_OVR -> 0u; attribute locations, block bindings and uniform values copied), then rebinds the original. Opt-in and experimental (analysis gl_multiview_libs, ANALYSIS_VERSION 6), setting gl_mv_debug, triage gl-multiview-twin-failed.
Host tests: the rewriter on Doom3Quest's 19 shaders (GPL-3.0 fixtures) and glmv.c against a stand-in GL that applies Mesa's rule. Untested on the device.
- record_video.py replaces record_tour.py: one storyboard per video with its output, a start state (demo or
fresh profile, Frame connected or not, start page, live or frozen stream), setup steps before filming, filmed
scenes and instruction cards (eyebrow, heading, numbered steps, a command, a note) for what happens outside
FramePort. --changed-since records the videos whose storyboard (or the recorder) changed; several videos record
in separate processes.
- videos/install.yaml: download, first start, the welcome screen's tool download, the setup command, the command
on the Frame (card), connected, scanning a folder, installing and playing a game (81 s).
- demo_home profile "fresh": FramePort's first start (empty library, welcome screen, nothing on the Frame); its
games wait for the pretend folder scan. Fakes for a first start: no Frame discovery on the real network, the setup
command shows the docs' example address, a pretend tool download, steps hooks first_run and pairing_done.
- CI records changed (or named) videos; releases attach every docs/media/frameport-<name>.mp4 as
FramePort-<name>.mp4. INSTALL.md and README link the tutorial; SHOWCASE.md documents the storyboard format.
- Cards are opened by their title in both videos (a card's middle is its Install button).
Klownicle's five fixes for Vader Immortal: Episode I, reimplemented as FramePort patches (found by Klownicle,
GitHub #49; their prebuilt APK is not used):
- frame.unreal_quest_precompile: UVRUtils::GetQuestShaderPrecompilePercent returned 0.0 on its non-Quest branch, so
the menu waited forever on the loading card; that branch now returns 1.0. Located by symbol, every instruction
around it checked (bl IsRunningOnSantaCruz; tbz w0,#0; fmov s0,wzr; ldp; ret); other builds stay untouched.
- frame.unreal_quest_keymap: the RPOC key selector (shared by AddAxisMapping and AddActionMapping, so both mapping
kinds were affected) picked the empty Gear VR key set for an Oculus HMD; the tbz to it becomes a nop. Located as
the common callee of both functions, pattern-checked.
- frame.zink_shader_fix + native/zinkfix (libVkLayer_fp_shaderfix.so): a Vulkan layer under Zink for GLES games
that applies zink_shader_fix entries (vk_shader_fix format) to the SPIR-V Zink generates; it adds itself to
GraphicsEnv's debug layer list from a constructor (the engine library loads it first). zink_shader_dump=1
captures modules. Vader's recipe carries the two lightspeed-shader fixes (12 OpStores each).
- adapter proximity_emul (FrameBridge): OVRPlugin's thumb/index proximity actions get bindings to the capacitive
touch inputs when the runtime lacks XR_FB_touch_controller_proximity (OVRPort offers the extension anyway):
generic replacement for the libUE4 ThumbUp patch.
- Vader's recipe: pose_time_fix + proximity_emul, status unknown (to test in the headset), min_app 0.12.1.
Analysis records unreal_quest_gates (ANALYSIS_VERSION 4). Star Wars: Tales from the Galaxy's Edge (same studio)
has neither gate (different engine build) and gets a catalog entry with pose_time_fix + proximity_emul.
Headless on the dev Frame (Lepton 3.0.5): the layer loads into Zink's instances, the proximity bindings are
accepted, and the game passes the loading card to the "guardian smaller than recommended, press any button" screen.
Newer Vivox builds (Green Hell VR) call AudioManager communication-device methods (API 31) from com.vivox.sdk.AudioChangeListener without a version check; Lepton is Android 11 -> NoSuchMethodError. The methods that call them now return at once (in-place dex edit, Dex.return_early). Detected by analysis vivox_api31 (ANALYSIS_VERSION 4), triage vivox-api31. Verified headless: Green Hell starts, Vivox initialises, ~65-70 fps.
Budget Cuts Ultimate asks for a 2048x2048 cube swapchain (faces=6); the Frame's runtime has no cube layers and refuses it (-2), OVRPlugin carries on with no images and crashes in ovrp_EndFrame4 (memset). cube_standin (default on, GLES) serves it as a GL cube map in the game's context and drops its layers. Triage cube-swapchain-refused (supersedes unity-render-crash/native-crash). Verified headless: the game runs on at ~70 fps.
- Library: an "All games" label and a 2 px rule (blue→orange in Portal themes) between the shelf and the grid,
hidden with the shelf.
- The pretend Frame's screenshots are the games' own in-game store screenshots (never cover art), installed games
first; the running demo game is BONELAB (it has screenshots, so Take screenshot adds a real one).
- render_docs: a shot whose steps fail is retried in a fresh window and never written (a window that didn't connect
had produced an empty Library); every shot is rendered twice in fresh windows and kept only when both agree (a
misdrawn glyph, "7.θ W", slipped through once); changes from 0.04 % of the pixels count (the sidebar card
showing another game is 0.12 %, run-to-run noise under 0.007 %). --fast renders once.
- record_tour: SHOWCASE_DEBUG=1 prints each step's start time. Docs images and the tour re-rendered.
Flet packs a patch by iterating each control's _values dict; Prop.__set__ adds/removes keys there without a lock.
A property set on a background thread (the live Frame card's sample, job progress, the library loader) while the
event loop packed a patch raised "dictionary changed size during iteration" and the update was lost (seen in CI:
a sidebar click left the old page showing). serialize_flet_updates() now makes property writes take the same
re-entrant lock as patches (app.FLET_LOCK). Reproduced with Flet's own encoder: 16 failures in 4 s before, none
after; test_property_writes_wait_for_a_patch_being_packed. The showcase server and ui_smoke serialize like the app.
Tour re-recorded with the fix (clean, no fallback clicks).
.github/workflows/showcase.yml renders the docs screenshots on UI pushes to main (and the tour when its storyboard or
scripts change, or on Run workflow), keeps every render as an artifact and opens/updates the PR showcase/update
when a picture visibly changed. Releases attach the tour as FramePort-tour.mp4. docs/SHOWCASE.md explains running
it, the step language and the CI flow; README opens with the teaser (link to the full MP4). Renders refuse
placeholder art (--allow-missing-art). tests/test_showcase.py: demo library, privacy scan, image threshold,
manifests (every docs image has a shot), step validation, element picking, pointer paths, ffmpeg graphs.
scripts/demo_record.py is replaced by the tour.
One window on the demo library, driven by name like a person would (drawn pointer, eased curved paths, a ring on
each click), filmed with Chrome's screencast (1080p, constant 30 fps from the frame timestamps), then captioned
(lower thirds in the app's colours), given title/end cards and crossfaded by postprod.py (ffmpeg only). Writes
docs/media/frameport-tour.mp4 (+ poster, best quality within 12 MB) and the README teaser docs/images/tour-teaser.webp.
Installs in the tour run a scripted job through the real queue; --draft for quick storyboard checks, --names lists
the clickable names. Element finding prefers the innermost match (a list's text includes its rows').
scripts/showcase/: the fakes split out of ui_smoke (the pretend Frame now reads the demo library's state, fixed
dates, a Take screenshot fake, a frozen monitor stream for stills), web.py (the GUI in web mode driven by name
through Flutter's semantics tree, drawn cursor, eased pointer paths, settle detection, one browser per window),
steps.py (the step language shared by screenshots and the tour), demo_home.py (21 catalog games from
docs/showcase/demo-library.yaml + demo-analyses.json, art and store details fetched once and cached) and
render_docs.py (docs/showcase/shots.yaml -> docs/images, rewritten only on a visible change; --check for CI).
BattleSisters' hands lagged behind the controllers (also with ovrp_hold_physics on or off). pose_debug=1 (new,
FrameBridge: per 5 s and per located space, the requested time minus the predicted display time) showed on the dev
Frame (SteamOS 0.4.5): XrTime runs 2.56 s ahead of CLOCK_MONOTONIC, the hand spaces were located at -2564 ms (the
monotonic "now" passed on as an XrTime: OVRPort's dispatcher converts XR_KHR_convert_timespec_time 1:1 and
FrameBridge's emulation is never asked) and the head at XrTime 0.1 s (~1450 xrLocateViews/xrLocateSpace per 5 s).
pose_time_fix (default off; suggested for Unity built-in OVRPlugin games): in xrLocateSpace(s)/xrLocateViews a time
nearer the monotonic clock than XrTime's "now" is moved by the offset measured at xrWaitFrame, one more than 0.5 s
before the display time goes to "now". Headless with it on: every far-past request moved, located times -9.8..0 ms
from the display time. Host unit test for the time rules; PLAYBOOK row.
frame.slz_vulkan_hooks: Stress Level Zero's plugin no longer intercepts Unity's Vulkan start-up and samplers (it called an invalid function right after OVRPlugin's pre-init instance was destroyed). Test fixture rebuilt by us, byte-identical. Owner's headset test: BONELAB 1.2974 plays well.
Closes#82
# Conflicts:
# docs/GAMES.md
# tests/test_patches.py
# tests/test_triage.py
ui/live_players.py: embedded_available (win/mac with flet_video; Linux only with Flet's full client + libmpv), find_mpv, order (pick first, browser last), low-latency mpv properties, MpvWindow (fails over when mpv quits at once). flet-video==1.0.2 only off Linux (its Linux plugin links libmpv.so.1: the bundle wouldn't start without it). Relay unchanged: native players read /stream.mp4 (checked with ffprobe/ffmpeg, from the start and mid-stream).
C.CoverButton (grid + shelf), library.card_hover/hover_motion/scrim, game page Play shows Starting on … with a spinner; glyphs.solid + frame-solid.svg for primary buttons; reduce motion skips lift/zoom/rings. ui_smoke: card hover, button hover and launch frames.
Main's microSD drives (agent v63), Linux app icons (agent v64), OBB detection, re-analysis and catalog entries,
on top of the UI refresh. Our monitor modules ({"modules": [...]}) move to agent v65 (MIN_AGENT_MODULES = 65).
Main's new UI uses the refreshed components: "Move to…" is in the game page's sectioned menu (MenuState.movable),
the .obb question and the Move dialog use C.dialog, the Frame page's drive picker C.dropdown, line-style icons.
analysis/unity_split.py: an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without assets/bin/Data/UnitySubsystems/ in the APK (zip names only), or BuildSettings listing more scenes than the APK has levelN files (globalgamemanagers loose, or its node read from the head of data.unity3d: only the blocks up to it). Exact on the 48 Unity APKs of both dump folders: all 13 split builds flagged, none of the 17 full builds (whose OBBs are asset bundles or sound banks). The missing-OBB warning and launch-test finding now cover them; existing entries are re-analysed at the next start.
quest_dump.find_data: after the folder-layout checks, search the APK's folder, its parent and up to 3 levels below each (folders with their own APKs are other games) for the package's expansion files; the folder with the APK's own versionCode wins, per kind the newest file not newer than it, else any version. When that folder holds other things too (the APK, other games' OBBs), the entry gets data_files and only those files are uploaded, counted and deleted with the game; no language-pack/content-file lookup there. Both dump folders scan identically (62/47 games).
The agent copies an AppImage's .DirIcon / the bundled .desktop file's Icon= (hicolor, pixmaps; symlinks resolved
and kept inside the app folder) to <anchor>/artwork/app-icon.{png,svg} at finalize_linux and when menu entries are
refreshed, and uses it for the menu entry's Icon= and the Steam shortcut (PNG only) unless the user chose an icon
(artwork/.icon-source = custom, sent by the PC with every art upload). StartupWMClass comes from the app's .desktop
file. A PNG goes back to the PC and becomes the library icon when there is no pick or store icon (no .picked;
.app-icon marker); folder apps get it at add time.
Closes#99
detect.ANALYSIS_VERSION (stored as analysis.extra.analysis_version, bumped
whenever analyze() gains a field): Quest/Android entries below it whose APK
still exists are analysed again in a background thread at the GUI's start
and before a build (pipeline.refresh_analyses). Only analysis and suggested
change; recipes the user didn't edit follow the new suggestion (e.g. an SDL
app gets frame.sdl_clipboard once sdl_java exists), user recipes, tags, art,
titles and builds stay. An unreadable APK keeps its entry and is marked
analysis_failed (not retried until the next bump); a missing one is tried at
a later start. Analyze again sets the version too. Rift/Linux entries skipped.
PLAYBOOK row + CLAUDE.md note.
The game page's "What FramePort will do" for a Linux app gets a Desktop Mode switch ("In the menu and on the
desktop", default on): kept in the library entry (desktop_entry; patches don't apply to Linux apps), passed to
finalize_linux at install and applied at once on the Frame through the agent's desktop_entry command when the app is
installed. The agent side (menu + desktop .desktop files, launcher with FRAMEPORT_DESKTOP=1, refresh for existing
installs, removal on uninstall/purge) came with agent v63. Help text desktop_entry, INSTALL/FRAME_RUNTIME/PLAYBOOK,
CLAUDE.md. Game Mode's Steam Input problem from the report is separate.
Closes#84
The Steam Frame page lists the Frame's drives (filesystem, free space, games on each; exFAT/NTFS cards say why they
can't hold games) and sets where new games go (library setting install.drive, default internal storage). Game menu
→ "Move to…" (Frame-installed games, agent v63) picks a drive in a dialog and queues a Frame job that follows the
agent's copy with progress (cancellable until the copy starts). The game page and the Frame page's installed list
show "on SD Card" / "SD Card not inserted"; a game whose card is out counts as installed, not "Not installed".
Help texts install_drive / move_game, ui_smoke --fake-frame drives + the Move dialog, docs (FRAME_RUNTIME "Where
FramePort keeps games", INSTALL, PLAYBOOK row, CLAUDE.md).
Closes#90
#90: `drives` lists internal storage, the drives under /run/media (from /proc/mounts) and the drives of Steam
library folders, with free space and whether they can hold games (vfat/exfat/ntfs can't: Lepton data and Proton
prefixes need Unix permissions and symlinks; read-only mounts neither). Games on a drive live in
<mount>/FramePort/<pkg>; the anchor stays internal, so the Steam shortcut never changes. prepare/prepare_pcvr/
prepare_linux take dest (free space from it); a drive that isn't mounted is an error, never a silent fallback to
internal storage, and an installed game keeps its folder. `move` (refused while the game runs, free-space check)
renames on the same drive, else copies with cp -a (in podman's user namespace when podman is there: container-owned
files), checks file count + bytes, retargets symlinks into the old folder (LibOVRRT redirect), points launch.sh at
the new folder (Quest: the app_dir line; Linux/PC VR: written again from the install record, which now keeps env and
game args) and rewrites deployment.json before removing the old copy; detached via systemd-run with a status file
(move_status). list_installed adds drive + drive_missing; launch refuses a game whose card is out; cleanup may remove
leftovers in a drive's FramePort folder (never an installed game); purge/uninstall remove an empty FramePort folder.
PC: install/drives.py (list, setting install.drive, move with progress, picks a running move up again), targets
pass dest for Quest/PC VR/Linux installs, CLI `frameport frame drives`, `frameport frame move <pkg> --to`,
`frameport install --dest`.
#84: finalize_linux writes ~/.local/share/applications/frameport-<slug>.desktop (+ an executable copy on ~/Desktop
when it exists; Exec quoted per the Desktop Entry spec, X-FramePort-Package marks ours) unless desktop_entry is off;
`desktop_entry` command switches it per app; ensure_host_fixes adds entries for existing Linux installs and drops
stale ones; uninstall/purge remove them. The Linux launcher with FRAMEPORT_DESKTOP=1 skips the Steam-parent
watchdog and Steam's display (upgrade_launchers updates existing launchers).
The PCM conversion is preventive (Sniper Elite VR and Hyper Clinic send envelopes), and revision 3 would show
'Update on Frame' for every build with adapter.haptic_fix (suggested for every OVRPlugin game), including builds
where upstream.py already left the workaround out. Same reasoning as rect_clamp. New builds get the PCM conversion
anyway (the artifact is rebuilt).
- native/ovrpshim: libunity.so's ovrp_BeginFrame/ovrp_EndFrame go through the shim; the frame wait waits <= 50 ms
for the last waited frame to begin and skips otherwise (the game deadlocked at its Init scene when Unity skipped a
begin); a begin/end of another index while a wait is outstanding gets the waited index. The physics-step
ovrp_Update2 is held back (prediction 0 located the controllers in the past on the Frame: the hands lagged).
frame.unity_oculus_check revision 5.
- native/xrshim: PCM vibrations (XrHapticPcmVibrationFB) become one plain vibration too; haptic_fix revision 3.
- Catalog: works with frame.unity_oculus_check + haptic_fix, min_app 0.12.1. Triage unity-frame-not-begun (info),
PLAYBOOK rows and how to get all-thread backtraces with gdbserver64 inside the container, test.
Stress Level Zero's graphics plugin (libSLZQuestNative.so) hooks Unity's Vulkan start-up and vkCreateSampler; on
the Frame its vkCreateInstance wrapper calls an invalid pointer right after OVRPlugin's pre-init instance is
destroyed (pc 0, a jump into an unloaded library, or a hang). The new patch turns the plugin's two registrations into
no-ops (found through its exported functions, 4 bytes each), so Unity starts Vulkan itself; ~35 s of shader
prewarming without the plugin's pipeline cache, then 72 fps (headless). One BONELAB recipe serves 1.2068 and 1.2974.
Triage slz-vulkan-hook-crash, PLAYBOOK row, tests with a fixture built from tests/fixtures/src/fakeslz.S.
Each build probes OVRPort's output for registered upstream fixes and leaves out the workarounds they make
unnecessary (recipes unchanged: builds with an older runtime keep them). Logged ("not needed: ..."), recorded in
build.superseded, left out of settings.conf at install, shown on the game page and in `frameport show`;
FRAMEPORT_KEEP_WORKAROUNDS=1 turns it off. Registered: ovrport.haptic_envelope (-> adapter.haptic_fix) and
ovrport.microphone_stream (-> frame.ovr_microphone), both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73;
haptics verified in the headset with Lucky's Tale). Probes checked against both real runtimes; fixtures in
tests/fixtures/src/fakeruntime.S.
bitmap + file via %TEMP%, macOS osascript PNG, Linux wl-copy/xclip; Flet's set_image only in web mode)); live view (`views/live.py` + `install/livestream.py`: the Frame's built-in SteamVR "headset view" webcam
(`steamvr-v4l2cam.service` → v4l2loopback "SteamVR" /dev/video99, see docs/FRAME_RUNTIME.md) + the default
output's pulse monitor (sound) → `fp_venc` (`native/venc`, own clean-room V4L2 driver of the Frame's iris
hardware encoder: RGB24→NV12 box downscale with NEON, H.264 CBR, one frame per slot of an even fraction of the
@@ -67,13 +68,38 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
mouse drags/right-clicks.
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
needs agent v63), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
needs agent v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
Games in a sample (agent v81): FramePort's own (launch.sh / container / Proton folder), any other game Steam's
reaper runs (`steam:<appid>`, title from the appmanifest, e.g. Job Simulator from Steam's store; not SteamVR, not
devkit entries; End game = Steam's Exit game), overlay apps flagged `overlay` and listed last; newest first. The
UI takes `frame/monitor.main_game(sample)` (never an overlay app alone). The agent's own workers never count.
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
heart, Pi Day, St Patrick's clover, Easter Sat-Mon egg (computus), April 1 = the logo upside down until hovered,
Halloween pumpkin, Dec 31-Jan 1 party popper, else Dec 20-Jan 2 snowflake; on those days the logo's box grows to
hold the badge: Flutter only hit-tests inside a control's bounds; `FRAMEPORT_TODAY` pretends a date),
Library search "frameport" (logo spins, "That's me!"), Type on Frame "hello" (keys still go to the Frame; a
headset peeks up from the window's corner and waves), the battery ring (only the ring, with a glow behind
it; not the percentage) empties at once and spins while its bar refills, then pulses, on reaching 100 % plugged (`charged_now`, `ring_pulse`, `ring_glow`), a long upload (>2.5 min, `HOP_AFTER`) makes the transit cover hop, the Live view
drives; vfat/exfat/ntfs/read-only refused), prepare*/finalize_linux `dest` (unmounted = error, never a fallback;
installed games keep their base), `move` (detached systemd-run + `move_status`; cp -a under podman unshare,
count+bytes check, symlink retarget, launch.sh: Quest app_dir line / Linux+PC VR rewritten from the record's
`launcher` field, else text swap), list_installed `drive`/`drive_missing` (install_state keeps such games
"installed"). PC: `install/drives.py`, library setting `install.drive` (Frame page → Storage), game menu
"Move to…" (job kind tool-frame), CLI `frame drives`/`frame move`/`install --dest`. Untested on the device.
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
@@ -114,7 +158,11 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
doesn't do). `VD.bat` is Virtual Desktop's launcher: ignore it except as an exe-location hint. Library migration
`rift_run_direct` resets existing recipes.
Auto launch-test is skipped on PC installs (it would start the game on the user's desktop). Launch tests collect the Unreal
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Lies Beneath via Proton without Revive
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Agent v67 adds Unity's logs (LocalLow/<Company>/<Product>/Player(-prev).log via `<Name>_Data/app.info`,
`output_log.txt`, Temp/…/Crashes/*/error.log; `unity_logs`) to launch tests and diagnostics; triage `unity-vr-init` /
`unity-crash`. A catalog recipe verified with another build (catalog `xr` ≠ the build's) keeps the build's own
`app-releases.json`, same 6 h cache, offline → cached / []) → `ChangelogEntry` (`whats_new()` keeps only the tag
message's "What's new" part: CI's intro, update hint and release-footer.md stripped); `between`/`changelog_for`
(installed < v ≤ offered, dev entry only when on/choosing dev, skipped versions included), `recent_history`;
the update/dev dialogs show one collapsible section per version (`_changes_column`, newest open); after an update
`pending_news` (setting `update.last_seen_version`; first start only records it; offline → retried next start)
→ Library "was updated" bar → "See what's new…"; Settings → Updates "What's new…" = recent history. CLI: `--version`, `frameport update [--check (exit 10)]
[--yes]`, once-a-day stderr hint read from the cache only (`refresh_cache()` in a daemon thread writes no
settings, to avoid read-modify-write races). `scripts/update_smoke.py <archive>` runs the real extract + swap script
(no relaunch) — CI runs it on all three OS with the archive it just built.
@@ -222,7 +296,17 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
by hand: the "Update now" button path (same apply(), called from the running app). macOS: CI smoke only.
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton).
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (app key, podman fix, Developer Mode, Lepton).
`bootstrap/setup.sh` — the setup URL (`curl -fsSL https://frameport.app/s | bash`, copied to the site by
`site/scripts/sync-media.mjs`): finds the PC (stdlib mDNS on 5353 for `_frameport-pair._tcp`, which the pairing
server announces while open; else the USB address and a /24 scan of `/ping`), `/hello` → the user clicks Allow (4
digits on both sides, `ask_digits`) → `/wait` hands over the code → runs bootstrap.sh exactly like the typed line.
Checked on the dev Frame 2026-10-10 (mDNS and scan paths) with a stub bootstrap; tests/test_setup_url.py.
Auto-pairing (`frame/autopair.py`, `app._start_auto_pair`, setting `frame.auto_pair`, default on; off with
FRAMEPORT_HOME/FRAMEPORT_NO_AUTO_PAIR): while no Frame is connected, Frames in Developer Mode (devkit mDNS, else a
30 s scan of the /24s for :32000/login-name, deduped by host key) are connected if FramePort's key works, else
offered the key via devkit.register every 4 s (refused at once until Pair new host is open; 45 s back-off after an
unanswered request). This WSL dev PC never hears the Frame's mDNS (Hyper-V firewall): the scan finds it.
- `catalog/games/<package>.yaml` (installed apps also fetch these from GitHub `main`, see "Catalog updates") — 38 recipes (34 verified 2026-09-28; Deadpool VR, 4XVR, NEX Player and AC Nexus's
90 Hz default confirmed later by the owner); `catalog/triage.yaml` — log signatures → fixes.
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
@@ -264,6 +365,11 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
`T.px(n)`, Material defaults through the theme's text_theme; never hardcode a bare pixel number in ui/.
`scripts/ui_smoke.py --scale 1.5 --viewport 2560x1440` renders it. Flet draws a grey box for invalid layouts (e.g.
an `expand` child in a `wrap=True` Row).
- One GUI per data folder (`core/instance.py`, 2026-10-11): `<data>/gui.lock` (flock/msvcrt, pid inside); a second
start asks the open window to come to the front (`gui.focus`, read by `_watch_links`) and exits; a lock held with no
window (no `gui.alive`) and older than 30 s is a leftover FramePort process and is ended. `main()` ends the process
(`os._exit`) once `ft.run` returns: background threads (re-analysis, session triage) used to keep a windowless
process running. Session triage shows one pop-up for all games of a burst (`_announce_sessions`).
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
## Round-2 polish (2026-10-02)
@@ -317,7 +423,10 @@ The Frame runs firewalld (22 and 32000 open). PC side: the setup server needs in
firewall blocks it silently (`DefaultInboundAction Block`): `pairing.ensure_reachable` adds a temporary rule via one
UAC prompt, removed when the server stops (flag file in %TEMP%, max 35 min); hints per OS after 45 s without a request
(`pairing.firewall_hint`). Flet 1.0 patches aren't thread-safe → `app.serialize_flet_updates()` (a dialog shown while a
scan redraw ran never closed: "dropped a patch for unknown control").
scan redraw ran never closed: "dropped a patch for unknown control"); it also makes property writes (Flet's
`Prop.__set__`) take the same `app.FLET_LOCK`: a property set on a background thread while the loop packed a patch
changed the control's `_values` mid-iteration ("dictionary changed size during iteration", a sidebar click's page
change died while the live Frame card took a sample). Anything that serves the app (ui_smoke, showcase) calls it too.
**Lepton:** needs an activity with category **LAUNCHER** (Quest apps often only have INFO → "APP_ACTIVITY is empty").
**2D apps:** Lepton runs every app headless (`lepton.headless=true`, only OpenXR output reaches the headset) unless the app folder (`<base>/lepton-app/`) has a `lepton-show-flatscreen` file (liblepton/app_metadata.sh) → Waydroid window on gamescope; agent v28 `set_flatscreen` at finalize for `vr_kind == "none"`. Android 11's navbar covered the
app's controls → patch `device.hide_navbar` (default on for vr_kind none, migration `flat_hide_navbar`) exports
@@ -534,11 +643,16 @@ reaches its OVRInput cleanly (probe: both Touch connected 0x63, individual butto
"outside of frame bounds" warnings stay (~2/frame) and are harmless.
Steam library (GitHub #4/#21/#27, agent v38): shortcuts go to every Steam account (signed-in first; MostRecent isn't
always the account on the Frame); stop_steam also waits for Steam's helpers; after the restart the agent re-reads
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`), else reports it. 0.6.3's Play
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`); lost again (agent v89): Steam
ignores FramePort's entries on that Frame, so `add_devkit_entry` adds the devkit entry instead (`added` gets `via:
devkit`) and only reports an error if that fails too. 0.6.3's Play
auto-repair (`_add_then_play`) crashed (its job got a Job, not a reporter). Uploads resume on a transient OSError.
Lepton's Android 11 has no clipboard service (134 services; checked with `podman exec … service check clipboard`):
SDL/LÖVE apps crashed at start → `frame.sdl_clipboard` (`apk/dex.py`: in-place dex edit, nops one invoke, fixes
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). 2D apps (vr_kind none)
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). Godot 4.2-4.4 (Kotlin `as ClipboardManager`,
GitHub #134 EndoparasiticVR) → `frame.godot_clipboard` (nops the `Intrinsics.checkNotNull` before that check-cast; Godot's
clipboard methods return at once; analysis `godot_clipboard`, ANALYSIS_VERSION 10; checked on real godot-lib/templates
4.2.2-4.4.1, not on the device; 4.1 and 4.5+ don't throw). 2D apps (vr_kind none)
keep every suggested patch that isn't about VR (`needs_vr = False`), not a fixed list. FramePort on the Frame:
`frame/local.py` (127.0.0.1, own key authorized; "This Frame (experimental)"; app data in
~/.local/share/frameport-app because ~/.local/share/frameport is the agent's) and Steam library changes wait while
@@ -551,7 +665,7 @@ recipe keeps it on). I Am Cat works with issues (judder; scale 0.8 no help). Rob
libroblox, also with the Vulkan shim). Installs skip the Steam restart when the shortcut is unchanged and wait while a
game runs (agent v37). New default fixes in FramePort's code reach games already in a library: after every app update
`library._follow_catalog` re-derives each non-user recipe once (setting `recipes.app_version`; tests switch it off via
`library.REFRESH_ON_UPDATE`); analysis fields added later still need a re-analysis. Troubleshooting techniques: docs/PLAYBOOK.md "Debugging techniques". Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
`library.REFRESH_ON_UPDATE`). Analysis fields added later: **bump `analysis/detect.ANALYSIS_VERSION`** (stored as `analysis.extra.analysis_version`); older entries whose APK is still there are analysed again at the GUI's start (background thread, not a job: ~8 s per 900 MB APK) and before a build (`pipeline.refresh_analyses`, GitHub #104); only `analysis`/`suggested` change, user recipes stay; an unreadable APK gets `analysis_failed` = the version (not retried until the next bump). Troubleshooting techniques: docs/PLAYBOOK.md "Debugging techniques". Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
(I Am Cat ran GLES); OVRPlugin's "Unavailable OpenXR extension: XR_FB_scene" is routine (no longer triaged).
**Round 4 (2026-10-04):** XR_KHR_android_surface_swapchain is listed by the Frame's runtime but returns
FUNCTION_UNSUPPORTED → adapter `surface_emul` (default on, `native/adapter/surface_swapchain.c`): an ordinary runtime
@@ -628,6 +742,9 @@ FrameBridge `snapshot=N` (`snapshot_gl.c`, GLES): every N s the left-eye image t
own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless launches show a black headset view, this
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. SteamVR's SetApplicationAutoLaunch didn't persist on the dev Frame (the watch never started by itself) → **agent v76: FramePort's own autostart**: the deployment's `overlay.autostart` is the source of truth; `ensure_host_fixes` (`ensure_overlay_service`) keeps the user service `frameport-vr-overlays.service` (`_vr_overlay_watch`, 5 s polls of the known vrserver's /proc stat) only while an overlay app has autostart on (register/unregister, uninstall, purge and the kill switch `~/.local/share/frameport/vr-overlays.disabled` / `FRAMEPORT_NO_OVERLAY_AUTOSTART` update it); a new vrserver (pid + start time, kept in `~/.cache/frameport-vr-overlays.json` across agent updates) → child `_vr_overlay_round`: wait for IVRApplications, 15 s grace, `launch_vr_overlay` for apps not running (folder process or GetApplicationProcessId); log `~/.local/share/frameport/vr-overlays.log`. Dev Frame: adopted the running vrserver, skipped the already running watch; a start after a real SteamVR start/boot is not yet seen. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**Vader's leak solved / AC Nexus flips (2026-10-14):** Vader Immortal leaves GL errors pending on its context; SteamVR's GL interop (`openxr_graphics_gl_openvr.cpp`, `xrclient_<pkg>.txt` in Steam's logs: `SXR_GL_CHECK: error:0x502 ... glGenTextures`) takes the stale error as its own failure once, and from then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a command buffer every frame (perf page-fault stacks; ~430 renderD128 mappings + 22 MB/s). Adapter `gl_error_clear` (GLES, catalog on for Vader ep1-3) clears them before xrCreateSwapchain/xrBeginFrame/xrEndFrame: flat memory, no SXR_GL_CHECK (headless). The per-game `xrclient_<pkg>.txt` logs are worth reading for runtime errors. The Frame's runtime caps swapchains per session (XR_ERROR_LIMIT_REACHED from ~15; AC Nexus asks for more): flip emulation now turns quads upright in place (scratch VkImage, once per acquire; transfer usage added to single-layer colour swapchains) instead of a shadow swapchain per quad, which ran into the cap (#138). Headless sessions don't draw AC Nexus's panels (adapter `flip_dump` saves before/after images of flipped quads, for headset runs).
**Lepton's logcat mirror dies (agent v67, 2026-10-09):** occasionally launch.log ends with `logcat: Unexpected EOF!` right after the game starts (about 1 launch in 50, Lepton 2.8.14 and 3.0.5): no dashboard auto-hide, no launch-test result. launch.sh's `_logcat_keeper` then reads `podman exec lepton-steamlaunch-<appid> logcat` itself into launch.log (up to 5 restarts while the game runs).
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
"(starting)", "Clearing baked app data due to early exit") and both died (Vader, BattleSisters). launch.sh now takes
@@ -655,6 +772,117 @@ head-pose deadline ends VR mode without a worn headset (not a game bug).
**Language packs (merged from PR #20, 2026-10-04):** overport's `libovrplatformloader.so` is a dispatcher that `dlopen`s Meta's own loader (`libovrplatformloader_meta.so` / `_meta_q1.so`, also `libpxrplatformloader.so`) and keeps its own message queue; `ovr_LanguagePack_GetCurrent/SetCurrent` are 8-byte `return 0` stubs in it, `ovr_AssetFile_GetList` forwards to Meta's loader. `frame.langpacks` (opt-in, `native/langpack`) serves `<tag>.lang` files from the game's data; `elf.hide_exports` marks the loader's exports STB_LOCAL (bionic and glibc only match GLOBAL/WEAK; verified for glibc, bionic's `is_symbol_global_and_defined` is from memory). `libfp_langpack.so` is built here (`python native/build.py --only langpack`) and committed. Owner-verified 2026-10-04: Deadpool VR with only `en.lang` and the patch on plays English dialogue and runs normally (headless launch tests show 2-6 fps while it loads: not a regression sign). A dispatcher answer that arrives after we answered the timed-out GetList is dropped (one answer per request); games already in a library get `lang_packs` filled after an app update (`library._refresh_data_fields`). The library logs to logcat under the tag `fp_langpack` (dirs looked in, packs found, every language-pack call), so it shows up in the game's `launch.log`; an `ovr_AssetFile_GetList` the dispatcher leaves unanswered for 1.5 s is answered with our packs alone. Deadpool VR (Unreal) accepts a pack only when its `Metadata` equals the game's own version string (`ULanguagePacksSubsystem` compares it with `%s.%s.%s.%s.%s` built from the build info, e.g. `1.0.40.356975.Quest` = versionName; found by disassembling `libUE4.so`): the patch writes the APK's versionName into the library (`@FPMETA@` slot, `with_metadata`), `FRAMEPORT_LANGPACK_META` overrides it. Deadpool VR (2026-10-04, headset): German became selectable with that Metadata, but dialogue stayed silent (even English once reported as a pack) while the path was spelled `/sdcard/Android/obb/<pkg>/x.lang`; with the `/storage/emulated/0/Android/obb/<pkg>/x.lang` spelling (Unreal's own, now listed first in `scan_dirs`) German dialogue plays. `FRAMEPORT_LANGPACK_SKIP=<tags>` or a file `fp_langpack_skip` in the obb folder leaves packs out of the list (experiments).
`games/<pkg>/target/shaders/`. Triage `gpu-hang` suggests both dumps; `triage.graphics_api` (FrameBridge's
xrCreateSwapchain formats: <0x1000 Vulkan, else GL) keeps only the session's API's (`API_ONLY`). Host-tested only.
Agent v74: a reporter's hang came ~10 s after the last new module, so the culprit wasn't among the newest written →
with an index naming modules, `shader_dumps` sends every module of the index's **newest session** (after the last
`# start`), deduplicated, last used first, each ≤4 MB, ≤30 MB in all (`session` = how many it names) + that session's
index lines; no index (fp_spirv) = the old newest-by-mtime 4 MB. One JSON line (~40 MB base64) over the SSH channel
is read whole by `Frame.agent` (fine at that size); `_Writer.fit` drops the least recently used modules last, after
cutting logs, to stay under the 24 MB zip limit.
**Unreal OBB check (GitHub #159, 2026-10-10):** Epic's DownloaderActivity checks OBB name+size (OBBData), then with `bVerifyOBBOnStartUp=true` CRCs the whole OBB; its screens are invisible in Lepton (stuck at `Displayed …/.DownloaderActivity`, triage `unreal-obb-check-stuck`) → `frame.unreal_skip_obb_check` (analysis `unreal_verify_obb`, ANALYSIS_VERSION 11) flips the manifest boolean (`axml.set_meta_data_bool`); skips only the CRC pass. Host-tested only.
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
once (`Dex.return_early`: return-void / `const/4 v0,0; return v0`; nopping the invoke would leave a move-result the
verifier rejects). Older Vivox (Eleven Table Tennis, BattleSisters) lacks that code and isn't matched. Verified
headless: Vivox initialises, 150 s at ~65-70 fps.
**Steam Input gamepad for 2D apps (GitHub #162, agent v72, 2026-10-10):** Lepton's Android only has the Wayland seat's `wayland_touch/keyboard/pointer`. Steam makes its virtual pad (uinput, `/devices/virtual/input`, 28de:11ff, "Microsoft X-Box 360 pad N", ACL for steamos) only while the Frame's controllers are on (controller.txt "Steam Controller reserving XInput slot 0"; gone when they sleep), also with no game running; steamos-manager's 28de:0000 keys device isn't a pad (no BTN_SOUTH). Lepton's container root (system_server too) = steamos (`keep-id:uid=0`), so a bind-mounted `/dev/input/eventN` opens. Opt-in `device.steam_gamepad` (vr_kind none; suggested for `android.hardware.gamepad`/`LEANBACK_LAUNCHER`, analysis `gamepad`, ANALYSIS_VERSION 9): deployment.json `steam_gamepad` → launcher `GAMEPAD_LINE` (after the codec line: exports FRAMEPORT_GAMEPAD + LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD, puts `agent/bin` first on PATH) → `PODMAN_WRAPPER` (written by ensure_host_fixes; non-`run` calls go straight on; `run` imports the agent's `podman_run_args`; hands on to the next Podman after its own PATH entry with itself removed from PATH, so it chains to the codec wrapper either way round) adds the pads + `Vendor_28de_Product_<pid>.kl` (Xbox 360 layout). Verified headless with a uinput stand-in pad (Stremio's container: EventHub `classes=0x80000141`, our key layout, KeyEvent BUTTON_A dispatched); with Steam's real pad and a game: not yet. Pads made after the start aren't seen until the next one.
Install games that target the Meta Quest, Android, or general PCVR onto your**Valve Steam Frame**. FramePort handles everything from uploading game files, setting up your Frame, injecting compatibility patches, and adding shortcuts to your Steam library. FramePort aims to be as simple as possible by taking advantage of the fact that the Steam Frame runs on Linux.
Install Quest games, Android apps, Linux apps and PCVR games on the**Valve Steam Frame**. FramePort sets up the
Frame, patches each game so it runs there, uploads it and adds it to your Steam library with artwork.
> **Notice:** FramePort explicitly does NOT download, share, or unlock games. You must provide legally obtained game
> executables. Core features of FramePort simply download and wrap other published tools (see [Built on](#built-on))
> with patches provided by FramePort adding a hardware compatibility layer. This enables users to use games/apps legally
> purchased on sites like [SideQuest](https://sidequestvr.com/).
▶ [Watch the full tour](docs/media/frameport-tour.mp4) · New to FramePort? [Watch the install tutorial](docs/media/frameport-install.mp4)
(about 90 seconds each)
> **Notice:** FramePort doesn't download, share or unlock games. Use games you got legally, for example from
> [SideQuest](https://sidequestvr.com/). FramePort downloads published open-source tools (see [Built on](#built-on))
> and adds its own patches so games run on the Frame.
>
> Not every game runs on the Frame: recipes are tested by the community, and some games need Meta's services or
> hardware the Frame lacks.
## Features
- **Painless setup:** one short command on the Frame. No root, no `sudo`, no password.
[What it changes](docs/FRAME_SETUP.md).
- **Type on Frame:** use your computer's keyboard on the Frame: in VR apps, Android apps, Steam and the desktop.
[More](#type-on-frame).
- **One click per game:** convert, patch, sign, upload, add to Steam with artwork, launch test. Artwork that can't be found automatically can be picked from the stores or replaced with your own images.
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
curved and 360° layers); game settings as simple switches.
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, Windows (non-VR) games via Proton, a Files
tab with drag and drop.
- **Linux apps:** install Linux apps (AppImage, a folder, or a zip/tar archive) on the Frame with a Steam
shortcut; arm64 builds run natively on SteamOS, x86_64 builds through Valve's FEX translator.
- **Install links:** "Install with FrameDrop" buttons on web pages (the one-click protocol of the FrameDrop
sideloader) and pasted links open in FramePort, which asks, downloads, adds and installs the build.
- **Screenshots tab:** the screenshots you took in the headset, sorted by game (matched by play time) and day;
view them and download them to your computer.
- **Live view tab:** watch what the headset shows, with sound, in a browser window on your computer.
- **Monitor tab:** the running game's frame rate, the Frame's load, temperatures, power and battery live, and its
processes, which you can end. [More](#monitor).
- **Self-updating** releases, redacted diagnostics, one-click problem reports and working-config sharing.

## Quick start
- **Easy setup:** one line in the Frame's terminal. No root, no password. [What it changes](docs/FRAME_SETUP.md).
- **One click per game:** patch, upload, add to Steam with artwork and test that it starts.
- **Recipes:** tested patches and settings for 120+ games; other games get suggested patches, each explained in
plain words.
- **Game settings:** sharpness, refresh rate, controllers, 360° video and mixed reality as simple switches.
- **More than Quest games:** Android apps in a window, Linux apps, PC VR games and Windows programs.
- **Install links:** "Install with FramePort" buttons on websites ([frameport.app](https://frameport.app) links) open
in FramePort.
- **Your Frame from your PC:** Type on Frame (your keyboard on the Frame), Files, Screenshots, Live view (what the
headset shows, in your browser) and Monitor (frame rate, temperatures, battery, processes).
- **Updates itself**, and reports problems without personal data.
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) and unzip the build for Windows, macOS
(Apple Silicon) or Linux, then start FramePort.
2.**Connect the Frame** (once):
1. In FramePort click **Steam Frame → Show setup command**. Keep FramePort open; the Frame and your computer must
be on the same Wi-Fi.
2. On the Frame open the **SteamVR dashboard → Launch a program → Desktop**: the Linux desktop opens on a virtual
screen.
3. Open the app menu (bottom-left corner of that desktop) → **System → Konsole** (or search for Konsole).
4. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
**Enter**. This will run the following [bash setup script](bootstrap/bootstrap.sh).
5. After a few seconds the desktop closes by itself (Steam restarts once); that's expected. If
Steam asks to install **Lepton**, confirm it. FramePort shows the Frame as connected within a minute. No
password needed.
3.**Add games → Scan a folder** with your game backups (APK + OBB, or PC VR game folders).
4. Open a game → **Install on Frame**, then play it from the Frame's Steam library.
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md). Questions (e.g. how to lay out games
with OBB files): [docs/FAQ.md](docs/FAQ.md).
## Type on Frame
Typing in VR is painful, so FramePort turns your computer's keyboard into a keyboard for the Frame. Open **Type on
Frame** (its own tab in the sidebar), select a text field in the headset and type: searches, logins, chat, in any
app, in Steam or on the desktop. Paste longer text to type it in one go. Nothing to install: FramePort adds a virtual
keyboard on the Frame while the tab is open, without root.

Unity apps whose text fields close the moment you select them on the Frame (no system keyboard there) get a per-game
fix, so Steam's on-screen keyboard and Type on Frame work in them too.
[Details](docs/INSTALL.md#typing-on-the-frame).
## Screenshots
Screenshots you take in the headset show up in FramePort's **Screenshots** tab, grouped by day and matched to the game
you were playing. Open one full size, step through them, and download single shots, a selection or all of them to
**Tested something? Share it.** In FramePort open the game → **…** → **Share working recipe…** (it fills in the
recipe for you) or **Report a problem…** (attaches a diagnostics zip with personal data removed). Without the app:
[share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) · [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml). Shared configs become built-in recipes for everyone.
FrameDrop and Valve's own tools install an app as it is. FramePort differs in four ways:
- **Free and open source** (GPL-3.0). FrameDrop is free (donationware) without published source.
- **Quest games that don't run on the Frame as they are** get converted and patched, with a tested recipe for 120+
games. The others install the game unchanged.
- **Windows, macOS and Linux.** FrameDrop is for Windows.
- **Wi-Fi or a USB cable**, and no **Pair new host** step.
<details>
<summary>All differences</summary>
| | **FramePort** | **FrameDrop** | **By hand with Valve's tools** |
|---|---|---|---|
| Cost and source code | Free, open source (GPL-3.0) | Free (donationware), source not published | Free, from Valve |
| Runs on | Windows, macOS, Linux | Windows | Depends on the tool |
| First connection | One line in the Frame's terminal; it turns on Developer Mode itself. No password | Turn on Developer Mode, then **Pair new host** | Turn on Developer Mode and pair, or use Android's debug tool (adb) |
| Wireless or cable | Wi-Fi, the Frame's hotspot or a USB cable | Same Wi-Fi network | Wi-Fi, or adb |
| Quest games that don't run as they are | Converted and patched for the Frame | Installed as they are | Installed as they are |
| Knows which patches a game needs | Tested recipes for 120+ games, updated without an app update | Not stated | No |
| In the Steam library | Yes, with artwork and tags | As a "Devkit Game" shortcut | As "Devkit Game: <title>"; not with adb |
| Android apps, Linux apps, Windows programs | All three; Proton (runs Windows programs) is installed for you | All three; install Proton first | Yes; 2D Android apps need an extra file |
| PC VR games | On your PC; some also on the Frame | Not supported | Not supported |
| "Install with …" buttons on websites | Yes ([frameport.app](https://frameport.app) links) | Its own | No |
| A game doesn't start | A launch test reads the logs and suggests a patch | A log viewer | No help |
| Use the Frame from your PC | Live view, Monitor, Files, Screenshots, Type on Frame | Not stated | No |
</details>
Other tools as described on their own pages, checked 2026-10-09:
@@ -143,8 +138,9 @@ uv run frameport --help # command line
uv run pytest # tests
```
## AI Usage Notice
While I would like to program everything manually, I no longer have much free time for personal projects. As a result I make use of AI tools to make it significantly quicker to debug compatibility issues.
## AI usage
I don't have much free time for this project, so I use AI tools to debug compatibility problems faster.
@@ -5,12 +5,15 @@ FramePort is GPL-3.0-only (see `LICENSE`). It includes or downloads the followin
## Included in this repository / the release bundles
| Component | Where | License |
|---|---|---|
| LuaJIT (Lua engine for verified xLua pointer compatibility, github.com/LuaJIT/LuaJIT) | `artifacts/arm64-v8a/libfp_luajit.so`; pinned build in `native/luajit/build.py` | MIT (`native/luajit/COPYRIGHT`, included in bundles as `artifacts/licenses/LuaJIT.txt`) |
notes:'GPU hang (zink:DEVICE LOST) even with MSAA off.'
details:Legacy VrApi via OVRPlugin.
status:works
notes:Plays with controllers that follow the hands (owner's headset test, Quest 1 build 35713).
details:'Unity 2019.4 on its built-in Oculus VR (no Oculus XR Plugin). Its legacy frame loop never waited for frames
(GPU hang, zink: DEVICE LOST) and Unity reported only Go controllers: frame.unity_oculus_check. Its frame wait then
deadlocked at the Init scene (Unity skipped beginning a frame) and the physics-step pose update located the
controllers in the past (hands lagged): both fixed in that patch (revision 5), switched on for this game by ovrp_begin_gate and ovrp_hold_physics. Grabbing an object in the tutorial
vibrates the controller; OVRPort''s envelope conversion then allocated ~1 GB/s until the memory cap ended the game:
haptic_fix. The Quest 1 build has no arraysources_assets.hd, which the game loads on the Frame (it detects a Quest
2) and then skips the .sd bundle:textures are missing unless the .sd file is also present under the .hd name in the
game''s obb folder.'
frame:
- frame.unity_no_msaa
- frame.unity_oculus_check
adapter:
haptic_fix:1
ovrp_begin_gate:1
ovrp_hold_physics:1
pcvr_alternative:Sniper Elite VR (Steam PC VR). Winter Warrior (Quest) works on the Frame.
notes:Build 1.2068 plays (head tracking and controls work with frame.unity_user_presence); the newer build
1.2974crashes at start (Vulkan), no fix yet.
details:The game runs (72 fps), but its player body stayed frozen - no head tracking, the controllers stuck to the
status:works
notes:Builds 1.2068 and 1.2974 play (owner's headset tests). One recipe serves both builds - 1.2068 needs frame.unity_user_presence (head tracking and controls); 1.2974 also needs
frame.slz_vulkan_hooks (72 fps after about 35 s of shader prewarming), which changes nothing on 1.2068.
details:Both builds - the game runs (72 fps), but its player body stayed frozen - no head tracking, the controllers stuck to the
model, no buttons - although OpenXR input and OVRPlugin's poses were fine. A few seconds after start (when Unity
switches XR loaders) OVRPlugin reports the worn headset as not worn (ovrp_GetUserPresent2 -> 0), and BONELAB's
Marrow rig only follows the player while Unity's HMD device reports UserPresence (OpenControllerRig checks
XRHMD.IsUserPresent every frame). frame.unity_user_presence reports the headset as worn. Build 1.2974 crashed
earlier in vkCreateInstance from the game's own Vulkan hooks (libSLZQuestNative.so); not retested.
tested_version:1.2068.34555
XRHMD.IsUserPresent every frame). frame.unity_user_presence reports the headset as worn. Build 1.2974 only - it adds Stress
notes:Audio on the file select screen sounds crunchy. Controls may need rebinding in the game. Quest build from https://github.com/fulldivegames/Majoras-Mask-VR.
Loaded 100 of 682 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.