Compare commits

...
Author SHA1 Message Date
spoopyghosty0 423b01ee98 Site specimens: steps.html comparison page (two steps, one shot; side by side or swipe) 2026-10-12 11:44:51 -04:00
spoopyghosty0 93b62fa185 Site: polish. The walkthrough's Downloads scene shows each platform's real file and program (was '?os=linux' and FramePort.exe everywhere); site dim text #868a96 (4.77:1+) and themed placeholders; pending install-log stages stay readable; no labels under 11px; docs blockquote without a side bar; changelog version without gradient text; notice at 64ch. Closes the 2026-10-12 critique snapshot 2026-10-12 11:38:52 -04:00
spoopyghosty0 fc47dab20b Site: onboard. Get started step 3 says what the setup line does (finds FramePort on your PC and waits; nothing changes on the Frame until you click Allow; then Developer Mode turns on; no password, no root) with a link to read the script. Specimens: walkthrough step 3 shot 2026-10-12 11:24:36 -04:00
spoopyghosty0 58a936c706 Site: adapt. Below 1120px the nav links move into a Menu panel (48px rows; Esc, outside click or a link closes it) instead of wrapping at 1024 or vanishing on phones; 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 600px. Specimens: phone user agent, menu shot 2026-10-12 11:15:54 -04:00
spoopyghosty0 72f94d1754 Site: distill + layout. App tour screenshots and videos behind one disclosure, the game-maker block is one row (snippet in the docs), What's new is one line, Compare without icon tiles, shared .disclose style; calmer section padding, labels closer to their headings, the notice sits between sections at 72ch, bigger hit areas on the hero's platform links (home 10.8k -> 8.4k px) 2026-10-12 11:08:15 -04:00
spoopyghosty0 af6117ad5d Site: clarify. Section labels name what you do there instead of 01-07; plain headlines (What happens when you click Install on Frame); hero eyebrow removed; nav Install -> Get started (no clash with the /install/ link page); /download/ says why Windows and macOS warn and where the SHA-256 sums are 2026-10-12 10:54:44 -04:00
spoopyghosty0 540c71982f Site: quieter. Removed the cursor light, click ripples, word-by-word headings and gradient letters, label scramble, count-ups, scroll fade-ups, the hero's scroll fly-away, film grain, icon hover tricks, the download page's warp field and breathing glow, Compare's tilt and pointer light, the contributors' dock magnification; springs land without overshoot (--ease-settle). Motion stays where something crosses. 2026-10-12 10:47:48 -04:00
spoopyghosty0 daa71a144e Site specimens: capture script, gallery and the baseline (00-before) 2026-10-12 10:35:19 -04:00
spoopyghosty0 a64a4c6958 Critique snapshot for frameport.app (28/40): effect stack and section scaffolding read as generated, no phone nav, long home page 2026-10-12 10:27:48 -04:00
spoopyghosty0 c0b3ba960a DESIGN.md + .impeccable/design.json: the Portal design system as built (tokens, rules, components) for design work on the app and the site 2026-10-12 09:54:55 -04:00
spoopyghosty0 aaf6aba621 PRODUCT.md: product record for design work (users, purpose, positioning, brand commitments, principles) 2026-10-12 09:48:47 -04:00
spoopyghosty0 80ef9ca6a7 Site: release and contributor data refreshed (1.0.4) 2026-10-11 21:30:25 -04:00
spoopyghosty0 c744f8740b Site: the games board lists games in a random order on every visit (working games first, then with issues, then not running) 2026-10-11 21:30:13 -04:00
spoopyghosty0 859f921361 Monitor: games Steam runs that FramePort didn't install count (e.g. Job Simulator from Steam's store, named from its appmanifest; End game = Steam's Exit game), overlay apps are never the game being played, and the newest game comes first (agent v81) 2026-10-11 17:27:10 -04:00
spoopyghosty0 6acf9d048e App icon: all four corners rounded (only the top-left one was); source runs on Windows show FramePort's icon instead of Flet's (window.icon from an .ico made of it) 2026-10-11 14:40:32 -04:00
spoopyghosty0 cf18b30be9 Uninstall dialog: the delete-saves checkbox label wraps (it ran past the dialog; also the delete-files-on-this-PC, share-recipe and remove-FramePort checkboxes), games uninstalled from the Frame page show their name; Monitor: the agent's own workers aren't a running game (uninstalling showed the game as now playing while Steam restarted; agent v80) 2026-10-11 14:33:47 -04:00
spoopyghosty0 eec0218b1d FramePort 1.0.4 2026-10-11 14:13:56 -04:00
spoopyghosty0 526ba0a684 Launch tests: a game that ran until the session ended for lack of a worn headset (VrApi bridge tracking deadline) passes, even when it crashes afterwards (BlazeRush 'Failed to launch' on install, GitHub #57) 2026-10-11 14:12:04 -04:00
spoopyghosty0 23b6ff1b5e Dev: WSL source runs can share the Windows copy's data folder (FRAMEPORT_WINDOWS_DATA=1): paths saved by the other side are translated on load, Java and Cpp2IL keep one copy per OS, and such runs leave the link handler alone 2026-10-11 13:59:37 -04:00
spoopyghosty0 00c6863e54 FramePort 1.0.3 2026-10-11 13:31:33 -04:00
spoopyghosty0 4863035a05 catalog: Robo Recall gets sync_guard (vrclient xrSyncActions crash on the splash after the intro, GitHub #188) 2026-10-11 13:31:20 -04:00
spoopyghosty0 a30162ea63 Fixes from reports: update dialog links open again (#194), a relaunched update no longer inherits the old app's Flet/Python environment (black window, #195), report form says to attach the zip in a comment if the box won't take it (#196), Job Simulator and Vacation Simulator without Lepton's Android window (frozen hands, #193/#163) 2026-10-11 13:27:18 -04:00
spoopyghosty0 24eaa97fde GUI: one window per data folder (a second start brings it to the front, a windowless leftover is ended), the process ends with its window, and checked play sessions get one pop-up instead of one per game 2026-10-11 13:20:31 -04:00
spoopyghosty0 767b152423 Merge catalog recipe from #183 2026-10-11 12:29:42 -04:00
github-actions[bot] 10e7669f70 catalog: com.drawandcode.TaskmasterVR (from #183) 2026-10-11 16:29:18 +00:00
spoopyghosty0 76c4f2d225 FramePort 1.0.2 2026-10-11 12:28:58 -04:00
spoopyghosty0 3a851e911f frame.ovr_stable_user: games keep the same Meta user id between starts (OVRPort made up a new one each time, so saves filed under it were lost; GitHub #182) 2026-10-11 12:22:02 -04:00
spoopyghosty0 2912d637b5 FAQ: where a game's settings are and how to diagnose and fix games that don't start, hang, look wrong, stutter, pause, lose progress or need your files; site data refreshed for 1.0.1 2026-10-11 10:48:56 -04:00
spoopyghosty0 5c070ccf23 Merge catalog recipe from #181
# Conflicts:
#	docs/PATCHES.md
2026-10-11 10:11:56 -04:00
spoopyghosty0 b979067215 Merge catalog recipe from #176
# Conflicts:
#	docs/PATCHES.md
2026-10-11 10:11:41 -04:00
spoopyghosty0 4440b459e5 Merge catalog recipe from #175
# Conflicts:
#	docs/PATCHES.md
2026-10-11 10:11:31 -04:00
spoopyghosty0 09b8046337 Merge catalog recipe from #174 2026-10-11 10:11:21 -04:00
spoopyghosty0 0d67335775 catalog: A Fisherman's Tale works with issues (loading screen corruption) 2026-10-11 10:11:09 -04:00
github-actions[bot] 867b9977d5 catalog: com.SchellGames.DieScreaming (from #176) 2026-10-11 14:10:09 +00:00
github-actions[bot] 80092098e0 catalog: com.schellgames.ieytd3 (from #181) 2026-10-11 14:10:06 +00:00
github-actions[bot] feab03e049 catalog: com.innerspacevr.afishermanstale (from #175) 2026-10-11 14:10:06 +00:00
github-actions[bot] 9713da222a catalog: com.WhiteDoorGames.Cosmodread (from #174) 2026-10-11 14:10:05 +00:00
spoopyghosty0 82e617741e FramePort 1.0.1 2026-10-11 09:59:28 -04:00
spoopyghosty0 9de0707070 Diagnostics: a zip saved while a Quest game runs includes threads.txt (each thread's wait, syscall and CPU use, TCP sockets) to find what a hanging game waits for (agent v79, GitHub #157) 2026-10-11 09:58:09 -04:00
spoopyghosty0 1343930ae0 Triage: Android's WebView helper exit is no force-quit; new app-not-started signature for Lepton's "Target app did not start" (GitHub #179) 2026-10-11 09:44:54 -04:00
spoopyghosty0 5424e429e4 Site: the patch reference in the docs; recipe chips (How it works, games board) and patch ids in docs code spans link to their entry 2026-10-11 00:58:52 -04:00
spoopyghosty0 9149eaecd7 Patch reference: scripts/patch_docs.py generates docs/PATCHES.md (every patch and game setting, anchors per id) and the site's id map; --check test, catalog PRs regenerate it 2026-10-11 00:58:51 -04:00
spoopyghosty0 64278e9e2c README and site: recipes for 120+ games (the catalog has 126) 2026-10-11 00:54:41 -04:00
spoopyghosty0 1531acb575 Site: the Built on section only on the homepage, What's new shows only the contributors
Footer's credits defaulted to on, so privacy and 404 showed it. credits is now 'all' (homepage), 'contributors'
(What's new) or off (default).
2026-10-11 00:51:58 -04:00
spoopyghosty0 6796e006d9 Site compare: the open-source card stacks on phones
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.
2026-10-11 00:51:56 -04:00
spoopyghosty0 45f05dd3c6 Site demo: the Library header no longer squeezes its title on narrow screens
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.
2026-10-11 00:51:54 -04:00
spoopyghosty0 5a2cd45fb8 Site: the hero scene stays sharp while it's in view on phones; no click ripples on touch
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.
2026-10-11 00:51:52 -04:00
spoopyghosty0 757671b389 Merge pull request #172 from spoopyghosty0/showcase/update
Update docs screenshots and videos
2026-10-10 23:52:39 -04:00
spoopyghosty0 8a2029c079 Docs: update screenshots and videos 2026-10-11 03:38:33 +00:00
spoopyghosty0 b2688dd368 Catalog: BlazeRush verified in the headset with FramePort's fixes (owner, 2026-10-11) 2026-10-10 23:33:55 -04:00
spoopyghosty0 13de114f39 test_showcase: the fake setup server check looks at every PairingServer method the GUI touches (it missed open_asks) 2026-10-10 23:26:11 -04:00
spoopyghosty0 9016982394 Showcase: the pretend setup server has open_asks/prune like the real one (the install tutorial failed to record); test that it offers everything the GUI uses 2026-10-10 23:25:14 -04:00
spoopyghosty0 cad2cd366c BlazeRush: min_app 1.0.0 (ships with 1.0) 2026-10-10 23:24:03 -04:00
spoopyghosty0 a5820e68d7 BlazeRush works (GitHub #57, from Klownicle's guide; headset check pending): frame.blazerush (1.0.349 only, fingerprint + words checked: avatar wait NOP, avatar renderer RET, shader cache cache/br_shader_v1), device.config_sync (agent v78: saved resolution in user_config.xml from the runtime eye size x scale at finalize, settings changes and before each start), catalog recipe with the GL shim fixes and scale 1.7, triage vrapi-fov-before-tracking / gl-integer-attribs 2026-10-10 23:18:42 -04:00
spoopyghosty0 aff939a6d8 BlazeRush native fixes (GitHub #57, Klownicle's guide): GL shim gl_int_attribs (integer vertex inputs fed as floats re-set with glVertexAttribIPointer per draw, per-context reflection dropped on link/binary/delete) and gl_highp_markers (matching vertex shaders compute in highp); VrApi bridge answers FOV properties 7/8 before any session with the live FOV saved by an earlier session (also located before tracking); FrameBridge logs the runtime's unscaled eye size 2026-10-10 23:18:41 -04:00
spoopyghosty0 5e725fbd08 Issue chooser: links to Discussions (questions, ideas) and the docs 2026-10-10 23:16:30 -04:00
spoopyghosty0 1cb9e6f65b docs/brand: GitHub social preview image (1280 x 640) 2026-10-10 23:11:13 -04:00
spoopyghosty0 7a843962e8 Catalog: Time Crisis II VR (test build) works (shared working config)
Closes #173
2026-10-10 23:09:47 -04:00
spoopyghosty0 51220a4942 docs/brand: square FramePort icon (1024 px PNG + SVG) for project pages and listings 2026-10-10 23:00:24 -04:00
spoopyghosty0 981daa3c2d Unreal: start without the OBB check (frame.unreal_skip_obb_check, GitHub #159)
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.
2026-10-10 22:49:21 -04:00
spoopyghosty0 9781c2bb6b Showcase: sharper README teaser (1280 px, 20 fps, WebP drawing preset from quality 95, up to 8 MB) and tour/install videos (CRF from 17, up to 20 MB); the teaser showed compression artifacts 2026-10-10 22:32:47 -04:00
spoopyghosty0 a7c349835c Install looks for a game's data files again when it was added without them (OBBs copied next to the APK afterwards; GitHub #156) 2026-10-10 22:29:52 -04:00
spoopyghosty0 9a48eac3e6 Catalog: TRIANGLE STRATEGY works (shared working config)
Closes #171
Closes #85
2026-10-10 22:18:23 -04:00
spoopyghosty0 9082c684ed test_vr_overlay: expect systemd quotes only where the path needs them (CI's checkout path has no spaces; the v1.0.0 build failed on it) 2026-10-10 22:17:22 -04:00
spoopyghosty0 84235fbc9f FramePort 1.0.0 2026-10-10 22:04:58 -04:00
spoopyghosty0 8accd6221a Install links: the homepage's example button is a demo link (frameport.app/demo/cool-game.json, published on the site). FramePort recognises every form of it without any network request and answers with a demo install question for "Cool Game"; Install tosses its cover out of the logo's portal (easter eggs off: "Nice, it works!"), nothing is downloaded or added. The landing page marks it as a demo link; open-link says it's the demo and stops 2026-10-10 21:47:57 -04:00
spoopyghosty0 c37eebb59f Site: link the rift-on-frame branch from How it works 2026-10-10 21:47:04 -04:00
spoopyghosty0 5b0bd2589b PC Steam shortcuts: Revive games (all started by ReviveInjector.exe) are told apart by the game in the launch options; a second Revive game overwrote the first one's shortcut 2026-10-10 21:44:47 -04:00
spoopyghosty0 231e5e50cd Merge branch 'worktree-agent-ad6131f295772bee1' 2026-10-10 21:39:29 -04:00
spoopyghosty0 423b2e1c3e Rename… for every library entry: the name is kept from then on and reaches Steam without a new shortcut (agent v77 rename)
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.
2026-10-10 21:38:56 -04:00
spoopyghosty0 b6c5f7c18e Scanning: SideQuest backup folders and AXRB downloads import with their OBBs. AXRB's own PC-patched copies (patched/, *-axrb.apk, no OBBs) are skipped and a rescan replaces games added from them (they started and stopped at once: Steam showed Resume); AXRB's other store files go with the OBBs; an APK inside another game's data folder isn't a game; backups are named after the package, not the backup time. FAQ explains both layouts 2026-10-10 21:29:22 -04:00
spoopyghosty0 74320e607f Site: How it works explains PC VR games on the Frame and on the PC (automatic Revive for Rift games), plus two experimental paths (Quest games in PC VR, Rift games on the Frame alone), closed by default. Install video: download from frameport.app 2026-10-10 21:13:29 -04:00
spoopyghosty0 d87e3b3b5f Merge branch 'worktree-agent-ad5dca676e297ca5d' 2026-10-10 20:59:46 -04:00
spoopyghosty0 c4153be8bc Docs: FramePort's overlay autostart service (agent v76), dev Frame result 2026-10-10 20:58:57 -04:00
spoopyghosty0 b5a16a2680 Overlay apps start with SteamVR through FramePort's own service (agent v76)
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).
2026-10-10 20:53:32 -04:00
spoopyghosty0 f6870da310 Merge branch 'worktree-agent-a541d9c7207d19375' 2026-10-10 20:52:50 -04:00
spoopyghosty0 6c8cda5d7a Godot apps: start without a clipboard service (frame.godot_clipboard, GitHub #134)
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.
2026-10-10 20:52:23 -04:00
spoopyghosty0 7331d9320a Catalog: VR4 zeroes a second permutation of the campaign shader that reads its loop counter before setting it (6604-byte module, found in the reporter's shader capture; GitHub #140) 2026-10-10 20:40:44 -04:00
242 changed files with 11447 additions and 840 deletions

No files matched your search

+1 -1
View File
@@ -38,6 +38,6 @@ body:
id: logs
attributes:
label: Diagnostics
description: Drag the FramePort-diag-*.zip here (max 25 MB)
description: "Drag the FramePort-diag-*.zip here (max 25 MB). If the box won't take it, submit the report and attach the zip in a comment."
validations:
required: true
+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Questions and ideas
url: https://github.com/spoopyghosty0/frameport/discussions
about: Setup help, how-to questions and feature ideas go to Discussions.
- name: Docs and FAQ
url: https://frameport.app/docs/
about: Install steps, game folder layouts and common questions.
+2 -1
View File
@@ -40,7 +40,8 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add "catalog/games/$PKG.yaml"
uv run python scripts/patch_docs.py # docs/PATCHES.md lists the catalog games per patch
git add "catalog/games/$PKG.yaml" docs/PATCHES.md site/src/data/patches.json
git commit -m "catalog: $PKG (from #$ISSUE)"
git push --force origin "$branch"
if ! gh pr view "$branch" >/dev/null 2>&1; then
@@ -0,0 +1,51 @@
---
target: site
total_score: 28
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 3
target_identity: "file:/mnt/d/Backup Data/Misc/emus/VR_META_QUEST/frameport/.claude/worktrees/impeccable-init/site/src/pages/index.astro"
target_fingerprint: "sha256:d6703ab52ed142060a06d3e159634338b66605ed54aa67cd8a942cfcc3918f65"
target_path: /mnt/d/Backup Data/Misc/emus/VR_META_QUEST/frameport/.claude/worktrees/impeccable-init/site/src/pages/index.astro
timestamp: 2026-10-12T14-19-41Z
slug: site-src-pages-index-astro
closed: true
---
# Critique: frameport.app (site/src: home, download, install, setup, docs, changelog)
Method: dual-agent (A: design review · B: detector + overlay)
## Design Health Score: 28/40 (Good)
| # | Heuristic | Score | Key Issue |
|---|---|---|---|
| 1 | Visibility of System Status | 3 | TransitLog rows dimmed to 1.8:1 until reached; read as disabled on a quick scroll |
| 2 | Match System / Real World | 3 | Home leads with VrApi, OVRPort patch ids, AXRB, a launch-test release card |
| 3 | User Control and Freedom | 3 | /download/ starts a download on arrival |
| 4 | Consistency and Standards | 2 | Nav "Install" -> /#install, /install/ is the link handler; "Install" vs "Setup"; three step-number styles |
| 5 | Error Prevention | 3 | Phone detection, checksums, 4-digit Allow |
| 6 | Recognition Rather Than Recall | 3 | No nav at all under 760px |
| 7 | Flexibility and Efficiency | 3 | Docs search "/", page keys, ?os= links |
| 8 | Aesthetic and Minimalist Design | 2 | 10.8k px home (13.7k mobile), app shown 3x in section 02, effect stack |
| 9 | Error Recovery | 3 | Install-link error is plain with a next step |
| 10 | Help and Documentation | 3 | Good docs; hub is a uniform card grid, "93 min read" |
## Design Specificity Verdict
Mixed. Content and hero are authored (real package -> game crossing the portal, TransitLog with real recipe chips, live CompatBoard incl. "Doesn't run", /setup Konsole preview). The ambient effect layer (interactions.ts: cursorLight, clickRipples, word-rise headings + pointer-lit gradient letters, scrambling labels, countUp, global .reveal fade-ups, heroScroll) and the section scaffolding (numbered "01 ·" mono eyebrows on 8 sections, statement + dim kicker headlines, Compare bento with icon tiles, uniform docs card hub, avatar wall, glows + warp field on /download/) are the template tells. Detector agrees: kicker-above-heading x9, gradient-text x2, icon-tile-stack, radial-spotlight-glow, dark-glow, bounce-easing, marquee; plus real a11y: --text-3 #717583 below 4.5:1 for small text (~18 hits), docs search placeholders 3.6-3.9:1, 10.5px text in Compare/.pip, heading-rhythm on 4 section heads, 114-char notice line. False positives: AppMock Roboto/nested cards/grid bg (imitates the app), Carousel lightbox img, purple palette (per-game hues), Crossing svg occlusion.
## Priority Issues
- [P1] Ambient effect stack reads as generated. Fix: keep motion only where something crosses (hero, TransitLog, setup Konsole, walkthrough); delete cursorLight, clickRipples, heading word-split + .lit, label scramble; limit .reveal. Command: /impeccable quieter
- [P1] No navigation on phones (Nav.astro:45-46 nav display:none under 760px, no menu). Fix: compact menu; phone CTA = send/copy link to PC. Command: /impeccable adapt
- [P1] Home too long and repetitive. Fix: keep the mock, fold carousel+videos, move "For game makers" to docs, release list to /changelog, shorten Compare. Command: /impeccable distill + /impeccable layout
- [P2] Walkthrough Downloads mock shows "?os=linux" + always ZIP/FramePort.exe (InstallWalkthrough.astro:272). Fix: map OS -> real asset names. Command: /impeccable polish
- [P2] No self-signed/SmartScreen reassurance at the download; "Install" overloaded; --text-3 contrast. Fix: one plain line per OS next to the SHA; rename nav item; raise --text-3 for small text. Command: /impeccable clarify + /impeccable onboard
## Persona Red Flags
Jordan: pipeline jargon right below "one click"; Install vs Setup; truncated walkthrough tabs. Riley: /install/ direct = error page; ?os=linux; 93 min read. Casey: no nav; three.js hero + warp canvas on a phone; 13.7k px. Reddit Frame owner: no "send to my PC" from home; curl|bash and Developer Mode first appear in section 05 without /setup's reassurance.
## Minor Observations
Hero meta line wraps; Compare tinted gradient cards break flat panels; green glow in Compare outside the palette; changelog version pills scroll without affordance; blockquote side border; --ease-back overshoot; marquee reduced-motion unverified.
## Questions to Consider
- If the crossing is the brand's one motion, why does every heading animate too?
- Who is the home page for after the hero: owner or tinkerer? Could the pipeline be "How it works ->"?
- Would sections named by what the visitor does beat numbered eyebrows?
+578
View File
@@ -0,0 +1,578 @@
{
"schemaVersion": 2,
"generatedAt": "2026-10-12T15:37:55+00:00",
"title": "Design System: FramePort",
"extensions": {
"colorMeta": {
"frame-orange": {
"role": "primary",
"displayName": "Frame Orange",
"canonical": "#FF8A1F",
"oklch": "oklch(74.8% 0.176 55.4)",
"tonalRamp": [
"#170600",
"#3B1A00",
"#633002",
"#8E4801",
"#C16300",
"#F58108",
"#FEB98A",
"#FEEADD"
]
},
"frame-orange-soft": {
"role": "primary",
"displayName": "Frame Orange Soft",
"canonical": "#3A2512",
"oklch": "oklch(28.6% 0.044 61.7)",
"tonalRamp": [
"#150700",
"#331F0C",
"#513A27",
"#705844",
"#947B65",
"#BA9F89",
"#E0C5AE",
"#FEEBDB"
]
},
"ember-ink": {
"role": "primary",
"displayName": "Ember Ink",
"canonical": "#160C03",
"oklch": "oklch(16.5% 0.027 67.8)",
"tonalRamp": [
"#130902",
"#2D2216",
"#493D31",
"#685B4E",
"#8B7E70",
"#B0A293",
"#D7C8B8",
"#FBECDC"
]
},
"pc-blue": {
"role": "secondary",
"displayName": "Pc Blue",
"canonical": "#3AA8FF",
"oklch": "oklch(71.0% 0.160 246.9)",
"tonalRamp": [
"#000C1B",
"#002643",
"#01426F",
"#04619F",
"#0485D7",
"#46ABFE",
"#9FD0FE",
"#E2F1FE"
]
},
"pc-blue-soft": {
"role": "secondary",
"displayName": "Pc Blue Soft",
"canonical": "#0F2A40",
"oklch": "oklch(27.6% 0.052 245.9)",
"tonalRamp": [
"#000C1A",
"#0B263C",
"#27425A",
"#45617A",
"#66849E",
"#8AA9C4",
"#AFCFEB",
"#E2F1FE"
]
},
"void": {
"role": "neutral",
"displayName": "Void",
"canonical": "#0D0E12",
"oklch": "oklch(16.5% 0.009 274.3)",
"tonalRamp": [
"#0A0B0F",
"#232428",
"#3E3F44",
"#5C5D63",
"#7E8085",
"#A3A4AA",
"#C9CAD0",
"#ECEEF4"
]
},
"sidebar-night": {
"role": "neutral",
"displayName": "Sidebar Night",
"canonical": "#111217",
"oklch": "oklch(18.3% 0.010 276.4)",
"tonalRamp": [
"#0A0B10",
"#232429",
"#3E3F45",
"#5C5D64",
"#7E8087",
"#A3A4AB",
"#C8CAD1",
"#ECEEF6"
]
},
"panel": {
"role": "neutral",
"displayName": "Panel",
"canonical": "#16181E",
"oklch": "oklch(21.0% 0.012 270.8)",
"tonalRamp": [
"#090B10",
"#22242A",
"#3D4046",
"#5B5D65",
"#7D8088",
"#A1A4AD",
"#C7CAD3",
"#EBEEF7"
]
},
"panel-raised": {
"role": "neutral",
"displayName": "Panel Raised",
"canonical": "#1D1F27",
"oklch": "oklch(24.1% 0.016 274.1)",
"tonalRamp": [
"#090B12",
"#22242C",
"#3D3F48",
"#5B5D67",
"#7D808A",
"#A1A4AF",
"#C7CAD5",
"#EBEEF9"
]
},
"panel-well": {
"role": "neutral",
"displayName": "Panel Well",
"canonical": "#252832",
"oklch": "oklch(27.8% 0.019 272.0)",
"tonalRamp": [
"#080B13",
"#21242D",
"#3C3F4A",
"#5A5D69",
"#7C808C",
"#A0A4B1",
"#C6CAD7",
"#EAEEFC"
]
},
"hairline": {
"role": "neutral",
"displayName": "Hairline",
"canonical": "#2A2D37",
"oklch": "oklch(29.8% 0.019 272.1)",
"tonalRamp": [
"#080B13",
"#21242D",
"#3C3F4A",
"#5A5D68",
"#7C808C",
"#A0A4B1",
"#C6CAD7",
"#EAEEFB"
]
},
"hairline-strong": {
"role": "neutral",
"displayName": "Hairline Strong",
"canonical": "#393D49",
"oklch": "oklch(36.1% 0.021 270.8)",
"tonalRamp": [
"#080B14",
"#20242F",
"#3B3F4B",
"#595D6A",
"#7B808D",
"#9FA4B3",
"#C5CAD9",
"#E9EEFD"
]
},
"text-bright": {
"role": "neutral",
"displayName": "Text Bright",
"canonical": "#EDEEF2",
"oklch": "oklch(94.9% 0.005 275.0)",
"tonalRamp": [
"#0A0B0D",
"#232427",
"#3F4043",
"#5D5D61",
"#7F8083",
"#A3A4A8",
"#C9CACE",
"#EDEEF2"
]
},
"text-dim": {
"role": "neutral",
"displayName": "Text Dim",
"canonical": "#A7ABB7",
"oklch": "oklch(74.2% 0.018 271.2)",
"tonalRamp": [
"#080B13",
"#21242D",
"#3C3F49",
"#5A5D68",
"#7C808B",
"#A0A4B0",
"#C6CAD7",
"#EAEEFB"
]
},
"text-faint": {
"role": "neutral",
"displayName": "Text Faint",
"canonical": "#717583",
"oklch": "oklch(56.4% 0.022 273.0)",
"tonalRamp": [
"#080A14",
"#20232F",
"#3C3F4C",
"#595D6A",
"#7C808E",
"#A0A4B3",
"#C5CAD9",
"#E9EEFE"
],
"siteValue": "#868a96"
},
"ok-green": {
"role": "status",
"displayName": "Ok Green",
"canonical": "#3DD68C",
"oklch": "oklch(77.9% 0.165 157.3)",
"tonalRamp": [
"#000F06",
"#002D18",
"#004D2D",
"#017043",
"#03995D",
"#1AC27A",
"#57EA9F",
"#C4FFDA"
]
},
"warn-amber": {
"role": "status",
"displayName": "Warn Amber",
"canonical": "#F2C94C",
"oklch": "oklch(85.0% 0.146 90.5)",
"tonalRamp": [
"#100A00",
"#2E2301",
"#4F3D02",
"#725B05",
"#9C7C01",
"#C79F05",
"#EFC648",
"#FFEEBE"
]
},
"error-coral": {
"role": "status",
"displayName": "Error Coral",
"canonical": "#FF6166",
"oklch": "oklch(69.8% 0.193 21.8)",
"tonalRamp": [
"#1F0002",
"#4B000A",
"#7C0118",
"#B10227",
"#DB3F49",
"#FE7173",
"#FEB5B1",
"#FFE8E7"
]
},
"info-cyan": {
"role": "status",
"displayName": "Info Cyan",
"canonical": "#5CD0E6",
"oklch": "oklch(79.9% 0.108 212.0)",
"tonalRamp": [
"#000E12",
"#012930",
"#014853",
"#066978",
"#0290A4",
"#3EB6CC",
"#6ADDF4",
"#D1F6FE"
]
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Site section headings and doc titles (Sora 800)."
},
"headline": {
"displayName": "Headline",
"purpose": "App page titles and the game hero title."
},
"title": {
"displayName": "Title",
"purpose": "App section and card headings."
},
"body": {
"displayName": "Body",
"purpose": "Running text (13px app, 16px site)."
},
"label": {
"displayName": "Label",
"purpose": "Pills, meta lines, counts."
},
"section-label": {
"displayName": "Section label",
"purpose": "Mono uppercase labels followed by the portal hairline."
}
},
"shadows": [
{
"name": "art-rest",
"value": "0 8px 22px 1px rgba(0,0,0,.55)",
"purpose": "Cover and shelf cards, screenshots."
},
{
"name": "art-hover",
"value": "0 16px 36px 2px rgba(0,0,0,.75), 0 4px 30px -2px rgba(255,138,31,.22)",
"purpose": "A pointed-at cover: deeper, with the orange glow."
},
{
"name": "float",
"value": "0 18px 40px rgba(0,0,0,.55)",
"purpose": "Menus, popovers, term cards, toasts."
},
{
"name": "portal-glow",
"value": "radial-gradient(85% 85% at 50% 37%, rgba(58,168,255,.10) 0%, rgba(255,138,31,.05) 45%, rgba(13,14,18,0) 100%)",
"purpose": "Soft light behind empty states and the site hero."
}
],
"gradients": [
{
"name": "portal",
"value": "linear-gradient(90deg, #3AA8FF, #FF8A1F)",
"purpose": "The seam: sidebar edge, selection fades (22 %), section hairlines, progress, link underlines."
}
],
"motion": [
{
"name": "ease-out",
"value": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"purpose": "Default for state changes, reveals and the cover lift."
},
{
"name": "ease-settle",
"value": "cubic-bezier(0.25, 1, 0.5, 1)",
"purpose": "Presses, pops and arrivals: they land without overshoot."
},
{
"name": "view-fade",
"value": "150ms",
"purpose": "App route change (AnimatedSwitcher)."
},
{
"name": "quiet-reveal",
"value": "120ms opacity",
"purpose": "Row actions that appear on hover/focus."
},
{
"name": "cover-lift",
"value": "220ms ease-out; art zoom 600ms",
"purpose": "Cover card hover."
},
{
"name": "reduce-motion",
"value": "all of the above off",
"purpose": "ui.reduce_motion / prefers-reduced-motion."
}
],
"breakpoints": [
{
"name": "site-narrow",
"value": "760px",
"purpose": "Docs diagrams and file trees switch to scroll / stacked layout."
},
{
"name": "site-page",
"value": "1180px",
"purpose": "Site content column max width."
}
],
"themes": [
{
"name": "portal",
"dual": true,
"note": "Default."
},
{
"name": "portal_oled",
"dual": true,
"note": "True black BG/sidebar; panels lift off it."
},
{
"name": "original",
"dual": false,
"note": "Violet accent #8B7CFF; no gradient touches."
}
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "The one main action of a view (Install on Frame, Play on Frame).",
"html": "<button class=\"ds-btn-primary\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>Play on Frame</button>",
"css": ".ds-btn-primary{display:inline-flex;align-items:center;gap:8px;padding:12px 16px;border:0;border-radius:8px;background:var(--accent,#FF8A1F);color:var(--on-accent,#160C03);font:600 13px/18px 'Segoe UI',Roboto,sans-serif;cursor:pointer;transition:filter 220ms cubic-bezier(.2,.8,.2,1),transform 320ms cubic-bezier(.25,1,.5,1)}.ds-btn-primary:hover{filter:brightness(1.08)}.ds-btn-primary:active{transform:scale(.97)}.ds-btn-primary:focus-visible{outline:2px solid var(--accent,#FF8A1F);outline-offset:3px}"
},
{
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
"description": "Secondary actions: the blue portal outline next to the orange primary.",
"html": "<button class=\"ds-btn-secondary\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M3 21v-5h5\"/></svg>Reinstall on Frame</button>",
"css": ".ds-btn-secondary{display:inline-flex;align-items:center;gap:8px;padding:12px 14px;border:1px solid rgba(58,168,255,.55);border-radius:8px;background:transparent;color:var(--text,#EDEEF2);font:600 13px/18px 'Segoe UI',Roboto,sans-serif;cursor:pointer;transition:background-color 220ms cubic-bezier(.2,.8,.2,1),transform 320ms cubic-bezier(.25,1,.5,1)}.ds-btn-secondary svg{color:var(--secondary,#3AA8FF)}.ds-btn-secondary:hover{background:rgba(58,168,255,.08)}.ds-btn-secondary:active{transform:scale(.97)}.ds-btn-secondary:focus-visible{outline:2px solid var(--accent,#FF8A1F);outline-offset:3px}"
},
{
"name": "Ghost Button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Quiet actions: Close, Cancel, inline links.",
"html": "<button class=\"ds-btn-ghost\">Cancel</button>",
"css": ".ds-btn-ghost{padding:10px 12px;border:0;border-radius:8px;background:transparent;color:var(--text-2,#A7ABB7);font:600 13px/18px 'Segoe UI',Roboto,sans-serif;cursor:pointer;transition:color 200ms,background-color 200ms}.ds-btn-ghost:hover{color:var(--text,#EDEEF2);background:rgba(237,238,242,.06)}.ds-btn-ghost:focus-visible{outline:2px solid var(--accent,#FF8A1F);outline-offset:3px}"
},
{
"name": "Danger Button",
"kind": "button",
"refersTo": "button-danger",
"description": "The final destructive button of a confirm dialog (Uninstall).",
"html": "<button class=\"ds-btn-danger\">Uninstall</button>",
"css": ".ds-btn-danger{padding:12px 16px;border:0;border-radius:8px;background:var(--error,#FF6166);color:var(--on-accent,#160C03);font:600 13px/18px 'Segoe UI',Roboto,sans-serif;cursor:pointer;transition:filter 220ms,transform 320ms cubic-bezier(.25,1,.5,1)}.ds-btn-danger:hover{filter:brightness(1.08)}.ds-btn-danger:active{transform:scale(.97)}.ds-btn-danger:focus-visible{outline:2px solid var(--error,#FF6166);outline-offset:3px}"
},
{
"name": "Search Field",
"kind": "input",
"refersTo": "input",
"description": "Filled well, no border until focus, then orange.",
"html": "<label class=\"ds-search\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-4-4\"/></svg><input placeholder=\"Type to search games and tags\"></label>",
"css": ".ds-search{display:flex;align-items:center;gap:10px;width:260px;padding:8px 12px;border:1px solid transparent;border-radius:8px;background:var(--surface-3,#252832);color:var(--text-3,#717583);transition:border-color 160ms}.ds-search:focus-within{border-color:var(--accent,#FF8A1F)}.ds-search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--text,#EDEEF2);font:13px 'Segoe UI',Roboto,sans-serif;caret-color:var(--accent,#FF8A1F)}.ds-search input::placeholder{color:var(--text-2,#A7ABB7)}"
},
{
"name": "Status Pills",
"kind": "chip",
"refersTo": "pill",
"description": "Color plus a word: status, install state, platform.",
"html": "<span class=\"ds-pill ds-ok\">Works</span> <span class=\"ds-pill ds-warn\">Works with issues</span> <span class=\"ds-pill ds-solid\">On Frame</span> <span class=\"ds-pill ds-pc\">PC VR</span>",
"css": ".ds-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 9px 3px 8px;border-radius:20px;font:600 11px/16px 'Segoe UI',Roboto,sans-serif;white-space:nowrap}.ds-pill::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor}.ds-ok{color:#3DD68C;background:rgba(61,214,140,.12)}.ds-warn{color:#F2C94C;background:rgba(242,201,76,.12)}.ds-pc{color:#3AA8FF;background:rgba(58,168,255,.12)}.ds-solid{color:#160C03;background:#3DD68C}"
},
{
"name": "Card",
"kind": "card",
"refersTo": "card",
"description": "Flat panel with a hairline: no shadow.",
"html": "<div class=\"ds-card\"><div class=\"ds-card-h\">Where it's installed</div><div class=\"ds-card-b\">Steam Frame · Installed</div><div class=\"ds-card-m\">Last launch test: passed</div></div>",
"css": ".ds-card{width:320px;padding:16px;border:1px solid var(--border,#2A2D37);border-radius:12px;background:var(--surface,#16181E);font-family:'Segoe UI',Roboto,sans-serif;transition:background-color 160ms}.ds-card:hover{background:var(--surface-2,#1D1F27)}.ds-card-h{font:600 16px/1.3 inherit;color:var(--text,#EDEEF2);margin-bottom:8px}.ds-card-b{font-size:13px;color:#3DD68C}.ds-card-m{font-size:12px;color:var(--text-2,#A7ABB7);margin-top:4px}"
},
{
"name": "Sidebar Navigation",
"kind": "nav",
"refersTo": "nav-item-selected",
"description": "Selected row = the blue→orange portal fade; the sidebar ends in the portal seam.",
"html": "<nav class=\"ds-nav\"><a class=\"on\" href=\"#\">Library</a><a href=\"#\">Steam Frame <i></i></a><a href=\"#\">Files</a><a href=\"#\">Monitor</a></nav>",
"css": ".ds-nav{display:grid;gap:2px;width:220px;padding:12px;background:var(--sidebar,#111217);border-right:1px solid;border-image:linear-gradient(180deg,#3AA8FF,#FF8A1F) 1;font:500 14px/1 'Segoe UI',Roboto,sans-serif}.ds-nav a{display:flex;align-items:center;justify-content:space-between;padding:12px;border-radius:8px;color:var(--text-2,#A7ABB7);text-decoration:none;transition:background-color 160ms,color 160ms}.ds-nav a:hover{background:rgba(237,238,242,.05);color:var(--text,#EDEEF2)}.ds-nav a.on{background:linear-gradient(90deg,rgba(58,168,255,.22),rgba(255,138,31,.22));color:var(--text,#EDEEF2);font-weight:600}.ds-nav a:focus-visible{outline:2px solid var(--accent,#FF8A1F);outline-offset:2px}.ds-nav i{width:8px;height:8px;border-radius:50%;background:#3DD68C}"
},
{
"name": "Callout",
"kind": "custom",
"refersTo": "callout",
"description": "Guidance in a status color: 8 % fill, 35 % border.",
"html": "<div class=\"ds-callout\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#5CD0E6\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v5M12 8h.01\"/></svg><span>Large data set (~27 GiB) incl. all language packs.</span></div>",
"css": ".ds-callout{display:flex;gap:12px;align-items:flex-start;max-width:520px;padding:12px 14px;border:1px solid rgba(92,208,230,.35);border-radius:8px;background:rgba(92,208,230,.08);color:var(--text,#EDEEF2);font:13px/1.5 'Segoe UI',Roboto,sans-serif}.ds-callout svg{flex:none;margin-top:1px}"
},
{
"name": "Cover Card",
"kind": "custom",
"refersTo": "card",
"description": "Signature: art-led card that lifts, zooms its art and glows orange underneath on hover.",
"html": "<div class=\"ds-cover\" tabindex=\"0\"><div class=\"ds-art\"></div><span class=\"ds-tag\">Quest</span><div class=\"ds-cap\"><b>4XVR Video Player</b><span>Works</span></div></div>",
"css": ".ds-cover{position:relative;width:180px;height:290px;border-radius:12px;overflow:hidden;border:1px solid var(--border,#2A2D37);box-shadow:0 8px 22px 1px rgba(0,0,0,.55);transition:transform 220ms cubic-bezier(.2,.8,.2,1),box-shadow 220ms;font-family:'Segoe UI',Roboto,sans-serif}.ds-art{position:absolute;inset:0;background:radial-gradient(60% 40% at 40% 45%,#3AA8FF 0 40%,transparent 41%),radial-gradient(55% 38% at 62% 47%,#FF8A1F 0 40%,transparent 41%),linear-gradient(160deg,#1a2c5a,#0d0e12);transition:transform 600ms cubic-bezier(.2,.8,.2,1)}.ds-cover:hover,.ds-cover:focus-visible{transform:translateY(-4px) scale(1.02);box-shadow:0 16px 36px 2px rgba(0,0,0,.75),0 4px 30px -2px rgba(255,138,31,.22);outline:none}.ds-cover:hover .ds-art{transform:scale(1.07)}.ds-tag{position:absolute;left:10px;top:10px;padding:3px 9px;border-radius:20px;background:rgba(0,0,0,.62);color:#EDEEF2;font:600 11px/16px inherit}.ds-cap{position:absolute;inset:auto 0 0 0;padding:40px 12px 12px;background:linear-gradient(transparent,rgba(13,14,18,.92));display:grid;gap:4px}.ds-cap b{color:#EDEEF2;font-size:15px}.ds-cap span{color:#A7ABB7;font-size:12px}@media (prefers-reduced-motion:reduce){.ds-cover,.ds-art{transition:box-shadow 220ms}.ds-cover:hover{transform:none}.ds-cover:hover .ds-art{transform:none}}"
}
],
"narrative": {
"northStar": "The Portal Between",
"overview": "FramePort is about crossing. On one side is the PC, in blue; on the other is the Frame, in orange. Between them is the\nseam where a game passes through: a horizontal gradient from blue to orange, the same one the logo's tilted portal\nmakes. Everything else is dark space, so the crossing is what glows. The desktop app and frameport.app share this\nsystem with the same color values. The site states it outright (\"FramePort Portal, for the web\").\n\nSurfaces are flat, dark, tonal steps a few lightness points apart, with hairline borders, and the chrome stays quiet.\nColor comes from three places only: cover art, the orange that marks the one thing to do now, and the blue portal\ntouches that show where you are or what's moving. Density is comfortable desktop density, built for a mouse and a\nwindow you resize. The interface scales as a whole through one scale factor, never by reflowing into a phone layout.\n\nComponents are tactile and confident. A cover lifts and its art zooms when pointed at. A button settles when pressed.\nOn the website, motion is kept for things that cross: the hero's package-to-game crossing, the install log turning\nfrom PC rows into Frame rows, the download going through the portal. Nothing moves just because the pointer or the\nscroll passed by (no cursor light, no word-by-word headings, no scroll fade-ups).\nPlay sends two rings out of the button, blue then orange. A new view fades in. Every one of these motions disappears\nunder Reduce motion, and the static state must still read on its own.",
"keyCharacteristics": [
"Dark only: a near-black void (`void`) with three tonal panel steps above it.",
"Orange is the action, blue is the place: the portal dichotomy is the color doctrine.",
"The blue→orange gradient is the system's only decoration.",
"Artwork carries the color; the chrome stays neutral.",
"Flat at rest. Artwork and floating layers lift.",
"Motion is physical (lift, spring, ripple) and always optional."
],
"rules": [
{
"name": "The Portal Rule",
"body": "Orange is the Frame and the action; blue is the PC and the secondary. Don't swap them, and don't use a third hue for an action.",
"section": "colors"
},
{
"name": "The One Orange Rule",
"body": "A view has one filled orange button: the thing to do now. Other actions are blue outlines or ghosts.",
"section": "colors"
},
{
"name": "The Seam Rule",
"body": "The blue→orange gradient (`--portal`, `C.portal_gradient`) is the only decoration. It always runs left to right (or top to bottom), blue first. It appears on the sidebar edge, the selected nav item and segment, the wordmark, section-label hairlines, link underlines on the site and progress fills. Dual themes only.",
"section": "colors"
},
{
"name": "The Dark-Only Rule",
"body": "No light mode. Installed theme files whose background is too light, or whose text falls below 4.5:1 on a panel, are refused.",
"section": "colors"
},
{
"name": "The Scale-Token Rule",
"body": "In the app every size goes through `T.T_*` tokens or `T.px()`, so the user's UI scale (75–300 %) applies everywhere. A bare pixel number is a bug.",
"section": "typography"
},
{
"name": "The Sentence-Case Rule",
"body": "Headings and buttons use sentence case. Only the mono section labels and pill-sized overlays are uppercase.",
"section": "typography"
},
{
"name": "The Lift-Is-Earned Rule",
"body": "A panel never gets a shadow just to look important. If it isn't artwork and it doesn't float over other content, it's flat with a hairline.",
"section": "elevation"
}
],
"dos": [
"Do read colors as theme tokens when building (`T.ACCENT`, `var(--accent)`), so all three built-in themes and user theme files work.",
"Do keep exactly one filled orange action per view; make everything else blue outline or ghost.",
"Do put the blue→orange gradient only where something crosses or is selected: seams, selection fades, progress, link underlines.",
"Do let cover art carry the color and keep surrounding chrome neutral.",
"Do pair every status color with a word or glyph (pills, callouts, checklists).",
"Do give motion a static fallback: under Reduce motion (`ui.reduce_motion`, `prefers-reduced-motion`) the state must still read without the animation.",
"Do scale every app size through `T.T_*` / `T.px()` and give icon-only controls a tooltip."
],
"donts": [
"Don't build a light theme or a light surface; FramePort is dark only.",
"Don't swap the poles: no orange secondary buttons, no blue primary actions.",
"Don't add shadows to ordinary panels or cards; only artwork and floating layers lift.",
"Don't use Warn Amber or another orange-adjacent hue for a warning that could be mistaken for the accent.",
"Don't use the tilted portal silhouette as general ornament; it belongs to the logo, the wordmark and empty states.",
"Don't put image bytes into controls or recreate clickable controls on progress ticks; update them in place."
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+1
View File
@@ -0,0 +1 @@
Baseline: the site as it was before the redesign steps (critique 28/40).
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+1
View File
@@ -0,0 +1 @@
Quieter: removed the cursor light, click ripples, word-by-word headings and gradient letters, label scramble, count-ups, scroll fade-ups, the hero's scroll fly-away, film grain, icon hover tricks, the download warp field and breathing glow, Compare's tilt/pointer light/tinted cards, the contributor dock magnification; springs no longer overshoot.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+1
View File
@@ -0,0 +1 @@
Clarify: section labels name what you do there (See how it works, Try the app, See what runs, Compare the tools, Get started, What's new, Credits) instead of 01-07; plain headlines (What happens when you click Install on Frame; FramePort patches the Quest games that don't run as they are); the hero's 'For the Steam Frame' eyebrow removed; nav Install -> Get started, Compared -> Compare; /download/ explains why Windows and macOS warn (self-signed) and where the SHA-256 sums are.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

@@ -0,0 +1 @@
Distill + layout: the app tour's 9-slide carousel and videos fold into one 'Screenshots and videos' disclosure; the game-maker block is one row (demo badge, one sentence, docs link; the HTML snippet lives in the docs); What's new is one line; Compare loses its icon tiles; calmer section padding, labels sit closer to their headings, the notice sits between sections at 72ch, bigger hit areas on the hero's platform links.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
Adapt: below 1120px the eight nav links move into a Menu panel (48px rows, Esc/outside click closes, a link closes it) instead of wrapping (1024) or vanishing (phones); on phones the download buttons say 'Get it on your PC' and lead to the download page's open-on-your-PC state; no three.js on data saver or 2G/3G (the CSS scene shows); the install log drops its technical detail column under 600px. Phone shots now use a phone user agent.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
Onboard: Get started step 3 (the setup line) now says what running it does: it finds FramePort on your PC and waits, nothing changes on the Frame until you click Allow, then Developer Mode turns on; no password, no root; with a link to read the script. New shot: desktop-walkthrough-step3.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
Polish: the walkthrough's Downloads scene shows each platform's real file and program (FramePort-linux-x64.tar.gz / TAR.GZ / FramePort; .app on macOS) instead of '?os=linux' and FramePort.exe; the site's dim text is #868a96 (4.77:1+, was 3.6-3.9:1) and placeholders use the secondary text colour; install-log stages not reached yet stay readable (only their icon fades); 10.5px labels are 11px; the docs blockquote loses its side bar; the changelog version is solid orange instead of gradient text; the notice reads at 64ch.
+128
View File
@@ -0,0 +1,128 @@
"""Specimen screenshots of frameport.app for the redesign steps, and the gallery that compares them.
python capture.py <NN-name> "<what changed>" [--url http://127.0.0.1:4391]
Writes <NN-name>/*.webp + note.txt next to this file and rebuilds index.html (every step, side by side per shot).
Math.random is seeded, so the hero picks the same game every run; the page is scrolled through first so
scroll-triggered reveals have played; Astro's dev toolbar is removed."""
import argparse
import html
import io
import time
from pathlib import Path
from PIL import Image
from playwright.sync_api import sync_playwright
HERE = Path(__file__).resolve().parent
SEED = """(() => { let a = 42; Math.random = () => { a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296; }; })();"""
# (name, path, viewport, full page?, scale of the saved image)
SHOTS = [
("desktop-home-fold", "/", (1440, 900), False, 1.0),
("desktop-home-full", "/", (1440, 900), True, 0.5),
("desktop-download", "/download/?os=windows", (1440, 900), True, 0.6),
("desktop-setup", "/setup/", (1440, 900), True, 0.6),
("desktop-docs", "/docs/", (1440, 900), True, 0.6),
("mobile-home-fold", "/", (390, 844), False, 1.0),
("mobile-home-full", "/", (390, 844), True, 0.6),
("mobile-download", "/download/?os=windows", (390, 844), True, 0.8),
("desktop-walkthrough-step3", "/#step3", (1440, 900), False, 1.0), # Get started, step 3 (the setup line)
("mobile-menu", "/#menu", (390, 844), False, 1.0), # the nav opened (from step 04 on: there was none before)
]
PHONE_UA = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0 Mobile Safari/537.36")
def settle(page):
page.evaluate("""async () => {
for (let y = 0; y < document.body.scrollHeight; y += 400) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 250)); }
window.scrollTo(0, 0);
document.querySelectorAll('astro-dev-toolbar').forEach(e => e.remove());
// the settled look of every section (a full-page shot can't wait for each reveal)
document.querySelectorAll('.reveal').forEach(e => e.classList.add('in'));
const s = document.createElement('style');
s.textContent = '.reveal,.reveal *,.words .w{opacity:1!important;transform:none!important;transition:none!important}';
document.head.append(s);
}""")
time.sleep(1.5)
def capture(step: str, note: str, base: str) -> None:
out = HERE / step
out.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch()
for name, path, (w, h), full, scale in SHOTS:
ctx = browser.new_context(viewport={"width": w, "height": h}, device_scale_factor=1,
accept_downloads=False,
is_mobile=w < 500, has_touch=w < 500,
user_agent=PHONE_UA if w < 500 else None)
ctx.add_init_script(SEED)
page = ctx.new_page()
page.goto(base + path.replace("#menu", "").replace("#step3", ""), wait_until="networkidle")
time.sleep(1.0)
if path.endswith("#menu"):
if not page.is_visible(".menu-btn"):
ctx.close()
continue
page.tap(".menu-btn")
if path.endswith("#step3"):
page.click("[data-wt] [data-go='2']")
page.evaluate("document.querySelector('[data-wt]').scrollIntoView({block: 'center'})")
if full:
settle(page)
else:
page.evaluate("document.querySelectorAll('astro-dev-toolbar').forEach(e => e.remove())")
time.sleep(2.5) # the hero's first crossing
img = Image.open(io.BytesIO(page.screenshot(full_page=full)))
if scale != 1.0:
img = img.resize((round(img.width * scale), round(img.height * scale)), Image.LANCZOS)
img.save(out / f"{name}.webp", "WEBP", quality=72, method=6)
ctx.close()
browser.close()
(out / "note.txt").write_text(note.strip() + "\n", encoding="utf-8")
build_index()
def build_index() -> None:
steps = sorted(d for d in HERE.iterdir() if d.is_dir() and (d / "note.txt").exists())
names = [s[0] for s in SHOTS]
rows = []
for name in names:
cells = "".join(
f'<figure><a href="{s.name}/{name}.webp"><img loading="lazy" src="{s.name}/{name}.webp" alt=""></a>'
f"<figcaption>{html.escape(s.name)}</figcaption></figure>"
for s in steps if (s / f"{name}.webp").exists())
rows.append(f'<section><h2>{html.escape(name)}</h2><div class="row">{cells}</div></section>')
notes = "".join(f"<li><b>{html.escape(s.name)}</b> — {html.escape((s / 'note.txt').read_text(encoding='utf-8').strip())}</li>"
for s in steps)
(HERE / "index.html").write_text(f"""<!doctype html><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Site specimens</title>
<style>
:root {{ color-scheme: dark; }}
body {{ margin: 0; padding: 24px 16px; background: #0d0e12; color: #edeef2; font: 15px/1.5 "Segoe UI", Roboto, sans-serif; }}
h1 {{ font-size: 22px; margin: 0 0 8px; }} h2 {{ font-size: 15px; color: #a7abb7; margin: 32px 0 8px; font-weight: 600; }}
ul {{ max-width: 90ch; padding-left: 20px; color: #c9ccd6; }} b {{ color: #ff8a1f; }}
.row {{ display: flex; gap: 16px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }}
figure {{ margin: 0; flex: 0 0 auto; width: min(560px, 85vw); }}
figure img {{ width: 100%; border: 1px solid #2a2d37; border-radius: 8px; display: block; }}
figcaption {{ font-size: 13px; color: #a7abb7; margin-top: 6px; }}
a {{ color: #3aa8ff; }}
</style>
<h1>frameport.app: step-by-step specimens</h1>
<p>Each row is one shot; each column is a step, oldest on the left. Click an image for full size.</p>
<ul>{notes}</ul>
{''.join(rows)}
""", encoding="utf-8")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("step")
ap.add_argument("note")
ap.add_argument("--url", default="http://127.0.0.1:4391")
a = ap.parse_args()
capture(a.step, a.note, a.url)
+18
View File
@@ -0,0 +1,18 @@
<!doctype html><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Site specimens</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px 16px; background: #0d0e12; color: #edeef2; font: 15px/1.5 "Segoe UI", Roboto, sans-serif; }
h1 { font-size: 22px; margin: 0 0 8px; } h2 { font-size: 15px; color: #a7abb7; margin: 32px 0 8px; font-weight: 600; }
ul { max-width: 90ch; padding-left: 20px; color: #c9ccd6; } b { color: #ff8a1f; }
.row { display: flex; gap: 16px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
figure { margin: 0; flex: 0 0 auto; width: min(560px, 85vw); }
figure img { width: 100%; border: 1px solid #2a2d37; border-radius: 8px; display: block; }
figcaption { font-size: 13px; color: #a7abb7; margin-top: 6px; }
a { color: #3aa8ff; }
</style>
<h1>frameport.app: step-by-step specimens</h1>
<p>Each row is one shot; each column is a step, oldest on the left. Click an image for full size.</p>
<ul><li><b>00-before</b> — Baseline: the site as it was before the redesign steps (critique 28/40).</li><li><b>01-quieter</b> — Quieter: removed the cursor light, click ripples, word-by-word headings and gradient letters, label scramble, count-ups, scroll fade-ups, the hero&#x27;s scroll fly-away, film grain, icon hover tricks, the download warp field and breathing glow, Compare&#x27;s tilt/pointer light/tinted cards, the contributor dock magnification; springs no longer overshoot.</li><li><b>02-clarify</b> — Clarify: section labels name what you do there (See how it works, Try the app, See what runs, Compare the tools, Get started, What&#x27;s new, Credits) instead of 01-07; plain headlines (What happens when you click Install on Frame; FramePort patches the Quest games that don&#x27;t run as they are); the hero&#x27;s &#x27;For the Steam Frame&#x27; eyebrow removed; nav Install -&gt; Get started, Compared -&gt; Compare; /download/ explains why Windows and macOS warn (self-signed) and where the SHA-256 sums are.</li><li><b>03-distill-layout</b> — Distill + layout: the app tour&#x27;s 9-slide carousel and videos fold into one &#x27;Screenshots and videos&#x27; disclosure; the game-maker block is one row (demo badge, one sentence, docs link; the HTML snippet lives in the docs); What&#x27;s new is one line; Compare loses its icon tiles; calmer section padding, labels sit closer to their headings, the notice sits between sections at 72ch, bigger hit areas on the hero&#x27;s platform links.</li><li><b>04-adapt</b> — Adapt: below 1120px the eight nav links move into a Menu panel (48px rows, Esc/outside click closes, a link closes it) instead of wrapping (1024) or vanishing (phones); on phones the download buttons say &#x27;Get it on your PC&#x27; and lead to the download page&#x27;s open-on-your-PC state; no three.js on data saver or 2G/3G (the CSS scene shows); the install log drops its technical detail column under 600px. Phone shots now use a phone user agent.</li><li><b>05-onboard</b> — Onboard: Get started step 3 (the setup line) now says what running it does: it finds FramePort on your PC and waits, nothing changes on the Frame until you click Allow, then Developer Mode turns on; no password, no root; with a link to read the script. New shot: desktop-walkthrough-step3.</li><li><b>06-polish</b> — Polish: the walkthrough&#x27;s Downloads scene shows each platform&#x27;s real file and program (FramePort-linux-x64.tar.gz / TAR.GZ / FramePort; .app on macOS) instead of &#x27;?os=linux&#x27; and FramePort.exe; the site&#x27;s dim text is #868a96 (4.77:1+, was 3.6-3.9:1) and placeholders use the secondary text colour; install-log stages not reached yet stay readable (only their icon fades); 10.5px labels are 11px; the docs blockquote loses its side bar; the changelog version is solid orange instead of gradient text; the notice reads at 64ch.</li></ul>
<section><h2>desktop-home-fold</h2><div class="row"><figure><a href="00-before/desktop-home-fold.webp"><img loading="lazy" src="00-before/desktop-home-fold.webp" alt=""></a><figcaption>00-before</figcaption></figure><figure><a href="01-quieter/desktop-home-fold.webp"><img loading="lazy" src="01-quieter/desktop-home-fold.webp" alt=""></a><figcaption>01-quieter</figcaption></figure><figure><a href="02-clarify/desktop-home-fold.webp"><img loading="lazy" src="02-clarify/desktop-home-fold.webp" alt=""></a><figcaption>02-clarify</figcaption></figure><figure><a href="03-distill-layout/desktop-home-fold.webp"><img loading="lazy" src="03-distill-layout/desktop-home-fold.webp" alt=""></a><figcaption>03-distill-layout</figcaption></figure><figure><a href="04-adapt/desktop-home-fold.webp"><img loading="lazy" src="04-adapt/desktop-home-fold.webp" alt=""></a><figcaption>04-adapt</figcaption></figure><figure><a href="05-onboard/desktop-home-fold.webp"><img loading="lazy" src="05-onboard/desktop-home-fold.webp" alt=""></a><figcaption>05-onboard</figcaption></figure><figure><a href="06-polish/desktop-home-fold.webp"><img loading="lazy" src="06-polish/desktop-home-fold.webp" alt=""></a><figcaption>06-polish</figcaption></figure></div></section><section><h2>desktop-home-full</h2><div class="row"><figure><a href="00-before/desktop-home-full.webp"><img loading="lazy" src="00-before/desktop-home-full.webp" alt=""></a><figcaption>00-before</figcaption></figure><figure><a href="01-quieter/desktop-home-full.webp"><img loading="lazy" src="01-quieter/desktop-home-full.webp" alt=""></a><figcaption>01-quieter</figcaption></figure><figure><a href="02-clarify/desktop-home-full.webp"><img loading="lazy" src="02-clarify/desktop-home-full.webp" alt=""></a><figcaption>02-clarify</figcaption></figure><figure><a href="03-distill-layout/desktop-home-full.webp"><img loading="lazy" src="03-distill-layout/desktop-home-full.webp" alt=""></a><figcaption>03-distill-layout</figcaption></figure><figure><a href="04-adapt/desktop-home-full.webp"><img loading="lazy" src="04-adapt/desktop-home-full.webp" alt=""></a><figcaption>04-adapt</figcaption></figure><figure><a href="05-onboard/desktop-home-full.webp"><img loading="lazy" src="05-onboard/desktop-home-full.webp" alt=""></a><figcaption>05-onboard</figcaption></figure><figure><a href="06-polish/desktop-home-full.webp"><img loading="lazy" src="06-polish/desktop-home-full.webp" alt=""></a><figcaption>06-polish</figcaption></figure></div></section><section><h2>desktop-download</h2><div class="row"><figure><a href="00-before/desktop-download.webp"><img loading="lazy" src="00-before/desktop-download.webp" alt=""></a><figcaption>00-before</figcaption></figure><figure><a href="01-quieter/desktop-download.webp"><img loading="lazy" src="01-quieter/desktop-download.webp" alt=""></a><figcaption>01-quieter</figcaption></figure><figure><a href="02-clarify/desktop-download.webp"><img loading="lazy" src="02-clarify/desktop-download.webp" alt=""></a><figcaption>02-clarify</figcaption></figure><figure><a href="03-distill-layout/desktop-download.webp"><img loading="lazy" src="03-distill-layout/desktop-download.webp" alt=""></a><figcaption>03-distill-layout</figcaption></figure><figure><a href="04-adapt/desktop-download.webp"><img loading="lazy" src="04-adapt/desktop-download.webp" alt=""></a><figcaption>04-adapt</figcaption></figure><figure><a href="05-onboard/desktop-download.webp"><img loading="lazy" src="05-onboard/desktop-download.webp" alt=""></a><figcaption>05-onboard</figcaption></figure><figure><a href="06-polish/desktop-download.webp"><img loading="lazy" src="06-polish/desktop-download.webp" alt=""></a><figcaption>06-polish</figcaption></figure></div></section><section><h2>desktop-setup</h2><div class="row"><figure><a href="00-before/desktop-setup.webp"><img loading="lazy" src="00-before/desktop-setup.webp" alt=""></a><figcaption>00-before</figcaption></figure><figure><a href="01-quieter/desktop-setup.webp"><img loading="lazy" src="01-quieter/desktop-setup.webp" alt=""></a><figcaption>01-quieter</figcaption></figure><figure><a href="02-clarify/desktop-setup.webp"><img loading="lazy" src="02-clarify/desktop-setup.webp" alt=""></a><figcaption>02-clarify</figcaption></figure><figure><a href="03-distill-layout/desktop-setup.webp"><img loading="lazy" src="03-distill-layout/desktop-setup.webp" alt=""></a><figcaption>03-distill-layout</figcaption></figure><figure><a href="04-adapt/desktop-setup.webp"><img loading="lazy" src="04-adapt/desktop-setup.webp" alt=""></a><figcaption>04-adapt</figcaption></figure><figure><a href="05-onboard/desktop-setup.webp"><img loading="lazy" src="05-onboard/desktop-setup.webp" alt=""></a><figcaption>05-onboard</figcaption></figure><figure><a href="06-polish/desktop-setup.webp"><img loading="lazy" src="06-polish/desktop-setup.webp" alt=""></a><figcaption>06-polish</figcaption></figure></div></section><section><h2>desktop-docs</h2><div class="row"><figure><a href="00-before/desktop-docs.webLine truncated
+255
View File
@@ -0,0 +1,255 @@
<title>frameport.app Redesign</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<style>
/* Layout: one column; a comparer (two steps, one shot) leads, the step list follows. FramePort's own Portal look:
dark only (the product is dark only), orange = the action and the current choice, blue = the secondary and the PC. */
:root {
color-scheme: dark;
--bg: #0d0e12;
--panel: #16181e;
--raised: #1d1f27;
--well: #252832;
--line: #2a2d37;
--line-strong: #393d49;
--text: #edeef2;
--dim: #a7abb7;
--faint: #868a96;
--orange: #ff8a1f;
--orange-ink: #160c03;
--blue: #3aa8ff;
--blue-line: rgba(58, 168, 255, .55);
--ok: #3dd68c;
--portal: linear-gradient(90deg, #3aa8ff, #ff8a1f);
--fade: linear-gradient(90deg, rgba(58, 168, 255, .22), rgba(255, 138, 31, .22));
--display: Sora, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--sans: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
--r: 12px; --r-sm: 8px;
}
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font: 400 15px/1.6 var(--sans); }
.page { max-width: 1240px; margin: 0 auto; padding-inline: clamp(16px, 4vw, 40px); padding-block: 40px 72px; display: grid; gap: 48px; }
a { color: var(--orange); }
:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
h1, h2 { font-family: var(--display); margin: 0; text-wrap: balance; letter-spacing: -0.02em; }
h1 { font-size: clamp(30px, 4.6vw, 48px); line-height: 1.08; font-weight: 800; }
h2 { font-size: 22px; font-weight: 700; }
p { margin: 0; text-wrap: pretty; }
code { font-family: var(--mono); font-size: .9em; }
/* ---- head */
.head { display: grid; gap: 18px; }
.site { display: flex; align-items: center; gap: 10px; font: 600 13px/1 var(--mono); color: var(--dim); }
.site::after { content: ""; flex: 1; height: 2px; background: var(--portal); opacity: .7; }
.lead { color: var(--dim); max-width: 64ch; font-size: 16.5px; }
.facts { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; padding: 0; list-style: none; }
.facts li { display: grid; gap: 2px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--panel); min-width: 0; }
.facts dt, .facts .k { font-size: 12.5px; color: var(--dim); }
.facts .v { font: 600 15px/1.3 var(--mono); font-variant-numeric: tabular-nums; }
.facts .v s { color: var(--faint); text-decoration-thickness: 1px; }
.facts .v b { color: var(--ok); font-weight: 600; }
/* ---- comparer */
.cmp { display: grid; gap: 16px; }
.bar { display: flex; flex-wrap: wrap; align-items: end; gap: 12px 20px; }
.pick { display: grid; gap: 4px; }
.pick label { font-size: 12.5px; color: var(--dim); }
select { appearance: none; min-height: 40px; padding: 0 34px 0 12px; border-radius: var(--r-sm); border: 1px solid var(--line-strong);
background: var(--well) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23a7abb7' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 12px center;
color: var(--text); font: 500 14px/1 var(--sans); cursor: pointer; }
.arrow { color: var(--faint); padding-bottom: 10px; }
.seg { display: inline-flex; padding: 3px; border-radius: 999px; border: 1px solid var(--line-strong); margin-left: auto; }
.seg button { all: unset; padding: 7px 14px; border-radius: 999px; font: 600 13px/1 var(--sans); color: var(--dim); cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--fade); color: var(--text); }
.seg button:focus-visible { outline: 2px solid var(--orange); outline-offset: 1px; }
.shots { display: grid; gap: 8px; }
.grp { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.grp .dev { width: 64px; font: 600 11px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--faint); }
.chip { all: unset; padding: 7px 12px; border-radius: 999px; border: 1px solid var(--line-strong); font: 500 13px/1 var(--sans); color: var(--dim); cursor: pointer; }
.chip:hover { color: var(--text); }
.chip[aria-pressed="true"] { border-color: transparent; background: var(--fade); color: var(--text); }
.chip:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
.stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
.pane { display: grid; gap: 8px; min-width: 0; }
.cap { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 13.5px; }
.cap b { font-weight: 600; }
.cap code { color: var(--faint); }
.cap .tag { font: 600 11px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.pane.a .tag { color: var(--blue); }
.pane.b .tag { color: var(--orange); }
.view { max-height: 76vh; overflow: auto; border: 1px solid var(--line); border-radius: var(--r); background: #050608; overscroll-behavior: contain; }
.view img { display: block; width: 100%; height: auto; }
.phone .view img { max-width: 390px; margin-inline: auto; }
.none { display: grid; place-items: center; min-height: 240px; padding: 24px; text-align: center; color: var(--dim); font-size: 14px; }
.swipe { position: relative; touch-action: pan-y; cursor: ew-resize; }
.swipe .after { position: absolute; inset: 0; clip-path: inset(0 0 0 var(--x, 50%)); }
.swipe .knob { position: absolute; top: 0; bottom: 0; left: var(--x, 50%); width: 2px; margin-left: -1px; background: var(--portal); box-shadow: 0 0 0 1px rgba(0, 0, 0, .5); pointer-events: none; }
.swipe-bar { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--dim); }
.swipe-bar input { flex: 1; accent-color: var(--orange); }
.swipe-bar .l { color: var(--blue); } .swipe-bar .r { color: var(--orange); }
/* ---- steps */
.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.step { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 20px; align-items: start; padding: 16px; border: 1px solid var(--line);
border-radius: var(--r); background: var(--panel); }
.step img { width: 100%; height: auto; border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
.step .body { display: grid; gap: 8px; min-width: 0; }
.step .top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; }
.step .n { font: 600 13px/1 var(--mono); color: var(--faint); font-variant-numeric: tabular-nums; }
.step h3 { margin: 0; font: 700 18px/1.25 var(--display); }
.step .top code { color: var(--faint); font-size: 12.5px; }
.step p { color: var(--dim); max-width: 75ch; }
.step button { all: unset; justify-self: start; padding: 8px 14px; border-radius: var(--r-sm); border: 1px solid var(--blue-line); color: var(--text);
font: 600 13.5px/1 var(--sans); cursor: pointer; }
.step button:hover { background: rgba(58, 168, 255, .08); }
.step button:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
.note { font-size: 13px; color: var(--faint); }
@media (max-width: 760px) {
.stage { grid-template-columns: minmax(0, 1fr); }
.view { max-height: 62vh; }
.seg { margin-left: 0; }
.step { grid-template-columns: minmax(0, 1fr); }
.step img { max-width: 360px; }
}
</style>
<main class="page">
<header class="head">
<p class="site">frameport.app</p>
<h1>The site, one step at a time</h1>
<p class="lead">Six passes after the 2026-10-12 critique (28/40), each committed on its own. Pick two steps and a
shot to compare them; the screenshots come from the same capture script each time.</p>
<ul class="facts" aria-label="Before and after">
<li><span class="k">Detector findings, home (desktop)</span><span class="v"><s>90</s> → <b>46</b></span></li>
<li><span class="k">Detector findings, home (phone)</span><span class="v"><s>59</s> → <b>33</b></span></li>
<li><span class="k">Home page height</span><span class="v"><s>10.8k px</s> → <b>8.4k px</b></span></li>
<li><span class="k">Dim text on cards</span><span class="v"><s>3.87:1</s> → <b>5.15:1</b></span></li>
<li><span class="k">Navigation on phones</span><span class="v"><s>none</s> → <b>Menu</b></span></li>
</ul>
</header>
<section class="cmp" id="compare" aria-labelledby="cmp-h">
<h2 id="cmp-h">Compare</h2>
<div class="bar">
<div class="pick"><label for="sel-a">Before</label><select id="sel-a"></select></div>
<span class="arrow" aria-hidden="true">→</span>
<div class="pick"><label for="sel-b">After</label><select id="sel-b"></select></div>
<div class="seg" role="group" aria-label="View">
<button type="button" id="mode-side" aria-pressed="true">Side by side</button>
<button type="button" id="mode-swipe" aria-pressed="false">Swipe</button>
</div>
</div>
<div class="shots" id="shots" role="group" aria-label="Shot"></div>
<div id="stage"></div>
</section>
<section aria-labelledby="steps-h" style="display:grid;gap:16px">
<h2 id="steps-h">The steps</h2>
<ol class="steps" id="steps"></ol>
<p class="note">Each step is one commit on the branch <code>worktree-impeccable-init</code>. Shots are seeded so the
hero shows the same game; scroll-reveal sections are shown settled.</p>
</section>
</main>
<script>
const DATA = {"steps": [{"id": "00-before", "name": "Before", "commit": "daa71a1", "note": "The site as the critique found it: 28/40, with a cursor light, word-by-word headings, scrambling labels, scroll fade-ups and no navigation on phones.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-download"]}, {"id": "01-quieter", "name": "Quieter", "commit": "540c719", "note": "Removed the ambient effects (cursor light, click ripples, rising and lit headings, label scramble, count-ups, scroll fade-ups, film grain, the download warp field, Compare's tilt, the avatar magnification). Motion stays where something crosses: the hero, the install log, the download portal.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-download"]}, {"id": "02-clarify", "name": "Clarify", "commit": "af6117a", "note": "Section labels name what you do there instead of 01-07. Plain headlines. The nav's \"Install\" became \"Get started\". The download page says why Windows and macOS warn.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-download"]}, {"id": "03-distill-layout", "name": "Distill + layout", "commit": "72f94d1", "note": "Screenshots and videos fold behind one disclosure, the game-maker block is one row, What's new is one line, Compare loses its icon tiles. Calmer spacing. The home page went from 10.8k to 8.4k px.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-download"]}, {"id": "04-adapt", "name": "Adapt", "commit": "58a936c", "note": "Below 1120px the nav links move into a Menu panel. Phones get \"Get it on your PC\". No 3D download on slow connections. Phone shots use a phone browser from here on.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-menu", "mobile-download"]}, {"id": "05-onboard", "name": "Onboard", "commit": "fc47dab", "note": "The setup-line step says nothing changes on the Frame until you click Allow, and links the script.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-walkthrough-step3", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-menu", "mobile-download"]}, {"id": "06-polish", "name": "Polish", "commit": "93b62fa", "note": "Real file names per platform in the walkthrough, readable dim text (4.77:1, was 3.6:1), themed placeholders, no labels under 11px, no gradient text or side-bar quotes left.", "shots": ["desktop-home-fold", "desktop-home-full", "desktop-walkthrough-step3", "desktop-download", "desktop-setup", "desktop-docs", "mobile-home-fold", "mobile-home-full", "mobile-menu", "mobile-download"]}], "shots": [{"id": "desktop-home-fold", "label": "Home, first screen", "device": "desktop"}, {"id": "desktop-home-full", "label": "Home, whole page", "device": "desktop"}, {"id": "desktop-walkthrough-step3", "label": "Get started, step 3", "device": "desktop"}, {"id": "desktop-download", "label": "Download page", "device": "desktop"}, {"id": "desktop-setup", "label": "Setup page", "device": "desktop"}, {"id": "desktop-docs", "label": "Docs", "device": "desktop"}, {"id": "mobile-home-fold", "label": "Home, first screen", "device": "phone"}, {"id": "mobile-home-full", "label": "Home, whole page", "device": "phone"}, {"id": "mobile-menu", "label": "Menu", "device": "phone"}, {"id": "mobile-download", "label": "Download page", "device": "phone"}]};
const $ = (s) => document.querySelector(s);
const step = (id) => DATA.steps.find((s) => s.id === id);
const shot = (id) => DATA.shots.find((s) => s.id === id);
const WHY = {
'mobile-menu': 'There was no menu on phones before Adapt (04).',
'desktop-walkthrough-step3': 'This shot was added with Onboard (05).',
};
const state = { a: DATA.steps[0].id, b: DATA.steps[DATA.steps.length - 1].id, shot: 'desktop-home-fold', mode: 'side', x: 50 };
function fillSelects() {
for (const sel of [$('#sel-a'), $('#sel-b')]) {
sel.innerHTML = DATA.steps.map((s, i) => `<option value="${s.id}">${String(i).padStart(2, '0')} · ${s.name}</option>`).join('');
}
$('#sel-a').value = state.a; $('#sel-b').value = state.b;
$('#sel-a').addEventListener('change', (e) => { state.a = e.target.value; render(); });
$('#sel-b').addEventListener('change', (e) => { state.b = e.target.value; render(); });
}
function renderShots() {
const devices = [...new Set(DATA.shots.map((s) => s.device))];
$('#shots').innerHTML = devices.map((d) => `<div class="grp"><span class="dev">${d}</span>${DATA.shots.filter((s) => s.device === d)
.map((s) => `<button type="button" class="chip" data-shot="${s.id}" aria-pressed="${s.id === state.shot}">${s.label}</button>`).join('')}</div>`).join('');
$('#shots').querySelectorAll('.chip').forEach((b) => b.addEventListener('click', () => { state.shot = b.dataset.shot; render(); }));
}
const src = (stepId) => step(stepId).shots.includes(state.shot) ? `${stepId}/${state.shot}.webp` : null;
const label = (stepId) => { const i = DATA.steps.findIndex((s) => s.id === stepId); return `${String(i).padStart(2, '0')} · ${step(stepId).name}`; };
const missing = (stepId) => `<div class="none">${WHY[state.shot] ?? `No ${shot(state.shot).label.toLowerCase()} shot in ${step(stepId).name}.`}</div>`;
const caption = (cls, tag, stepId) => `<div class="cap"><span class="tag">${tag}</span><b>${label(stepId)}</b><code>${step(stepId).commit}</code></div>`;
function render() {
$('#shots').querySelectorAll('.chip').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.shot === state.shot)));
$('#mode-side').setAttribute('aria-pressed', String(state.mode === 'side'));
$('#mode-swipe').setAttribute('aria-pressed', String(state.mode === 'swipe'));
const phone = shot(state.shot).device === 'phone';
const a = src(state.a), b = src(state.b);
const stage = $('#stage');
stage.className = phone ? 'phone' : '';
if (state.mode === 'swipe' && a && b) {
stage.innerHTML = `<div class="pane">
<div class="cap"><span class="tag" style="color:var(--blue)">Left</span><b>${label(state.a)}</b>
<span aria-hidden="true">·</span><span class="tag" style="color:var(--orange)">Right</span><b>${label(state.b)}</b></div>
<div class="view"><div class="swipe" style="--x:${state.x}%">
<img src="${a}" alt="${shot(state.shot).label}, ${label(state.a)}">
<div class="after"><img src="${b}" alt="${shot(state.shot).label}, ${label(state.b)}"></div>
<i class="knob"></i></div></div>
<div class="swipe-bar"><span class="l">${step(state.a).name}</span>
<input type="range" id="swipe-x" min="0" max="100" value="${state.x}" aria-label="Divider position">
<span class="r">${step(state.b).name}</span></div></div>`;
const sw = stage.querySelector('.swipe'), range = stage.querySelector('#swipe-x');
const set = (x) => { state.x = Math.max(0, Math.min(100, x)); sw.style.setProperty('--x', `${state.x}%`); range.value = state.x; };
range.addEventListener('input', () => set(Number(range.value)));
let drag = false;
const at = (e) => { const r = sw.getBoundingClientRect(); set(((e.clientX - r.left) / r.width) * 100); };
sw.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') return; drag = true; sw.setPointerCapture(e.pointerId); at(e); });
sw.addEventListener('pointermove', (e) => { if (drag) at(e); });
sw.addEventListener('pointerup', () => { drag = false; });
return;
}
stage.innerHTML = `<div class="stage">
<div class="pane a">${caption('a', 'Before', state.a)}<div class="view">${a ? `<img src="${a}" alt="${shot(state.shot).label}, ${label(state.a)}">` : missing(state.a)}</div></div>
<div class="pane b">${caption('b', 'After', state.b)}<div class="view">${b ? `<img src="${b}" alt="${shot(state.shot).label}, ${label(state.b)}">` : missing(state.b)}</div></div>
</div>`;
// the two views scroll together (by proportion: the pages differ in length)
const views = [...stage.querySelectorAll('.view')];
let lock = false;
views.forEach((v, i) => v.addEventListener('scroll', () => {
if (lock) return;
const o = views[1 - i], max = v.scrollHeight - v.clientHeight, omax = o.scrollHeight - o.clientHeight;
if (max <= 0 || omax <= 0) return;
lock = true; o.scrollTop = (v.scrollTop / max) * omax; requestAnimationFrame(() => { lock = false; });
}, { passive: true }));
}
function renderSteps() {
$('#steps').innerHTML = DATA.steps.map((s, i) => `<li class="step">
<img src="${s.id}/desktop-home-fold.webp" alt="Home, first screen, after ${s.name}" loading="lazy">
<div class="body">
<div class="top"><span class="n">${String(i).padStart(2, '0')}</span><h3>${s.name}</h3><code>${s.commit}</code></div>
<p>${s.note}</p>
${i ? `<button type="button" data-cmp="${i}">Compare with ${DATA.steps[i - 1].name}</button>` : ''}
</div></li>`).join('');
$('#steps').querySelectorAll('[data-cmp]').forEach((b) => b.addEventListener('click', () => {
const i = Number(b.dataset.cmp);
state.a = DATA.steps[i - 1].id; state.b = DATA.steps[i].id;
$('#sel-a').value = state.a; $('#sel-b').value = state.b;
render();
$('#compare').scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
}));
}
$('#mode-side').addEventListener('click', () => { state.mode = 'side'; render(); });
$('#mode-swipe').addEventListener('click', () => { state.mode = 'swipe'; render(); });
fillSelects(); renderShots(); renderSteps(); render();
</script>
+62 -4
View File
@@ -69,6 +69,10 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
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 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
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
@@ -211,7 +215,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
`FRAMEPORT_DESKTOP=1` skips the Steam-parent watchdog and Steam's display; ensure_host_fixes refreshes entries
(older installs, stale ones removed), uninstall/purge remove them. Per app: library entry field `desktop_entry`
(default on; patches don't apply to Linux apps) → game page switch → agent `desktop_entry`. Untested on device.
- Scanning: SideQuest backups (`<package>/<time>/apk|obb|data`) and AXRB downloads (`AXRB/<app id>/<binary id>/base.apk`
+ OBBs/assets; its `patched/`/`*-axrb.apk` PC builds are skipped) are recognised (`sources/quest_dump.py`, FAQ).
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
- Rename… (game menu "Name and artwork", Library right-click; CLI `frameport rename <pkg> "<name>"|--reset`):
`pipeline.rename_game` sets `title` + `title_locked` (+ `auto_title` = the automatic name for reset; rescans, Rift
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -316,8 +327,17 @@ Games tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device chec
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
managed tools. **The owner's real app data is the Windows copy's: `%LOCALAPPDATA%\FramePort`** (= `/mnt/c/Users/
<user>/AppData/Local/FramePort`; tools, overport workspace + game keystores, library, artwork, frames.json, the app's
SSH key which the dev Frame authorizes; moved from WSL's `~/.local/share/frameport` on 2026-10-11, which is now only
a stale backup). The desktop shortcut "FramePort (dev)" runs the source checkout natively on Windows
(`%LOCALAPPDATA%\FramePort-dev\launch-frameport-dev.vbs`: venv `FramePort-dev\venv` via `UV_PROJECT_ENVIRONMENT`,
`uv sync --frozen`, log `FramePort\logs\dev-gui.log`). **WSL runs on that data need `FRAMEPORT_WINDOWS_DATA=1`**
(set in `.claude/settings.local.json`; `paths.shares_windows_data`): the data folder becomes the Windows one, saved
paths from the other side are translated on load (`paths.localize`: /mnt/d/X <-> D:\X in library.json, tools.json,
PC deployment records), Java/Cpp2IL keep one copy per OS (tools.json `java-<os>-<arch>`), and the link handler isn't
registered (it would point the Windows copy's links at WSL). Don't run a WSL GUI and the Windows GUI at once
(the gui.lock flock/msvcrt locks don't see each other; only the gui.alive heartbeat does).
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
- Docs screenshots + videos (docs/SHOWCASE.md): never by hand. `uv run python scripts/showcase/render_docs.py`
@@ -345,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)
@@ -622,7 +647,10 @@ shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lo
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
@@ -712,7 +740,7 @@ 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. Autostart at the next SteamVR start: not verified yet. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**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".
**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
@@ -779,6 +807,14 @@ with an index naming modules, `shader_dumps` sends every module of the index'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.
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
every start; Walkabout Mini Golf files saves under `Profiles/Oculus/<id>/` and lost all progress. `frame.ovr_stable_user`
(default_on, detect = the game has libovrplatformloader.so) rewrites the helper to `mov x0, <6e18 + 32 bits of
sha256(package, keystore)>; ret`: stable per game and per user (keystores are personal), so online games never see two
FramePort users as one. Found in every PATCHED build checked. Host-tested only; upstream candidate.
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
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
@@ -795,6 +831,22 @@ table comes from dlsym on that handle; also first DT_NEEDED for its direct gl*/e
built single-view twin (view 0, uniforms copied per draw); eglMakeCurrent resets the per-thread cache. Host-tested only
(rewriter on all 19 Doom3Quest shaders + glmv.c against a stand-in GL, `tests/test_gl_multiview_fbo.py`); the host's
Mesa 23.2 llvmpipe has no OVR_multiview. Headset-verified by Xandrix1987 (2026-10-09: HUD/PDA shown, no fps drop) -> in the Doom3Quest catalog recipe.
**BlazeRush (GitHub #57, Klownicle's verified guide, 2026-10-10; FramePort's version headless-tested only):**
`frame.blazerush` (1.0.349 only: exec-segment SHA + every word checked, offsets from PT_LOAD) NOPs the avatar-readiness
wait (0x4b2dbc), RETs the avatar renderer (0x4b2adc), renames `cache/shader_cache` → `cache/br_shader_v1`. GL shim
(now also exports glShaderSource/glLinkProgram/glProgramBinary/glDeleteProgram/glDraw*: pass-through unless a setting
is on) `gl_int_attribs` (integer inputs fed via glVertexAttribPointer re-set with glVertexAttribIPointer per draw,
reflection cached per EGL context + program, dropped on link/binary/delete) and `gl_highp_markers` (vertex shaders with
every '|' marker: mediump→highp except on in/out/uniform lines); host test `tests/test_blazerush.py` with a stand-in
GL. FOV: the game reads VrApi props 7/8 right after vrapi_Initialize (no session; the Frame has no
XR_EPIC_view_configuration_fov) → the bridge saves the live FOV (also located before tracking, headless: 118.87×120.04
on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at the next start (log `FOV properties: …
(saved by an earlier session)`). `device.config_sync` (catalog field `config_sync`, agent v78): user_config.xml
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline. Launch tests count an exit/crash after
such a line as a pass (triage.yaml `headless_end`, finding `headless-end`; GitHub #57 "Failed to launch" on install).
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
@@ -847,6 +899,10 @@ up to 4 s for the link file to be taken before trusting the heartbeat, the windo
`gui.alive`, and the Windows command runs under `conhost.exe --headless` (plain `-WindowStyle Hidden` flashed a
console). Screens: `scripts/ui_smoke.py --links --fake-frame --game <pkg>` (tall pages: `--viewport
1280x7000`; Flutter's popup menu ignores Escape).
Demo link: the homepage's example button = `deeplink.DEMO_MANIFEST` (https://frameport.app/demo/cool-game.json, published
from site/public/demo/); every form (https button page, frameport://, framedrop://, pasted) → `InstallRequest.demo`,
no network (fetch_manifest returns `demo_manifest()`, download refuses), `link_dialog.show_demo` (Install =
`easter.demo_install`: Cool Game's cover out of the portal; eggs off → "Nice, it works!"), CLI just says so.
## Releases, CI, GitHub
Maintainer-only notes (accounts, credentials, key locations) live in the git-ignored `CLAUDE.local.md`.
@@ -937,5 +993,7 @@ the unresolved eye distortion, and testing the release bundles on real Windows/m
fallback (`core/cache.py`). Don't hardcode what can be discovered (e.g. Lepton path via appmanifests).
- Keep APK edits minimal and byte-stable (parity depends on it). Every new fix: a patch module + a triage signature +
a PLAYBOOK row + a unit test.
- New or changed patch, game setting, catalog recipe or triage signature → run `python scripts/patch_docs.py`
(regenerates docs/PATCHES.md + site/src/data/patches.json, the site's id → anchor map; a test runs `--check`).
- Known-good backups of every working APK and the signing keys: `PATCHED/_known-good-2026-09-28/` (don't delete).
- Your development Frame: `frameport frame info` (remembered in `<user data>/frames.json`); SSH as `steamos@<frame>`.
+365
View File
@@ -0,0 +1,365 @@
---
name: FramePort
description: Quest games, Android apps, Linux apps and PC VR games on the Valve Steam Frame, set up and installed from your PC.
colors:
frame-orange: "#FF8A1F"
frame-orange-soft: "#3A2512"
ember-ink: "#160C03"
pc-blue: "#3AA8FF"
pc-blue-soft: "#0F2A40"
void: "#0D0E12"
sidebar-night: "#111217"
panel: "#16181E"
panel-raised: "#1D1F27"
panel-well: "#252832"
hairline: "#2A2D37"
hairline-strong: "#393D49"
text-bright: "#EDEEF2"
text-dim: "#A7ABB7"
text-faint: "#717583"
ok-green: "#3DD68C"
warn-amber: "#F2C94C"
error-coral: "#FF6166"
info-cyan: "#5CD0E6"
typography:
display:
fontFamily: "Sora, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
fontSize: "clamp(30px, 4.2vw, 44px)"
fontWeight: 800
lineHeight: 1.1
letterSpacing: "-0.02em"
headline:
fontFamily: "Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
fontSize: "28px"
fontWeight: 700
lineHeight: 1.2
title:
fontFamily: "Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
fontSize: "16px"
fontWeight: 600
lineHeight: 1.3
body:
fontFamily: "Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
fontSize: "13px"
fontWeight: 400
lineHeight: 1.5
label:
fontFamily: "Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
fontSize: "11px"
fontWeight: 600
lineHeight: 1.3
section-label:
fontFamily: "ui-monospace, Cascadia Mono, SF Mono, Menlo, Consolas, monospace"
fontSize: "12px"
fontWeight: 600
lineHeight: 1
letterSpacing: "0.14em"
rounded:
xs: "6px"
sm: "8px"
md: "12px"
pill: "20px"
spacing:
s1: "4px"
s2: "8px"
s3: "12px"
s4: "16px"
s5: "24px"
s6: "32px"
components:
button-primary:
backgroundColor: "{colors.frame-orange}"
textColor: "{colors.ember-ink}"
typography: "{typography.title}"
rounded: "{rounded.sm}"
padding: "12px 16px"
button-primary-big:
backgroundColor: "{colors.frame-orange}"
textColor: "{colors.ember-ink}"
rounded: "{rounded.sm}"
padding: "18px 22px"
button-primary-disabled:
backgroundColor: "{colors.panel-well}"
textColor: "{colors.text-faint}"
rounded: "{rounded.sm}"
button-secondary:
backgroundColor: "transparent"
textColor: "{colors.text-bright}"
rounded: "{rounded.sm}"
padding: "12px 14px"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.text-dim}"
rounded: "{rounded.sm}"
button-danger:
backgroundColor: "{colors.error-coral}"
textColor: "{colors.ember-ink}"
rounded: "{rounded.sm}"
padding: "12px 16px"
input:
backgroundColor: "{colors.panel-well}"
textColor: "{colors.text-bright}"
typography: "{typography.body}"
rounded: "{rounded.sm}"
padding: "10px 12px"
pill:
textColor: "{colors.text-dim}"
typography: "{typography.label}"
rounded: "{rounded.pill}"
padding: "3px 9px 3px 8px"
card:
backgroundColor: "{colors.panel}"
textColor: "{colors.text-bright}"
rounded: "{rounded.md}"
padding: "16px"
callout:
textColor: "{colors.text-bright}"
rounded: "{rounded.sm}"
padding: "12px 14px"
nav-item-selected:
textColor: "{colors.text-bright}"
rounded: "{rounded.sm}"
tooltip:
backgroundColor: "{colors.panel-well}"
textColor: "{colors.text-bright}"
rounded: "{rounded.xs}"
padding: "8px 12px"
---
# Design System: FramePort
## Overview
**Creative North Star: "The Portal Between"**
FramePort is about crossing. On one side is the PC, in blue; on the other is the Frame, in orange. Between them is the
seam where a game passes through: a horizontal gradient from blue to orange, the same one the logo's tilted portal
makes. Everything else is dark space, so the crossing is what glows. The desktop app and frameport.app share this
system with the same color values. The site states it outright ("FramePort Portal, for the web").
Surfaces are flat, dark, tonal steps a few lightness points apart, with hairline borders, and the chrome stays quiet.
Color comes from three places only: cover art, the orange that marks the one thing to do now, and the blue portal
touches that show where you are or what's moving. Density is comfortable desktop density, built for a mouse and a
window you resize. The interface scales as a whole through one scale factor, never by reflowing into a phone layout.
Components are tactile and confident. A cover lifts and its art zooms when pointed at. A button settles when pressed.
On the website, motion is kept for things that cross: the hero's package-to-game crossing, the install log turning
from PC rows into Frame rows, the download going through the portal. Nothing moves just because the pointer or the
scroll passed by (no cursor light, no word-by-word headings, no scroll fade-ups).
Play sends two rings out of the button, blue then orange. A new view fades in. Every one of these motions disappears
under Reduce motion, and the static state must still read on its own.
**Key Characteristics:**
- Dark only: a near-black void (`void`) with three tonal panel steps above it.
- Orange is the action, blue is the place: the portal dichotomy is the color doctrine.
- The blue→orange gradient is the system's only decoration.
- Artwork carries the color; the chrome stays neutral.
- Flat at rest. Artwork and floating layers lift.
- Motion is physical (lift, spring, ripple) and always optional.
## Colors
A cool, near-black neutral ramp with two saturated poles, the orange Frame and the blue PC, plus four status colors
tuned so none of them reads as the accent.
### Primary
- **Frame Orange** (`frame-orange`): the main action of a view (Install on Frame, Play on Frame, Add games), the
current selection in single-color themes, text cursors, focused input borders and links. In the wordmark it is
"Frame".
- **Frame Orange Soft** (`frame-orange-soft`): the tinted backing for orange in small areas, such as selected rows in
the `original` theme and the accent wash behind the portal scene.
- **Ember Ink** (`ember-ink`): text and glyphs on orange (and on the coral danger button). Never pure black and never
white.
### Secondary
- **PC Blue** (`pc-blue`): secondary (outlined) buttons, progress, switch tracks, anything "on this PC" or PC VR, and
"Port" in the wordmark. Outlines use it at 55 % opacity.
- **PC Blue Soft** (`pc-blue-soft`): tinted backing for blue in small areas, and the round icon wells on feature cards.
### Neutral
- **Void** (`void`): the window and page background.
- **Sidebar Night** (`sidebar-night`): the sidebar and table headers, one step above the void.
- **Panel** (`panel`): cards, sections and the game page's info blocks.
- **Panel Raised** (`panel-raised`): hover, menus, code blocks and popovers.
- **Panel Well** (`panel-well`): inputs, chips, tooltips and disabled buttons.
- **Hairline** (`hairline`) / **Hairline Strong** (`hairline-strong`): 1px card borders, dividers, menu and popover
outlines.
- **Text Bright** (`text-bright`), **Text Dim** (`text-dim`), **Text Faint** (`text-faint`): primary text, secondary
text and meta / disabled text. The website uses a lighter Text Faint (#868a96): the app's value is below 4.5:1 for
the small print the site carries (timestamps, captions). Placeholders use Text Dim there, never the browser gray.
### Status
- **OK Green** (`ok-green`): Works, Connected, Installed, the battery ring, passed checks.
- **Warn Amber** (`warn-amber`): Works with issues, warnings. Deliberately yellow, so it never reads as Frame Orange.
- **Error Coral** (`error-coral`): failures and destructive actions.
- **Info Cyan** (`info-cyan`): info callouts and a second data series next to PC Blue in Monitor charts.
### Named Rules
**The Portal Rule.** Orange is the Frame and the action; blue is the PC and the secondary. Don't swap them, and don't
use a third hue for an action.
**The One Orange Rule.** A view has one filled orange button: the thing to do now. Other actions are blue outlines or
ghosts.
**The Seam Rule.** The blue→orange gradient (`--portal`, `C.portal_gradient`) is the only decoration. It always runs
left to right (or top to bottom), blue first. It appears on the sidebar edge, the selected nav item and segment, the
wordmark, section-label hairlines, link underlines on the site and progress fills. Dual themes only.
**The Dark-Only Rule.** No light mode. Installed theme files whose background is too light, or whose text falls below
4.5:1 on a panel, are refused.
## Typography
**Display Font:** Sora 600–800 (website only; self-hosted variable woff2), falling back to Segoe UI / Roboto
**Body Font:** the platform sans (Segoe UI on Windows, Roboto via Flutter, Helvetica Neue / Arial)
**Label/Mono Font:** ui-monospace / Cascadia Mono / SF Mono / Menlo / Consolas
**Character:** the app speaks in its platform's sans, so it feels like a native tool. The site adds Sora's geometric
heavy weights for headings, which echo the rounded wordmark. Monospace appears where something is literally typed or
counted: the setup line, logs, section labels.
### Hierarchy
- **Display** (Sora 800, clamp(30px, 4.2vw, 44px), 1.1, −0.02em): site section headings and doc titles. In the app,
empty-state and setup headings use 700 at 22px.
- **Headline** (700, 28px): the app's page titles (Library, the game title on its hero).
- **Title** (600, 16px): section headings in the app (Where it's installed, About this game) and card headings.
Buttons use 600 at 13px (15px when big).
- **Body** (400, 13px app / 16px at 1.6–1.7 on the site): running text. Site prose and leads stay at 62ch or less.
- **Label** (600, 11–12px): pills, meta lines, counts.
- **Section label** (mono 600, 12px, 0.14em, uppercase): site section labels followed by the portal hairline. They
name what the visitor does there ("See what runs", "Get started"), never a number ("02 · The app" was removed: the
order carries no information). The app's "ALL GAMES" divider is the same idea.
### Named Rules
**The Scale-Token Rule.** In the app every size goes through `T.T_*` tokens or `T.px()`, so the user's UI scale
(75–300 %) applies everywhere. A bare pixel number is a bug.
**The Sentence-Case Rule.** Headings and buttons use sentence case. Only the mono section labels and pill-sized
overlays are uppercase.
## Layout
The app is a fixed-width left sidebar (navigation, then the activity card, the live Frame card and the power bar at
the bottom) next to a scrolling content column. The sidebar ends in a 1px vertical blue→orange seam. Content uses an
8-point spacing scale (4/8/12/16/24/32), with 24–32 between sections and 12 between a heading and its body. Pages
start with a title row (title and count on the left, search and actions on the right, the orange action alone at the
far right), then filter rows of segmented controls and dropdowns, then content.
Library covers sit in a wrapping grid of portrait cards, with a horizontal "On your Frame" shelf of landscape cards
above. The game page opens with a full-width art hero holding breadcrumb, meta line, title, pills and the action row.
Dialogs come in three widths (440 / 600 / 880). Selection bars sit **below** a list, never above it, so items don't
jump during a drag.
The site uses a centered 1180px column with a fluid gutter (`clamp(16px, 4vw, 40px)`) and section padding of
`clamp(56px, 7vw, 96px)`. On phones, wide doc tables and diagrams scroll sideways inside their box, never the page.
## Elevation & Depth
The system is flat at rest. Depth comes from tonal steps (void → sidebar → panel → raised → well) and 1px hairlines.
Shadows are reserved for two things: **artwork**, which sits on the page like a physical cover, and **floating
layers** (menus, popovers, term cards, toasts, dialogs), which have to separate from whatever is beneath them.
Hovering a cover deepens its shadow and adds an orange glow, the one place the accent becomes light.
### Shadow Vocabulary
- **Art at rest** (`box-shadow: 0 8px 22px 1px rgba(0,0,0,.55)`): cover and shelf cards, screenshots.
- **Art hover** (`0 16px 36px 2px rgba(0,0,0,.75), 0 4px 30px -2px rgba(255,138,31,.22)`): a pointed-at cover, with
the orange glow underneath.
- **Float** (`0 18px 40px rgba(0,0,0,.55)`, app menus: elevation 8 on black): menus, popovers, term cards, the
search-result bar.
- **Portal glow** (radial: blue 10 % → orange 5 % → transparent): the soft light behind empty states and the site
hero. Not a shadow, but the only other source of depth.
### Named Rules
**The Lift-Is-Earned Rule.** A panel never gets a shadow just to look important. If it isn't artwork and it doesn't
float over other content, it's flat with a hairline.
## Shapes
Gently rounded and consistent: 12px for cards, dialogs and the hero; 8px for buttons, inputs, callouts, thumbnails and
code blocks; 6px for tooltips and small chips; fully round (20px+) for pills, segmented tracks, the cover buttons and
toasts. Borders are 1px hairlines; a focused input or a site `:focus-visible` gets a 2px orange edge instead. Cover art
is clipped inside its card, so the art can zoom on hover without spilling. The portal itself is a tilted oval (18°,
`--tilt`), and that silhouette is used only in the logo, the wordmark and the empty-state portal scene.
## Components
### Buttons
Tactile and confident: clear fills, a firm press.
- **Shape:** gently rounded (8px).
- **Primary:** Frame Orange fill, Ember Ink 600 label, 12px × 16px (18px × 22px for the hero's big Play/Install). The
glyph switches to its `-solid` variant. Disabled turns to Panel Well with Text Faint.
- **Hover / Press:** the site brightens 8 % on hover and settles to 0.97 on press (ease-out-quart, no overshoot). The
app uses Material's overlay. Focus shows a 2px orange outline, 3px offset.
- **Secondary:** transparent with a 1px blue outline at 55 %, a blue glyph, a bright label and a faint blue wash on
hover. Single-color themes use a neutral Hairline Strong outline.
- **Ghost:** a Text Dim label only, for Close, Cancel and inline links (orange when it's a link).
- **Danger:** the primary's shape in Error Coral. The outlined variant is for entries that ask first; the filled one
is the dialog's final button.
- **Icon button:** 20px glyph in Text Dim, always with a tooltip.
### Pills
- **Style:** fully round, 11px 600 label, colored text on a 12 % tint of the same color. A leading dot (site) or glyph
(app) means the color is never the only signal.
- **Variants:** status (Works / Works with issues / Can't run), install state (solid fill + Ember Ink, e.g. "On
Frame"), platform (Quest / PC VR). Overlay pills on artwork sit on 62 % black.
### Cards / Containers
- **Corner Style:** 12px.
- **Background:** Panel on Void, with a 1px Hairline border.
- **Shadow Strategy:** none; see Elevation. Cover cards are the exception.
- **Internal Padding:** 16px.
### Cover cards (signature)
The game is the hero. A portrait (grid) or landscape (shelf) cover, clipped at 12px, has a bottom fade for its title
and status line and pills over the top-left corner. Hover lifts the card (scale 1.02, up 1.5 %, 220ms ease-out), zooms
the art inside the clip (1.07, 600ms), fades in a radial scrim and deepens the shadow into Art hover. A frosted round
**cover button** (10px blur, 60 % white ring) springs in (scale 0.72 → 1, ease-out-back) and fills with orange under
the pointer. Play sends out two rings, blue then orange, and turns into a "Starting on Frame…" pill for 8 s. Reduce
motion keeps only the shadow and scrim changes.
### Inputs / Fields
- **Style:** filled Panel Well, no border at rest, 8px corners, 13px text, dense 10px × 12px padding. Search adds a
leading magnifier.
- **Focus:** the border appears in Frame Orange; the cursor is orange.
- **Labels:** Text Dim. Dropdowns share the same filled look.
### Callouts
A line of guidance that belongs to the page: an 18px status glyph, then text. The status color is used at 8 % for the
fill and 35 % for the 1px border, with 8px corners. Kinds: info (cyan), warn, error, ok, pc (blue). Buttons inside a
callout make it a bar.
### Navigation
- **Sidebar:** glyph + label rows with Text Dim at rest. The selected row gets the portal fade (blue→orange at 22 %)
with a bright icon and label (single-color themes: the orange soft tint and an orange icon). The Steam Frame row
carries a green connected dot.
- **Segmented controls:** a round track. The selected segment takes the same portal fade.
- **Site nav:** the wordmark (orange "Frame", the portal glyph, blue "Port"), ghost links, one small primary
"Download FramePort" button at the right.
### Empty states (signature)
The portal with what the screen is about coming out of it, on a soft portal light. Then a 22px heading, one centered
sentence, the action that fills the screen, and up to three feature cards (round blue icon well, 14px heading, one
sentence) that say what will appear here.
## Do's and Don'ts
### Do:
- **Do** read colors as theme tokens when building (`T.ACCENT`, `var(--accent)`), so all three built-in themes and
user theme files work.
- **Do** keep exactly one filled orange action per view; make everything else blue outline or ghost.
- **Do** put the blue→orange gradient only where something crosses or is selected: seams, selection fades, progress,
link underlines.
- **Do** let cover art carry the color and keep surrounding chrome neutral.
- **Do** pair every status color with a word or glyph (pills, callouts, checklists).
- **Do** give motion a static fallback: under Reduce motion (`ui.reduce_motion`, `prefers-reduced-motion`) the state
must still read without the animation.
- **Do** scale every app size through `T.T_*` / `T.px()` and give icon-only controls a tooltip.
### Don't:
- **Don't** build a light theme or a light surface; FramePort is dark only.
- **Don't** swap the poles: no orange secondary buttons, no blue primary actions.
- **Don't** add shadows to ordinary panels or cards; only artwork and floating layers lift.
- **Don't** use Warn Amber or another orange-adjacent hue for a warning that could be mistaken for the accent.
- **Don't** use the tilted portal silhouette as general ornament; it belongs to the logo, the wordmark and empty
states.
- **Don't** put image bytes into controls or recreate clickable controls on progress ticks; update them in place.
+121
View File
@@ -0,0 +1,121 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
Two surfaces share this record:
- **The desktop app:** Python + Flet 1.0 (Flutter), shipped as bundles for Windows x64, macOS arm64 and Linux x64, plus
a CLI wheel. It is not a website, but it renders through Flutter rather than native OS widgets, so it counts as `web`
here. Desktop window, mouse and keyboard first; no phone layout.
- **The website** [frameport.app](https://frameport.app): Astro 7 (`site/`), with pages for home, download, install,
setup, docs, changelog and privacy. It hosts the setup line (`/s`) and the demo install link.
## Users
The primary user is a **Steam Frame owner who isn't technical**. They have Quest games (often from SideQuest backups)
or PC VR games and want them in the Frame's Steam library without learning about APKs, patches or Proton. They work
from their PC with the headset nearby, and they may switch to the headset to play or to type the setup line in
Konsole.
Defaults, copy and the main paths serve that user. Depth is there for a second group: **tinkerers and recipe
contributors** who change patches and game settings, read launch tests and session triage, use Monitor and share
working recipes back to the catalog. Their tools sit behind Customize, "Show technical details" and the game's "…"
menu, and stay out of the main path.
## Product Purpose
FramePort installs Quest games, Android apps, Linux apps, PC VR games and Windows programs on the **Valve Steam
Frame**. It sets up the Frame once (one setup line, no root, no password), patches each game so it runs in Valve's
Lepton (Android) or Proton (Windows), uploads it, adds it to Steam with artwork, and tests that it starts.
Success means the user picks a game, clicks **Install on Frame**, and plays it from the Frame's Steam library. When a
game can't run, FramePort says so plainly and gives a next step instead of failing silently.
## Positioning
Other tools (FrameDrop, Valve's own tools) install an app as it is. FramePort **converts and patches** Quest games that
don't run on the Frame as they are. It uses community-tested recipes (131 catalog entries in `catalog/games/`) and
suggests patches for every other game, each explained in plain words. It is free and open source (GPL-3.0), and it
manages the Frame from the PC: Files, Screenshots, Live view, Type on Frame and Monitor.
## Operating Context
- **First run:** download, unpack, start FramePort, then **Steam Frame → Start setup**. On the Frame, run the setup
line in Konsole and click **Allow** when both sides show the same 4-digit code. Wi-Fi or a USB cable.
- **Daily use:** Add games (scan a folder, install link, Linux app, Windows program), then install, play, update and
uninstall from the Library. The game page holds the main action; patches are under Customize.
- **While playing:** the PC shows the Frame's state. The sidebar Frame card has battery and "now playing" fps, plus the
Monitor, Live view (in the browser), Screenshots and Files tabs.
- **When something breaks:** launch tests, session triage callouts ("Last session"), Report a problem (redacted
diagnostics, prefilled GitHub issue) and Share working config.
- Long jobs (uploads of several GiB) run in a background queue that can be cancelled and resumed. The window stays
responsive and shows progress in the sidebar activity cards.
## Capabilities and Constraints
- Terms and voice follow `docs/STYLE.md`: Steam Frame / the Frame, Quest game, PC VR game, patch, recipe, Install /
Update / Reinstall / Play, Set up / connect, This PC, sentence case, US spelling, short labels (four words at most).
- Every GUI text goes through `tr()` (`src/frameport/i18n.py`), so layouts must tolerate longer translated strings.
- GUI scale is user-set (`ui.scale`, auto under WSL); sizes go through theme tokens and `T.px()`, never bare pixels.
- Performance rules are binding: no image bytes in controls (artwork by URL thumbnails), no I/O in render paths, and
clickable controls are updated in place, never recreated on progress ticks.
- Reduce motion (`ui.reduce_motion`) turns off every animation.
- Docs screenshots and videos are generated by `scripts/showcase/` from a demo library, never by hand. New icon-only
controls need a tooltip so the showcase steps can find them.
- Headless tests prove that a game starts, never what it looks like. Tracking only works with the headset worn.
- Not every game runs. Some need Meta's services or hardware the Frame lacks (e.g. 32-bit-only APKs, Oculus Platform
SDK games). The product must say this, not hide it.
## Brand Commitments
- **Logo:** a monitor coming out of a tilted portal as the Frame (`src/frameport/ui/icons/logo.svg`,
`logo-solid.svg`; `docs/brand/`). The wordmark carries the portal.
- **Portal dichotomy:** orange = the main action and selection, blue = secondary actions, progress and the PC side.
`portal` is the default theme (`portal_oled` and `original` also exist).
- **Dark only.** No light mode; new themes are dark variants. User theme files that are light or unreadable are
refused.
- **Voice:** plain, technical wording that says what happens ("Steam restarts once"). No marketing adjectives
(`docs/STYLE.md`).
- **Credit** the projects FramePort wraps (OVRPort, Revive, Proton, Lepton, SteamVR and others): most of the
functionality is theirs.
- **No piracy tools.** FramePort doesn't download, share or unlock games and never bypasses entitlement checks.
- **No Quest2Frame mentions** anywhere.
- **Easter eggs** exist (`ui/easter.py`) and are never mentioned in the UI or docs.
## Evidence on Hand
- Generated screenshots in `docs/images/` (library, game, game settings, patches, install progress, frame, files,
screenshots, screenshot viewer, live view, monitor, type on Frame, appearance) and diagrams (`architecture.svg`,
`self-update.svg`).
- Videos: `docs/media/frameport-tour.mp4` and `docs/media/frameport-install.mp4` (about 90 s each), teaser
`docs/images/tour-teaser.webp`.
- Install button badges in `docs/badges/` (`docs/INSTALL_BUTTON.md`); brand icon and social preview in `docs/brand/`.
- Tested games list `docs/GAMES.md` and the recipe catalog `catalog/games/`. Real GitHub release, download and
contributor data feed the site (`site/scripts/releases.mjs`, `contributors.mjs`).
- **Absent, do not fabricate:** testimonials, user counts, press quotes, reviews or performance claims beyond what
the docs record.
## Product Principles
1. **One click for the owner, every switch for the tinkerer.** The main path never asks a question the recipe can
answer; depth lives one step away and is explained in plain words.
2. **Say what happens, including failure.** Every state, from setup to launch test to "can't run on the Frame", is
stated plainly with a next step.
3. **The Frame is a place you reach from your PC.** Files, screenshots, live view, typing and monitoring treat the
headset as connected, not remote.
4. **Never lose the user's work.** Saves, signing keys, picked artwork, renamed titles and interrupted uploads are
kept and resumable.
5. **Open and honest.** Credit the upstream projects, no piracy, no personal data in reports, and admit that not every
game runs.
## Accessibility & Inclusion
- Reduce motion turns off every animation, and the easter eggs too.
- User-adjustable GUI scale for high-DPI screens.
- Every text is translatable (`tr()`).
- Icon-only controls carry tooltips. These also feed Flutter's semantics tree, which the showcase uses.
- No formal WCAG target has been set (open decision).
+3 -3
View File
@@ -28,7 +28,7 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
- **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 90+ games; other games get suggested patches, each explained in
- **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.
@@ -76,7 +76,7 @@ suggests patches. See the [list of tested games](docs/GAMES.md). Got a game work
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 90+
- **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.
@@ -91,7 +91,7 @@ FrameDrop and Valve's own tools install an app as it is. FramePort differs in fo
| 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 90+ games, updated without an app update | Not stated | No |
| 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: &lt;title&gt;"; 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 |
+679 -12
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 75
AGENT_VERSION = 81
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -737,6 +737,12 @@ def ensure_host_fixes():
overlays = []
if overlays:
changed.append(f"SteamVR overlay apps registered ({len(overlays)})")
try:
service = ensure_overlay_service()
except Exception: # noqa: BLE001
service = None
if service:
changed.append(f"overlay autostart service {service}")
return changed
@@ -944,7 +950,15 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
root = vdf_decode(data) if data else {"shortcuts": {}}
shortcuts = root.setdefault("shortcuts", {})
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe), None)
# matched by Exe; a launcher many games share (Revive's injector on a PC) also by the game in its launch options,
# or two Revive games overwrote each other's shortcut
shared = "reviveinjector" in exe.lower()
def game_of(options): # the game's program in the injector's arguments (its flags may change)
found = re.findall(r'"([^"]+\.exe)"|(\S+\.exe)', options or "", re.IGNORECASE)
return (found[-1][0] or found[-1][1]).lower() if found else (options or "")
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe
and (not shared or game_of(v.get("LaunchOptions", "")) == game_of(launch_options))), None)
ident = entry["appid"] if entry else shortcut_appid(exe, title)
if entry is None:
entry = {"appid": ident, "LastPlayTime": 0, "tags": {"0": tag}}
@@ -2513,6 +2527,7 @@ def cmd_register_vr_overlay(args):
out = register_overlay(pkg, bool(args.get("autostart")))
dep["overlay"]["registered"] = out["registered"]
write_deployment(pkg, dep)
out["service"] = overlay_service_quiet()
return out
@@ -2523,6 +2538,7 @@ def cmd_unregister_vr_overlay(args):
if dep and "overlay" in dep:
dep.pop("overlay")
write_deployment(pkg, dep)
overlay_service_quiet()
return {"unregistered": True, "steamvr": reached}
@@ -2554,6 +2570,15 @@ def cmd_launch_vr_overlay(args):
return {"started": not err, "pid": pid or None, "error": err}
def overlay_service_quiet():
"""ensure_overlay_service() after an overlay app's autostart flag changed; a systemd problem never fails the
command (ensure_host_fixes tries again at the next connection)."""
try:
return ensure_overlay_service()
except Exception as exc: # noqa: BLE001
return f"error: {exc}"
def overlay_registrations():
"""Register overlay apps whose registration is missing (SteamVR wasn't running at install, or forgot it)."""
done = []
@@ -2581,6 +2606,259 @@ def overlay_registrations():
return done
# ------------------------------------------------------------------------------------------ overlay autostart (v76)
# SteamVR's own auto-launch flag (SetApplicationAutoLaunch) was accepted on the dev Frame but never written to
# steamvr.vrsettings, and a registered watch didn't start by itself (2026-10-10). FramePort's deployment flag
# (overlay.autostart) is the source of truth: a small user service (only while at least one installed overlay app has
# autostart on) notices every new vrserver process (SteamVR start or restart, boot), waits until SteamVR answers,
# gives SteamVR's own auto-launch a moment, then starts each autostart overlay app that isn't running yet, the same
# way launch_vr_overlay does (LaunchApplication: SteamVR spawns it, in its own environment). Off for every app: the
# flag file VR_OVERLAY_DISABLED or FRAMEPORT_NO_OVERLAY_AUTOSTART=1 (the service is then removed).
VR_OVERLAY_UNIT = "frameport-vr-overlays"
SYSTEMD_USER_DIR = os.path.join(HOME, ".config/systemd/user")
VR_OVERLAY_UNIT_PATH = os.path.join(SYSTEMD_USER_DIR, VR_OVERLAY_UNIT + ".service")
VR_OVERLAY_DISABLED = os.path.join(HOME, ".local/share/frameport/vr-overlays.disabled")
VR_OVERLAY_LOG = os.path.join(HOME, ".local/share/frameport/vr-overlays.log")
VR_OVERLAY_STATE = os.path.join(HOME, ".cache/frameport-vr-overlays.json")
VR_OVERLAY_LOG_MAX = 128 * 1024
VR_OVERLAY_POLL = 5 # s between /proc scans (nothing else happens while SteamVR keeps running)
VR_OVERLAY_READY_WAIT = 180 # s for a new SteamVR to answer IVRApplications
VR_OVERLAY_GRACE = 15 # s after SteamVR answers: its own auto-launch may start the app first
def overlay_autostart_disabled():
return os.environ.get("FRAMEPORT_NO_OVERLAY_AUTOSTART", "0") not in ("", "0") or \
os.path.exists(VR_OVERLAY_DISABLED)
def autostart_overlays():
"""Installed overlay apps whose deployment has autostart on: [(package, deployment)]."""
found = []
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
if isinstance(dep, dict) and dep.get("package") and (dep.get("overlay") or {}).get("autostart"):
dep.setdefault("base", os.path.dirname(dep_path))
found.append((dep["package"], dep))
return found
def systemd_quote(arg):
"""One ExecStart= word (systemd's own quoting: double quotes, % and $ doubled)."""
arg = arg.replace("%", "%%").replace("$", "$$")
if re.fullmatch(r"[A-Za-z0-9_./+:=@-]+", arg):
return arg
return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
def vr_overlay_unit():
"""The user service's unit text (the agent's path and Python are written in: the agent lives in AGENT_HOME)."""
agent = os.path.abspath(__file__)
return ("[Unit]\n"
"Description=FramePort: start SteamVR overlay apps with SteamVR\n"
"\n[Service]\n"
"Type=simple\n"
f"ExecStart={systemd_quote(sys.executable or '/usr/bin/python3')} {systemd_quote(agent)} "
"_vr_overlay_watch\n"
"Restart=always\n"
"RestartSec=10\n"
"Nice=10\n"
"\n[Install]\n"
"WantedBy=default.target\n")
def user_systemctl(*args):
return run(["systemctl", "--user", *args])
def ensure_overlay_service():
"""Install (or update) the overlay autostart service when an installed overlay app has autostart on, remove it
when none has (or the kill switch is set). Returns "installed", "updated", "removed" or None (no change)."""
want = bool(autostart_overlays()) and not overlay_autostart_disabled()
text = vr_overlay_unit()
try:
current = open(VR_OVERLAY_UNIT_PATH).read()
except OSError:
current = None
if not want:
if current is None:
return None
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
try:
os.remove(VR_OVERLAY_UNIT_PATH)
except OSError:
pass
user_systemctl("daemon-reload")
user_systemctl("reset-failed", VR_OVERLAY_UNIT + ".service")
return "removed"
if current == text:
if user_systemctl("is-active", "--quiet", VR_OVERLAY_UNIT + ".service").returncode == 0:
return None
user_systemctl("enable", "--now", VR_OVERLAY_UNIT + ".service")
return "started"
os.makedirs(SYSTEMD_USER_DIR, exist_ok=True)
tmp = VR_OVERLAY_UNIT_PATH + ".tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
user_systemctl("enable", VR_OVERLAY_UNIT + ".service")
user_systemctl("restart", VR_OVERLAY_UNIT + ".service")
return "installed" if current is None else "updated"
def overlay_log(msg):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_LOG), exist_ok=True)
if os.path.exists(VR_OVERLAY_LOG) and os.path.getsize(VR_OVERLAY_LOG) > VR_OVERLAY_LOG_MAX:
os.replace(VR_OVERLAY_LOG, VR_OVERLAY_LOG + ".1")
with open(VR_OVERLAY_LOG, "a") as f:
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
except OSError:
pass
def vrserver_ident(pid):
""""<pid>:<start ticks>" when process `pid` is a vrserver (the start time tells a reused pid apart), else None."""
try:
with open(f"/proc/{pid}/stat") as f:
stat = f.read()
except OSError:
return None
if stat[stat.find("(") + 1:stat.rfind(")")] != "vrserver":
return None
return f"{pid}:{stat[stat.rfind(')') + 2:].split()[19]}"
def vrserver_process(known=None):
"""The running vrserver's vrserver_ident, None if none. `known` (the last one seen) is checked first: while
SteamVR keeps running a poll reads one file instead of scanning /proc."""
if known and vrserver_ident(known.split(":")[0]) == known:
return known
for d in os.listdir("/proc"):
if d.isdigit():
found = vrserver_ident(d)
if found:
return found
return None
def overlay_running(pkg, dep, apps=None):
"""The overlay app runs already: a process of its install folder or launcher (also when Steam or SteamVR started
it), or SteamVR knows its process (GetApplicationProcessId)."""
if pcvr_pids(dep.get("base") or os.path.join(ANCHORS, pkg)) or pcvr_pids(os.path.join(ANCHORS, pkg)):
return True
if apps is not None:
try:
return bool(apps.pid(dep_overlay_key(pkg, dep)))
except Exception: # noqa: BLE001
return False
return False
def overlay_autostart_round():
"""One new SteamVR: wait until it answers, give its own auto-launch a moment, then start each autostart overlay
app that isn't running. Returns {package: "started" | "running" | error text}; {} without SteamVR."""
deadline = time.time() + VR_OVERLAY_READY_WAIT
apps = SteamVRApps.open()
while apps is None and time.time() < deadline:
time.sleep(3)
apps = SteamVRApps.open()
if apps is None:
overlay_log("SteamVR didn't answer: nothing started")
return {}
try:
time.sleep(VR_OVERLAY_GRACE)
todo = [(pkg, dep) for pkg, dep in autostart_overlays() if not overlay_running(pkg, dep, apps)]
result = {pkg: "running" for pkg, _ in autostart_overlays() if pkg not in dict(todo)}
finally:
apps.close()
for pkg, _ in todo:
try:
r = cmd_launch_vr_overlay({"package": pkg})
result[pkg] = "started" if r.get("started") else (r.get("error") or "not started")
except Exception as exc: # noqa: BLE001
result[pkg] = str(exc) or type(exc).__name__
return result
def overlay_watch_tick(state):
"""One poll of the watcher: a vrserver not handled before (state["vrserver"]) gets one autostart round. Returns
the round's result, or None when there was nothing to do."""
if overlay_autostart_disabled():
return None
proc = vrserver_process(state.get("vrserver"))
if not proc or proc == state.get("vrserver"):
return None
state["vrserver"] = proc
overlay_save_state(state) # a restarted watcher (agent update) doesn't start apps the user closed again
if not autostart_overlays():
return None
overlay_log(f"new SteamVR (vrserver {proc.split(':')[0]})")
result = run_overlay_round()
for pkg, what in result.items():
overlay_log(f"{pkg}: {what}" if what in ("started", "running") else f"{pkg}: couldn't start it: {what}")
state["last"] = {"time": time.time(), "result": result}
overlay_save_state(state)
return result
def run_overlay_round():
"""overlay_autostart_round() in a child process (_vr_overlay_round): OpenVR is loaded only there, and a SteamVR
that never answers can't hang the watcher (timeout)."""
try:
p = subprocess.run([sys.executable, os.path.abspath(__file__), "_vr_overlay_round"], capture_output=True,
text=True, errors="replace", timeout=VR_OVERLAY_READY_WAIT + VR_OVERLAY_GRACE + 120)
except subprocess.TimeoutExpired:
return {"*": "timed out"}
try:
out = json.loads(p.stdout.strip().splitlines()[-1])
return out if isinstance(out, dict) else {}
except (ValueError, IndexError):
return {"*": (p.stderr.strip().splitlines() or [f"exit {p.returncode}"])[-1]}
def overlay_save_state(state):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_STATE), exist_ok=True)
with open(VR_OVERLAY_STATE + ".tmp", "w") as f:
json.dump(state, f)
os.replace(VR_OVERLAY_STATE + ".tmp", VR_OVERLAY_STATE)
except OSError:
pass
def vr_overlay_watch():
"""The service's loop (_vr_overlay_watch): a /proc scan every VR_OVERLAY_POLL s. Exits when its own file changes
(an agent update: systemd starts the new one)."""
me = os.path.abspath(__file__)
try:
mine = os.path.getmtime(me)
except OSError:
mine = None
try:
state = json.load(open(VR_OVERLAY_STATE))
state = state if isinstance(state, dict) else {}
except (OSError, ValueError):
state = {}
overlay_log(f"watching (agent v{AGENT_VERSION}, overlay apps with autostart: "
f"{', '.join(p for p, _ in autostart_overlays()) or 'none'})")
while True:
try:
if os.path.getmtime(me) != mine:
overlay_log("agent updated: restarting")
return
except OSError:
return
try:
overlay_watch_tick(state)
except Exception as exc: # noqa: BLE001 (keep watching)
overlay_log(f"error: {exc}")
time.sleep(VR_OVERLAY_POLL)
def cmd_prepare(args):
"""Where to upload, and what the Frame already has (so unchanged data is not re-sent)."""
pkg = check_pkg(args["package"])
@@ -2724,6 +3002,8 @@ def upgrade_launchers():
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
new = set_config_sync_line(new, os.path.basename(os.path.dirname(path)),
wants_config_sync(os.path.dirname(path)))
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
new = upgrade_linux_launcher(new)
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
@@ -3078,7 +3358,7 @@ def upgrade_linux_launcher(text):
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False):
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False, config_sync=False):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
@@ -3087,6 +3367,7 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
video_codec=wrapper_lines(hw_video, gamepad),
plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1])
text = set_config_sync_line(text, pkg, config_sync)
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
@@ -3098,6 +3379,187 @@ def data_files_dir(base, pkg):
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
# ------------------------------------------------- saved game settings in step with the eye buffers (agent v78)
# device.config_sync (per game, from its recipe; deployment.json "config_sync": {file relative to the game's
# Android/data/<pkg>/files: {setting: expression}}). Some games save their render resolution and load it after they
# created the headset's eye buffers: BlazeRush creates 2938x2938 buffers at scale 1.7, then loads its saved
# user_config.xml (r_3dwidth/r_3dheight 1728, r_width/r_height 3072x1728) and draws a displaced, stretched picture with
# black borders (Klownicle's guide, GitHub #57). The values are written while the game is stopped: at finalize, after
# a Game settings change, and by the launcher before Lepton starts (the game rewrites its file when it exits, so a scale
# changed while it ran is caught at the next start). The eye buffer = the runtime's recommended size (learned from the
# game's last launch.log: FrameBridge logs it; older builds: the bridge's stereo= size divided by the logged scale)
# times settings.conf's scale, rounded like FrameBridge does. Only existing numeric entries change; the previous file
# is kept as <file>.frameport-backup and the new one replaces it atomically.
CONFIG_EXPR_RE = re.compile(r"\s*([wh])\s*(?:\*\s*(\d+(?:\.\d+)?))?\s*(?:/\s*(\d+(?:\.\d+)?))?\s*")
CONFIG_SYNC_LINE = 'python3 {agent_q} _config_sync {pkg_q} >"$app_dir/config-sync.log" 2>&1 9>&- || true'
CONFIG_BACKUP = ".frameport-backup"
RUNTIME_EYE_RE = re.compile(r"FrameBridge: runtime recommended eye size (\d+)x(\d+)")
BRIDGE_STEREO_RE = re.compile(r"OVRPortVrApi: Initialized .*?stereo=(\d+)x(\d+)")
ADAPTER_VIEW_RE = re.compile(r"FrameBridge: view 0 recommended (\d+)x(\d+)")
ADAPTER_SCALE_RE = re.compile(r"FrameBridge: scale=([0-9.]+) ")
def config_value(expr, w, h):
"""An expression of the eye-buffer size: w, h, optionally *a and/or /b (for example h*16/9), rounded."""
m = CONFIG_EXPR_RE.fullmatch(str(expr))
if not m:
raise AgentError(f"bad config_sync expression {expr!r}")
value = (w if m.group(1) == "w" else h) * float(m.group(2) or 1) / float(m.group(3) or 1)
if not 0 < value < 1e6:
raise AgentError(f"config_sync expression {expr!r} out of range")
return int(value + 0.5)
def settings_scale(base):
"""FrameBridge's resolution scale for this game (settings.conf; out-of-range values are ignored like FrameBridge
does)."""
scale = 1.0
try:
for line in open(os.path.join(base, "settings.conf")):
k, _, v = line.strip().partition("=")
if k == "scale":
try:
value = float(v)
except ValueError:
continue
if 0.5 <= value <= 2.0:
scale = value
except OSError:
pass
return scale
def runtime_eye_size(base):
"""(width, height) the Frame's runtime recommends per eye before FrameBridge's scale, from the game's last
launch.log / session.log, or None."""
for name in ("launch.log", "session.log"):
try:
with open(os.path.join(base, name), errors="replace") as f:
text = f.read(16 << 20)
except OSError:
continue
found = RUNTIME_EYE_RE.findall(text)
if found:
return int(found[-1][0]), int(found[-1][1])
scales = ADAPTER_SCALE_RE.findall(text)
sized = BRIDGE_STEREO_RE.findall(text) or ADAPTER_VIEW_RE.findall(text)
if sized and scales:
try:
scale = float(scales[-1])
except ValueError:
continue
if 0.5 <= scale <= 2.0:
return int(int(sized[-1][0]) / scale + 0.5), int(int(sized[-1][1]) / scale + 0.5)
return None
def sync_config_text(text, values):
"""(new text, {name: (old, new)}, [names not found]) with the numeric value of every named setting replaced; the
rest of the text stays byte for byte. Matches <x name="NAME" ... value="V">, <x value="V" ... name="NAME">,
<NAME value="V"/> and <NAME>V</NAME>."""
changed, missing = {}, []
num = r"-?\d+(?:\.\d+)?"
for name, new in values.items():
n = re.escape(name)
patterns = (rf"""(<[^<>]*\bname\s*=\s*["']{n}["'][^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<[^<>]*?\bvalue\s*=\s*["'])({num})(["'][^<>]*\bname\s*=\s*["']{n}["'])""",
rf"""(<{n}\b[^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<{n}>\s*)({num})(\s*</{n}>)""")
for pattern in patterns:
m = re.search(pattern, text)
if m:
old = m.group(2)
if old != str(new):
text = text[:m.start(2)] + str(new) + text[m.end(2):]
changed[name] = (old, str(new))
break
else:
missing.append(name)
return text, changed, missing
def sync_game_config(pkg, dep, check_running=True):
"""Write the eye-buffer size into the game's saved settings (device.config_sync). Returns a report (None when the
game has no rules)."""
rules = (dep or {}).get("config_sync") or {}
if not isinstance(rules, dict) or not rules:
return None
base = dep["base"]
if check_running and dep.get("appid") and container_running(dep["appid"]):
return {"skipped": "the game is running (its next start corrects it)"}
runtime = runtime_eye_size(base)
if runtime and runtime != tuple(dep.get("eye_base") or ()):
dep["eye_base"] = list(runtime)
try:
write_deployment(pkg, dep)
except OSError:
pass
runtime = runtime or tuple(dep.get("eye_base") or ())
if len(runtime) != 2:
return {"skipped": "the Frame's eye-buffer size isn't known yet (it is after the game's first start)"}
scale = settings_scale(base)
w, h = int(runtime[0] * scale + 0.5), int(runtime[1] * scale + 0.5)
files_dir = os.path.realpath(data_files_dir(base, pkg))
out = {"eye": [w, h], "runtime": list(runtime), "scale": scale, "changed": {}, "missing": [], "files": []}
for rel, exprs in rules.items():
target = os.path.realpath(os.path.join(files_dir, str(rel)))
if not target.startswith(files_dir + os.sep) or not isinstance(exprs, dict):
out["missing"].append(str(rel))
continue
if not os.path.isfile(target):
out["files"].append({"file": rel, "exists": False}) # the game writes it itself, from the right size
continue
values = {str(k): config_value(v, w, h) for k, v in exprs.items()}
with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
text = f.read()
new, changed, missing = sync_config_text(text, values)
out["missing"] += [f"{rel}:{k}" for k in missing]
out["files"].append({"file": rel, "exists": True, "changed": sorted(changed)})
if not changed:
continue
shutil.copy2(target, target + CONFIG_BACKUP)
tmp = target + ".frameport-tmp"
with open(tmp, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
f.write(new)
shutil.copymode(target, tmp)
os.replace(tmp, target)
out["changed"].update(changed)
return out
def wants_config_sync(anchor):
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
return isinstance(dep, dict) and isinstance(dep.get("config_sync"), dict) and bool(dep["config_sync"])
def set_config_sync_line(text, pkg, want):
"""A Lepton launcher that brings the game's saved settings in step before Lepton starts (want), or without it."""
text = "".join(line for line in text.splitlines(True) if " _config_sync " not in line)
if not want or "\nsetsid " not in text:
return text
line = CONFIG_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
return text.replace("\nsetsid ", f"\n{line}\nsetsid ", 1)
def config_sync_worker(pkg):
"""launch.sh, before Lepton starts (the game isn't running: the launcher holds its launch lock)."""
dep = deployment(check_pkg(pkg))
print(json.dumps({"time": time.time(), "result": sync_game_config(pkg, dep, check_running=False) if dep else None}))
def cmd_config_sync(args):
"""Bring a game's saved settings in step with the eye buffers now (device.config_sync), unless it runs."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
return sync_game_config(pkg, dep)
def cmd_video_codec_status(args):
"""The shared codec installed on this Frame ({digest, revision}, {} if none or damaged) and whether FramePort's
setting switched it off for every game ("disabled")."""
@@ -3291,19 +3753,25 @@ def cmd_finalize(args):
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
if gamepad:
ensure_podman_wrapper()
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad)
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
previous = deployment(pkg) or {}
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
"apk": args.get("apk_name", "game.apk"),
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "agent_version": AGENT_VERSION,
"time": time.time()}
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
synced = sync_game_config(pkg, dep)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced}
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
@@ -4561,7 +5029,43 @@ def cmd_set_settings(args):
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
return {"settings": current, "controller_models": models}
return {"settings": current, "controller_models": models, "config_sync": sync_game_config(pkg, dep)}
TITLE_MAX = 128
def clean_title(title):
"""A game's name for its Steam shortcut: one line, no control characters, at most TITLE_MAX characters."""
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
if not text:
raise AgentError("the new name is empty")
return text[:TITLE_MAX]
def cmd_rename(args):
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
tests and the game's container stay the same. shortcuts (default on) rewrites the library entry like an install
does (Steam restarts once, after a running game or Desktop Mode). A devkit entry is named by its id: unchanged."""
pkg = check_pkg(args["package"])
title = clean_title(args.get("title"))
dep = deployment(pkg)
if not dep or not dep.get("appid"):
raise AgentError(f"{pkg} is not installed")
old = dep.get("title") or pkg
out = {"package": pkg, "title": title, "old": old, "appid": dep["appid"], "renamed": old != title}
if out["renamed"]:
dep["title"] = title
write_deployment(pkg, dep)
if (dep.get("overlay") or {}).get("registered"): # its SteamVR manifest shows the name too
try:
register_overlay(pkg, bool(dep["overlay"].get("autostart")))
except Exception as exc: # noqa: BLE001 - the shortcut matters more
out["overlay_error"] = str(exc)
out["shortcuts"] = cmd_shortcuts({"packages": [pkg], "restart": True}) \
if args.get("shortcuts", True) and out["renamed"] else {"started": False}
return out
def cmd_uninstall(args):
@@ -4607,6 +5111,8 @@ def cmd_uninstall(args):
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log", "session.log"):
p = os.path.join(anchor, name)
remove_tree(p)
if is_overlay(dep):
overlay_service_quiet() # the last autostart overlay app gone: the service goes too
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
@@ -5430,6 +5936,96 @@ def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES, session_bytes=SHADER_SE
return out
# arm64 syscall numbers seen in stuck threads (/proc/<tid>/syscall gives the number)
ARM64_SYSCALLS = {22: "epoll_pwait", 29: "ioctl", 56: "openat", 63: "read", 64: "write", 73: "ppoll",
98: "futex", 101: "nanosleep", 115: "clock_nanosleep", 203: "connect", 206: "sendto",
207: "recvfrom", 212: "recvmsg", 260: "wait4"}
TCP_STATES = {"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2",
"06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING"}
def _remote_kind(hexaddr):
"""/proc/net/tcp(6) remote address → loopback / local / internet (never the address itself: it would need
redacting, and what matters is whether the game waits on the internet)."""
import ipaddress
try:
raw = bytes.fromhex(hexaddr)
if len(raw) == 4:
ip = ipaddress.IPv4Address(raw[::-1])
else: # four little-endian 32-bit words
ip = ipaddress.IPv6Address(b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)))
ip = ip.ipv4_mapped or ip
except ValueError:
return "?"
if ip.is_unspecified:
return "none"
if ip.is_loopback:
return "loopback"
return "local" if ip.is_private or ip.is_link_local else "internet"
def game_threads(pkg, proc=None, samples=3, gap=1.0, sleep=time.sleep):
"""What a running (e.g. stuck) Android game's threads are doing (GitHub #157): for every process of `pkg` (its
main process and `pkg:<name>` ones), each thread's state, CPU time used over the sampled seconds, kernel wait
channel and current syscall, plus its TCP sockets. Readable as steamos: the container's user namespace belongs to
steamos. None when the game isn't running."""
proc = proc or PROC
pids = []
for d in os.listdir(proc):
if not d.isdigit():
continue
cmd = (_rd(os.path.join(proc, d, "cmdline")) or "").split("\0")[0]
if cmd == pkg or cmd.startswith(pkg + ":"):
pids.append((int(d), cmd))
if not pids:
return None
def cpu(pid):
out = {}
for t in os.listdir(os.path.join(proc, str(pid), "task")) if os.path.isdir(os.path.join(proc, str(pid),
"task")) else []:
st = _rd(os.path.join(proc, str(pid), "task", t, "stat")) or ""
f = st[st.rfind(")") + 2:].split()
if len(f) > 12:
out[t] = (f[0], int(f[11]) + int(f[12]))
return out
first = {pid: cpu(pid) for pid, _ in sorted(pids)}
for _ in range(max(0, samples - 1)):
sleep(gap)
span = max(0, samples - 1) * gap
lines = [f"# threads of the running game {pkg}; cpu_ms = CPU time used in {span:g} s, wchan = kernel wait, "
"syscall = what the thread is blocked in"]
for pid, cmd in sorted(pids):
last = cpu(pid)
lines.append(f"\n## process {cmd} (pid {pid}, {len(last)} threads)")
lines.append(f"{'tid':>7} st {'cpu_ms':>7} {'comm':<16} {'wchan':<24} syscall")
rows = []
for tid, (state, ticks) in last.items():
used = (ticks - first.get(pid, {}).get(tid, (state, ticks))[1]) * 1000 // CLK_TCK
tdir = os.path.join(proc, str(pid), "task", tid)
comm = (_rd(os.path.join(tdir, "comm")) or "?").strip()
wchan = (_rd(os.path.join(tdir, "wchan")) or "?").strip() or "-"
sc = (_rd(os.path.join(tdir, "syscall")) or "?").split()
call = sc[0] if sc else "?"
if call.isdigit():
call = ARM64_SYSCALLS.get(int(call), "syscall " + call)
rows.append((-used, int(tid), f"{tid:>7} {state:>2} {used:>7} {comm:<16} {wchan:<24} {call}"))
lines += [r[2] for r in sorted(rows)]
socks = []
for name in ("tcp", "tcp6"):
for ln in (_rd(os.path.join(proc, str(pid), "net", name)) or "").splitlines()[1:]:
f = ln.split()
if len(f) > 3 and ":" in f[2]:
addr, port = f[2].rsplit(":", 1)
socks.append(f"{name:<4} {TCP_STATES.get(f[3], f[3]):<12} remote={_remote_kind(addr)}"
f":{int(port, 16)}")
if socks:
lines.append("# TCP sockets (remote = loopback / local network / internet, and port)")
lines += sorted(set(socks))
return "\n".join(lines) + "\n"
def cmd_collect_diag(args):
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
@@ -5510,6 +6106,13 @@ def cmd_collect_diag(args):
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
name = os.path.basename(p)
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
if dep.get("kind", "quest") == "quest":
try:
threads = game_threads(pkg, samples=int(args.get("thread_samples", 3)))
except OSError:
threads = None
if threads:
files["threads.txt"] = threads
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)),
int(args.get("shader_session_bytes", SHADER_SESSION_BYTES)))
if shaders:
@@ -5560,6 +6163,14 @@ def purge_worker(payload):
result["removed"].append(f"SteamVR overlay: {d.get('title')}")
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
try: # the overlay autostart service runs this agent: it goes before AGENT_HOME does
if os.path.exists(VR_OVERLAY_UNIT_PATH):
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
os.remove(VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
result["removed"].append(VR_OVERLAY_UNIT_PATH)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"overlay autostart service: {exc}")
try:
service = stop_steam()
except AgentError as exc:
@@ -5993,6 +6604,10 @@ MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor",
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
# game from Steam's store); its title comes from the appmanifest
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
("steam", ("steam", "reaper", "fossilize")),
@@ -6324,6 +6939,7 @@ class Monitor:
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
self.deps = []
self.deps_at = -1e9
self.steam_titles = {}
self.tick = 0
self.last = None # time of the previous sample
self.disk_at = -1e9
@@ -6424,10 +7040,16 @@ class Monitor:
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
if m and m.group(1) in by_appid:
root = (by_appid[m.group(1)]["package"], "container")
else:
elif not any(a in cmd for a in AGENT_FILES):
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
pkg = next((k for path, k in anchors if path in cmd), None) or \
next((k for base, k in bases if base in cmd), None)
root = (pkg, "launcher") if pkg else None
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
root = (STEAM_GAME + m.group(1), "steam")
self.root_cache[key] = root
if self.root_cache[key]:
roots[pid] = self.root_cache[key]
@@ -6505,7 +7127,7 @@ class Monitor:
deps = {d["package"]: d for d in self.deployments(now)}
out = []
for pkg in sorted({v[0] for v in self.roots.values()}):
d = deps.get(pkg) or {}
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
@@ -6525,10 +7147,25 @@ class Monitor:
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
"fps": fps, "frame_ms": ms, "processes": len(pids)})
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
# the game in front first: overlay apps (they run next to games) last, then the most recently started
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
self.games = out
return out
def steam_game(self, appid):
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
if appid not in self.steam_titles:
app = find_app_id(appid)
name = (app or {}).get("name")
if not name:
for user in steam_users():
name = shortcut_names(user).get(appid)
if name:
break
self.steam_titles[appid] = name or f"App {appid}"
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
def filtered(self, now=None):
procs = self.procs
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
@@ -6674,8 +7311,29 @@ class Monitor:
return {"ended": False, "pid": pid, "name": comm}
def steam_app_running(appid):
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
for name in os.listdir(PROC):
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
return True
return False
def monitor_end_game(pkg, wait=6.0):
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
if str(pkg).startswith(STEAM_GAME):
appid = str(pkg)[len(STEAM_GAME):]
if not appid.isdigit():
raise AgentError(f"bad Steam app id {appid!r}")
if not steam_app_running(appid):
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
end = time.monotonic() + wait
while time.monotonic() < end and steam_app_running(appid):
time.sleep(0.5)
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
pkg = check_pkg(pkg)
dep = deployment(pkg)
if not dep:
@@ -6794,6 +7452,12 @@ COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
def main():
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
return keyboard_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_watch":
vr_overlay_watch()
return
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
print(json.dumps(overlay_autostart_round()), flush=True)
return
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
return monitor_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
@@ -6814,6 +7478,9 @@ def main():
if len(sys.argv) >= 4 and sys.argv[1] == "_dashboard_worker":
dashboard_worker(sys.argv[2], sys.argv[3])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_config_sync":
config_sync_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_tools_worker":
tools_worker(sys.argv[2])
return 0
+4 -4
View File
@@ -6,11 +6,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
fc559dbe0b87eecc3641256837293260cefb7c2ce7b21d808d10edd2a2dba9fc ./arm64-v8a/libglshim.so
1655921f4c91f2f336edf0aac66d8df1b2b649a272aafe365c1f2fa7867129de ./arm64-v8a/libopenxr_loader_generic.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
449e13bbe58de6718d4e530177924e09552a95d748a082739899144e2cb3ccf3 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
aa4dc0020c77e12d41ef6ce80b23ad90c9cb7e882338eddf2261efe8645cf38d ./arm64-v8a/libvrapi.so
7943c6825e1c2841e54627f96acbd0754a3c06bb608c47f7aa15007aec2191a5 ./armeabi-v7a/libopenxr_loader_generic.so
1d83eb94ec1df44f29a5f525dce4d5b608dbbb13a9c7a5efeb80a4b1dc54a721 ./arm64-v8a/libvrapi.so
47f736dfeffce6aa56be2fc049c2d11b87b0d1f7389db7ee1737fcded9457811 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -1,9 +1,9 @@
package: com.Armature.VR4
title: VR4
status: works
notes: Installs the no-ForceQuit build and fixes a campaign shader that hung the GPU after the opening cutscene.
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). One fragment shader
reads a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
notes: Installs the no-ForceQuit build and fixes two campaign shaders that hung the GPU at cutscenes.
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). Two permutations of one fragment shader
read a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
no-ForceQuit build is kept. Diagnosed and confirmed on a Frame by the reporter of GitHub issue 10.
tested_version: '2.3'
engine: Unreal
@@ -14,8 +14,8 @@ use_alt: true
adapter:
# one campaign shader reads an uninitialized loop counter and hangs the GPU (black screen, slowing audio, crash);
# the Vulkan shim inserts two OpStores that zero it and its accumulator (from the reporter's capture, GitHub
# issue 10)
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42
# issue 10); a second permutation of the same shader (6604 bytes) hung the GPU at a later cutscene (GitHub #140)
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42;6604:a0e6edd8a8e969acbefe1a193f0bc535d8c0c18d004def4c361840a9f2601f13:2956:0x0003003e,122,18,0x0003003e,135,42
verified:
date: '2026-10-02'
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
@@ -24,5 +24,5 @@ verified:
frame_build: '20260930.6234839'
agent: 26
issue: 1
updated: '2026-10-04'
updated: '2026-10-11'
source_hint: Resident Evil 4
@@ -0,0 +1,19 @@
package: com.SchellGames.DieScreaming
title: SilentSlayer
status: works
tested_version: 1.1.2+323863s
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: 5cb085909f9a54488543102242f19c5b2f176a1439659a29a9cca04362601f68
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 176
source_hint: Silent Slayer - Vault of the Vampire
@@ -0,0 +1,25 @@
package: com.WhiteDoorGames.Cosmodread
title: Cosmodread
status: works
notes: First run wouldn't allow you to interact with the "New Game" button. Restarting it fixed it.
tested_version: 1.0.0.28
engine: Unity
xr: VrApi
frame:
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: 44911a4607d0de2770705eefb6339a4c7353cb5dfb49f389d6731edbb9cbea92
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 174
source_hint: Cosmodread
@@ -5,6 +5,8 @@ notes: Needed the LAUNCHER category fix.
details: Source APK is a community 'patch+savefix+90Hz' build. Shares Vader Immortal's Unreal 4 Oculus input (GitHub
49) - thumbs follow the touches (frame.unreal_thumb_touch) and controller poses are located at the right time
(pose_time_fix); added 2026-10-09 from Vader's headset results, not yet checked in this game.
sync_guard added 2026-10-11 for a vrclient xrSyncActions crash on the loading splash after the intro (GitHub
#188; the same runtime race as Myst).
tested_version: '1.0'
engine: Unreal
xr: VrApi
@@ -14,10 +16,11 @@ frame:
- frame.unreal_thumb_touch
adapter:
pose_time_fix: 1
sync_guard: 1
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 4ce6e3563da131e288645f4b62a634ac98043e55bc42b7c0ad5f461207d5f53c
updated: '2026-10-09'
updated: '2026-10-11'
source_hint: Robo Recall- Unplugged
@@ -0,0 +1,24 @@
package: com.drawandcode.TaskmasterVR
title: Taskmaster
status: works
notes: Launched and played a few tasks. Seems to work pretty much flawlessly other than some occasionally finicky
grip detection - very possible that this is just the game itself though
tested_version: 1.0.17557.29562
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: e3b3c49d77284afb76e9f960828cbfaa4250d66c11b7fd9e4053d489aca662ae
app: 1.0.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 78
issue: 183
source_hint: Taskmaster VR
@@ -0,0 +1,20 @@
package: com.innerspacevr.afishermanstale
title: Fisherman
status: issues
notes: Loading screens have corruption, but everything works otherwise.
tested_version: '1.087'
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: c645ca6321845bd460b4ff1b8ac871e941adb26d280cccba0285e79e4a96a231
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 175
source_hint: A Fisherman's Tale
@@ -1,13 +1,12 @@
package: com.owlchemylabs.jobsimulator
title: Job Simulator
status: works
notes: 'Without Lepton''s Android window: with it, the hands froze after a few seconds (GitHub #193).'
tested_version: 1.4.0.6515
engine: Unity
xr: OpenXR
frame:
frame_remove:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
@@ -16,4 +15,5 @@ verified:
frame_build: '20260930.6234839'
agent: 33
issue: 11
updated: '2026-10-11'
source_hint: Job Simulator
@@ -1,14 +1,13 @@
package: com.owlchemylabs.vacationsimulator
title: Vacation Simulator
status: issues
notes: Controllers aren't tracked (reported; not yet diagnosed).
status: works
notes: 'Without Lepton''s Android window: with it (or after clicking it), the hands stayed on the floor (GitHub
#163).'
tested_version: 1.3.0.40142
engine: Unity
xr: OpenXR
frame:
frame_remove:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
refresh_rate: 90.0
@@ -20,4 +19,5 @@ verified:
frame_build: '20261007.6125817'
agent: 59
issue: 163
updated: '2026-10-11'
source_hint: com.owlchemylabs.vacationsimulator
+24
View File
@@ -0,0 +1,24 @@
package: com.schellgames.ieytd3
title: IEYTD3
status: works
tested_version: 1.0.1.211419 (ReleaseCandidate_1.0.0_QuestStore)
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: 06f901ae22f7c07a79e11fc1af6b3941a51bbc99624336d6a0939f9593f3bfa0
app: 0.12.1.dev263
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 78
issue: 181
source_hint: 2026-10-08T23-57-41.310Z_211419
@@ -0,0 +1,18 @@
package: com.square_enix.android_meta.trianglexr
title: TRIANGLE STRATEGY
status: works
notes: Works in XR, with and without Arcturus Vision color (switched while playing).
tested_version: '1.0'
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
known_good_sha256: 86e8297adeb4056eecfbc175438ef25387df67dd0a15ae79afbe6f2e67319a0c
app: 0.12.0
overport_cli: 1.2.5
issue: 171
source_hint: com.square_enix.android_meta.trianglexr
@@ -0,0 +1,17 @@
package: org.timecrisis2.arcadevr
title: Time Crisis II VR (Test)
status: works
tested_version: 0.2.0
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-10'
known_good_sha256: 7829a4cb84298a03ebe539b893916a962aecc4119f189f9fa9ceeab24c747bc5
app: 0.12.0
overport_cli: 1.2.3
frame_build: '20261007.6125817'
agent: 59
issue: 173
source_hint: TimeCrisis2VR-v0.2.0-quest3
+31 -8
View File
@@ -1,18 +1,41 @@
package: ru.targem.blazerush
title: BlazeRush
status: unsupported
notes: Starts and reaches the menu room, but the room shows no controllers and ignores all input (it all reaches
the game); no fix yet.
status: works
notes: From Klownicle's guide (GitHub issue 57); FramePort's version verified in a Steam Frame headset (2026-10-11).
Menus and racing controls, car bodies and controllers, particle effects, the full field of view and a sharp,
correctly placed picture.
details: 'Seven fixes from the guide. frame.blazerush (this exact build only) skips the menus'' wait for a Meta avatar,
which never comes on the Frame, so the game creates its own controllers, and gives its shader cache a new folder
(cache/br_shader_v1) so programs compiled before the shader fixes aren''t reused. The GL shim feeds integer vertex
inputs (bone indices) as integers (gl_int_attribs: car bodies and controllers were invisible) and computes the
particle vertex shaders in highp (gl_highp_markers: streaks and flashes). The game reads the field of view once at
start, before tracking: the VrApi bridge answers with the FOV of the previous session (FramePort''s launch test
after the install is one), not 90x90 degrees. scale 1.7 keeps the wider view sharp (about 2.9x the pixels).
device.config_sync keeps the resolution saved in user_config.xml at the eye-buffer size, else the picture is
displaced with black borders after a scale change.'
tested_version: 1.0.349
engine: Other
xr: VrApi
frame:
- frame.vrapi_bridge
- frame.avatar_stub
- frame.gl_shim
- frame.blazerush
adapter:
scale: 1.7
gl_int_attribs: 1
gl_highp_markers: in uvec2 inBoneIndices;|uniform InstanceData|void main()
gl_hide_multiview: 0
gl_hide_msrtt: 0
config_sync:
user_config.xml:
r_3dwidth: w
r_3dheight: h
r_width: h*16/9
r_height: h
verified:
date: '2026-10-06'
known_good_sha256: 7cbd5d4252788ccd3c95b5c1841dcc798ea029d687938c024e33bd698ba17671
app: 0.12.0
updated: '2026-10-06'
min_app: 0.12.0
date: '2026-10-11'
issue: 57
updated: '2026-10-11'
min_app: 1.0.0
source_hint: BlazeRush
+48 -2
View File
@@ -74,7 +74,8 @@ signatures:
diagnosis: Android's debug checks stopped the game. CheckJNI is on because the converted app is debuggable, and it aborts on sloppy JNI use.
suggest: [frame.nodebug]
- id: force-quit
pattern: 'System\.exit|ForceQuit|FAndroidMisc::RequestExit'
# not Android's own WebView helper, which calls System.exit at every container boot (GitHub #179)
pattern: '(?m)^(?!.*WebViewLoader).*System\.exit|ForceQuit|FAndroidMisc::RequestExit'
severity: error
diagnosis: The game quit itself right after starting. Usually Unreal's ForceQuit after a failed Quest platform check; the alternate build leaves it out.
suggest: [patch_remove_unreal_force_quit]
@@ -154,6 +155,11 @@ signatures:
severity: fatal
diagnosis: The app crashed at start because the Frame's Android has no clipboard. SDL apps (SDL2 / LÖVE) need Android's clipboard service, which Lepton lacks.
suggest: [frame.sdl_clipboard]
- id: godot-no-clipboard
pattern: 'non-null type android\.content\.ClipboardManager(?:.*\n){1,4}?.*at org\.godotengine\.godot\.Godot\b'
severity: fatal
diagnosis: The app crashed at start because the Frame's Android has no clipboard. Godot 4 apps (Godot 4.2 to 4.4) expect Android's clipboard service, which Lepton lacks.
suggest: [frame.godot_clipboard]
- id: controller-profile-rejected
pattern: 'PATH_UNSUPPORTED.*xrSuggestInteractionProfileBindings|xrSuggestInteractionProfileBindings.*(-48|PATH_UNSUPPORTED)'
severity: info
@@ -193,6 +199,20 @@ signatures:
diagnosis: Meta's avatar library stopped the game because it needs Meta's Horizon app. A do-nothing stand-in lets the game run without Meta avatars (for example BlazeRush).
suggest: [frame.avatar_stub]
supersedes: [native-crash, java-crash]
- id: vrapi-fov-before-tracking
pattern: 'OVRPortVrApi: FOV properties: [0-9.]+ x [0-9.]+ degrees \(default, nothing known before tracking\)'
severity: info
diagnosis: The game read the field of view right after starting VrApi, before the Frame's runtime could tell it, and
got 90x90 degrees (a narrow, square view if it keeps that value, for example BlazeRush). The VrApi bridge saves the
headset's real FOV for the next start (framebridge-vrapi-fov.txt in the game's files); start the game once more.
suggest: []
- id: gl-integer-attribs
pattern: 'GLShim: int attribs: program \d+ location \d+: floats -> glVertexAttribIPointer'
severity: info
diagnosis: The game feeds integer shader inputs (for example bone indices) through glVertexAttribPointer; the GL shim
sets them up as integers (gl_int_attribs), else the Frame's GL driver converts them to floats and skinned models
vanish (for example BlazeRush's cars and controllers).
suggest: []
- id: vrapi-symbol-missing
pattern: 'cannot locate symbol "vrapi_\w+"'
severity: fatal
@@ -288,6 +308,19 @@ signatures:
diagnosis: The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR). The patch makes Vivox's audio-route check return early (voice chat keeps the default route).
suggest: [frame.vivox_audio_route]
supersedes: [java-crash]
- id: unreal-obb-check-stuck
pattern: 'Displayed \S+/\S*DownloaderActivity\b'
# the downloader returned (the engine's main init resumed, OpenXR started) or the game crashed (its own finding)
unless: 'xrCreateInstance|xrCreateSession|FrameBridge: pacing:|Resuming main init|Fatal signal \d+|FATAL EXCEPTION'
severity: fatal
diagnosis: >-
The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never
starts VR (for example Contractors, GitHub #159). Unreal checks that the data file (.obb) has the name and size
this APK expects, and with bVerifyOBBOnStartUp it also reads the whole file to check it; a failed check waits
on an error screen. "Unreal - start without the OBB check" skips the content check. If the game still stops
here, the OBB doesn't match this APK version - copy the APK and the OBB from the same install, add the game
again and reinstall.
suggest: [frame.unreal_skip_obb_check]
- id: native-crash
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
severity: fatal
@@ -308,6 +341,12 @@ signatures:
severity: fatal
diagnosis: 'Games can''t start until the Frame restarts: it ran out of kernel keys. Rootless podman leaks one session keyring per container start; the FramePort agent sets keyring=false in ~/.config/containers/containers.conf, and a reboot frees the leaked keys.'
suggest: []
- id: app-not-started
pattern: 'Target app \(.*\) did not start'
severity: error
diagnosis: Lepton didn't start the game this time, which happens now and then on the first start after an install. Start it again; if it never starts, attach diagnostics.
suggest: []
supersedes: [force-quit]
- id: container-not-started
pattern: "is not a running context|OCI runtime error"
unless: 'Boot complete!' # Lepton 3.x prints the error while its container is still starting, then boots fine
@@ -442,6 +481,13 @@ signatures:
the video was decoded in software instead, which is slow for 4K/8K. Turn off hardware video decoding in Steam's
settings, restart Steam, then start the game again.
suggest: []
# Launch tests run without a worn headset. Some parts give up on tracking after a while and end the VR session; what
# the game does after that (often a crash in the GPU driver while its renderer tears down) never happens in the
# headset. In a launch test that already had paced frames before such a line, a later exit or crash still passes,
# with a note (finding `headless-end`). Play sessions ignore this list.
headless_end:
- label: the VrApi bridge's 30 s tracking deadline
pattern: 'EnterVrMode: no tracked head pose before deadline'
milestones: # progress markers, in order; the furthest one reached is reported
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
@@ -450,7 +496,7 @@ milestones: # progress markers, in order; the furthest one reached is reported
- {id: pcvr-openxr, kind: pcvr, pattern: 'wineopenxr: .*[Ss]ession|LibReviveXR|Revive.*OpenXR|xrCreateSession', label: OpenXR reached}
- {id: pcvr-session, kind: pcvr, pattern: 'xrCreateSession|XR_SESSION_STATE_(READY|SYNCHRONIZED)', label: OpenXR session created}
- {id: container, pattern: 'Boot complete!', label: Lepton container started}
- {id: process, pattern: 'Start proc \d+:', label: App process started}
- {id: process, pattern: 'Start proc \d+:(?!WebViewLoader|com\.android\.|android\.)', label: App process started}
- {id: adapter, pattern: 'FrameBridge: scale=', label: FrameBridge adapter loaded}
- {id: instance, pattern: 'xrCreateInstance result=0', label: OpenXR instance created}
- {id: session, pattern: 'xrCreateSession|session state=1|Created OpenXR session', label: OpenXR session created}
+3
View File
@@ -40,6 +40,9 @@ games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.con
ReviveInjector.txt / steam-<appid>.log / game-log-N.txt (PC VR), files.json (+ missing)
(game-log-N.txt: Unreal Saved/Logs + crash summaries, Unity Player.log / Player-prev.log /
output_log.txt + crash error.log from the Proton prefix, agent v67)
threads.txt (agent v79): saved while a Quest game runs (e.g. hangs), each thread's
state, CPU ms over 2 s, kernel wait and syscall, plus its TCP sockets (remote = loopback /
local / internet + port, no addresses); read once from /proc, the game isn't paused
games/<pkg>/target/shaders/ shader dumps (agent v72), when the game wrote any:
fp_vk_shaders/ adapter vk_shader_dump=1 (Vulkan shim): <size>_<sha256>.spv + index.txt
fp_spirv/ adapter zink_shader_dump=1 (OpenGL ES, shader-fix layer under Zink): <size>_<sha256>.spv
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more