Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423b01ee98 | ||
|
|
93b62fa185 | ||
|
|
fc47dab20b | ||
|
|
58a936c706 | ||
|
|
72f94d1754 | ||
|
|
af6117ad5d | ||
|
|
540c71982f | ||
|
|
daa71a144e | ||
|
|
a64a4c6958 | ||
|
|
c0b3ba960a | ||
|
|
aaf6aba621 | ||
|
|
80ef9ca6a7 | ||
|
|
c744f8740b | ||
|
|
859f921361 | ||
|
|
6acf9d048e | ||
|
|
cf18b30be9 | ||
|
|
eec0218b1d | ||
|
|
526ba0a684 | ||
|
|
23b6ff1b5e | ||
|
|
00c6863e54 | ||
|
|
4863035a05 | ||
|
|
a30162ea63 | ||
|
|
24eaa97fde | ||
|
|
767b152423 | ||
|
|
10e7669f70 | ||
|
|
76c4f2d225 | ||
|
|
3a851e911f | ||
|
|
2912d637b5 | ||
|
|
5c070ccf23 | ||
|
|
b979067215 | ||
|
|
4440b459e5 | ||
|
|
09b8046337 | ||
|
|
0d67335775 | ||
|
|
867b9977d5 | ||
|
|
80092098e0 | ||
|
|
feab03e049 | ||
|
|
9713da222a | ||
|
|
82e617741e | ||
|
|
9de0707070 | ||
|
|
1343930ae0 | ||
|
|
5424e429e4 | ||
|
|
9149eaecd7 | ||
|
|
64278e9e2c | ||
|
|
1531acb575 | ||
|
|
6796e006d9 | ||
|
|
45f05dd3c6 | ||
|
|
5a2cd45fb8 | ||
|
|
757671b389 | ||
|
|
8a2029c079 | ||
|
|
b2688dd368 | ||
|
|
13de114f39 | ||
|
|
9016982394 | ||
|
|
cad2cd366c | ||
|
|
a5820e68d7 | ||
|
|
aff939a6d8 | ||
|
|
5e725fbd08 | ||
|
|
1cb9e6f65b | ||
|
|
7a843962e8 | ||
|
|
51220a4942 | ||
|
|
981daa3c2d | ||
|
|
9781c2bb6b | ||
|
|
a7c349835c | ||
|
|
9a48eac3e6 | ||
|
|
9082c684ed | ||
|
|
84235fbc9f | ||
|
|
8accd6221a | ||
|
|
c37eebb59f | ||
|
|
5b0bd2589b | ||
|
|
231e5e50cd | ||
|
|
423b2e1c3e | ||
|
|
b6c5f7c18e | ||
|
|
74320e607f | ||
|
|
d87e3b3b5f | ||
|
|
c4153be8bc | ||
|
|
b5a16a2680 | ||
|
|
f6870da310 | ||
|
|
6c8cda5d7a | ||
|
|
7331d9320a |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 169 KiB |
@@ -0,0 +1 @@
|
||||
Baseline: the site as it was before the redesign steps (critique 28/40).
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 170 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 171 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
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.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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'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.</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'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.</li><li><b>03-distill-layout</b> — 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.</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 '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.</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'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.</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
|
||||
@@ -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>
|
||||
@@ -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>`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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: <title>"; not with adb |
|
||||
| Android apps, Linux apps, Windows programs | All three; Proton (runs Windows programs) is installed for you | All three; install Proton first | Yes; 2D Android apps need an extra file |
|
||||
| PC VR games | On your PC; some also on the Frame | Not supported | Not supported |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||