Files
Toastconcern--FrameStudio/src/switcher.cpp
T
ToastconcernandClaude Opus 5.5 d8abf2c438 Environment bundles (.fbundle): several .fenv files in one, each its own environment
src/fenv_bundle.h: a ZIP of bundle.json and the .fenv files stored as-is, copied
in pieces (zip64 from the start when 4 GB or more). Write, read the manifest,
read one environment's fenv.json + thumbnail in place, unpack all or one
(.part then rename, checksummed; never replaces a different file: "name 2"),
validate.

fenv-tool bundle / unbundle, and info lists a bundle's environments. The
switcher unpacks bundles dropped into envs/ into their environments and
deletes them (frame-switcher --unpack-bundles does it at once). The installers
copy .fbundle files alongside .fenv; only a .fenv sets the one shown.
tools/bundletest checks it against real environments (--big: past 4 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:26:16 -05:00

2019 lines
111 KiB
C++

// frame-switcher — the Steam Frame's environment switcher: a SteamVR dashboard panel listing every environment
// in ~/frame-home/envs (Frame environments .fenv, home APKs, and footprint + vista pairs from pairs.txt) with
// thumbnails. Point and click one: ~/frame-home/current changes and Frame Home switches to it live. A bundle of
// environments (.fbundle) put there is unpacked into its .fenv files, which the panel then lists one by one.
//
// frame-switcher the dashboard panel (SteamVR starts it: see --install)
// frame-switcher --install register with SteamVR and start it with SteamVR from now on
// frame-switcher --list the environments, the current one marked with *
// frame-switcher --set NAME switch (NAME: the file name, or a number from --list)
// frame-switcher --thumbs render missing thumbnails (frame-home --snapshot) into envs/.thumbs
// frame-switcher --delete NAME delete an environment (like the panel's delete button; NAME as for --set)
// frame-switcher --unpack-bundles unpack the bundles (.fbundle) in envs now (the panel does it by itself)
// frame-switcher --test-delete DIR self-check of deleting, in a fake root made in DIR (must not exist yet)
#include "fenv.h"
#include "fenv_bundle.h"
#include "overlay_texture.h"
#include "overlays.h"
#include "perf_settings.h"
#include "proc_util.h"
#include <openvr.h>
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"
#include <fcntl.h>
#include <signal.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <ctime>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <set>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
extern "C" unsigned char* stbi_load_from_memory(const unsigned char* buffer, int len, int* x, int* y, int* comp, int req_comp);
extern "C" void stbi_image_free(void* retval_from_stbi_load);
#define LOG(...) do { std::fprintf(stderr, "[frame-switcher] " __VA_ARGS__); std::fputc('\n', stderr); } while (0)
namespace fs = std::filesystem;
namespace {
constexpr const char* kAppKey = "framehome.switcher";
constexpr int W = 1280, H = 800;
fs::path rootDir() {
const char* home = std::getenv("HOME");
return fs::path(home && *home ? home : "/home/steamos") / "frame-home";
}
std::string readText(const fs::path& p) {
std::ifstream f(p);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
struct Image { std::vector<uint8_t> rgba; int w = 0, h = 0; };
Image loadImage(const fs::path& p) {
Image img;
std::ifstream f(p, std::ios::binary);
std::vector<uint8_t> b((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (b.size() > 16 && b[0] == 'P' && b[1] == '6') { // frame-home --snapshot writes binary PPM
std::istringstream hdr(std::string(b.begin(), b.begin() + std::min<size_t>(b.size(), 64)));
std::string magic;
int w = 0, h = 0, maxv = 0;
hdr >> magic >> w >> h >> maxv;
const size_t off = static_cast<size_t>(hdr.tellg()) + 1;
if (w > 0 && h > 0 && off + static_cast<size_t>(w) * h * 3 <= b.size()) {
img.w = w; img.h = h;
img.rgba.resize(static_cast<size_t>(w) * h * 4);
for (size_t i = 0; i < static_cast<size_t>(w) * h; ++i) {
img.rgba[i * 4] = b[off + i * 3]; img.rgba[i * 4 + 1] = b[off + i * 3 + 1]; img.rgba[i * 4 + 2] = b[off + i * 3 + 2]; img.rgba[i * 4 + 3] = 255;
}
}
return img;
}
if (b.empty()) return img;
int w = 0, h = 0, c = 0;
if (unsigned char* px = stbi_load_from_memory(b.data(), static_cast<int>(b.size()), &w, &h, &c, 4)) {
img.w = w; img.h = h;
img.rgba.assign(px, px + static_cast<size_t>(w) * h * 4);
stbi_image_free(px);
}
return img;
}
Image decodeBytes(const std::vector<uint8_t>& b) {
Image img;
int w = 0, h = 0, c = 0;
if (!b.empty())
if (unsigned char* px = stbi_load_from_memory(b.data(), static_cast<int>(b.size()), &w, &h, &c, 4)) {
img.w = w; img.h = h;
img.rgba.assign(px, px + static_cast<size_t>(w) * h * 4);
stbi_image_free(px);
}
return img;
}
// ── The environments ──
struct Env {
std::string name, kind; // shown
std::vector<std::string> files; // what goes into `current` (names in envs/)
std::string key; // `current` contents, one file per line
Image thumb;
Image fitted; // thumb scaled to the card (made when first drawn)
};
std::string pretty(std::string stem) {
// Meta's shell homes: "shell-env-vista-calming-quest3s-buck_206.0.0.0.70_974701191" -> "calming"
if (stem.rfind("shell-env-", 0) == 0) {
stem = stem.substr(10);
for (const char* role : {"footprint-", "vista-"})
if (stem.rfind(role, 0) == 0) stem = stem.substr(std::strlen(role));
if (const size_t q = stem.find("-quest"); q != std::string::npos && q > 0) stem = stem.substr(0, q);
}
// package names: "com.oculus.environment.prod.courtyard" -> "courtyard"
if (stem.rfind("com.", 0) == 0 && stem.find_last_of('.') + 1 < stem.size()) stem = stem.substr(stem.find_last_of('.') + 1);
// trailing version numbers: "_206.0.0.0.70_974701191"
for (size_t u; (u = stem.find_last_of('_')) != std::string::npos && u > 0 && u + 1 < stem.size() &&
stem.find_first_not_of("0123456789.", u + 1) == std::string::npos;)
stem.erase(u);
for (char& c : stem) if (c == '_' || c == '-') c = ' ';
if (!stem.empty()) stem[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(stem[0])));
return stem;
}
fs::path thumbPath(const std::string& key) {
std::string n = key;
for (char& c : n) if (c == '\n' || c == '/') c = '+';
return rootDir() / "envs" / ".thumbs" / (n + ".ppm");
}
std::vector<Env> scanEnvs() {
const fs::path envs = rootDir() / "envs";
std::vector<Env> out;
std::error_code ec;
std::vector<fs::path> files;
for (const auto& e : fs::directory_iterator(envs, ec)) {
const std::string ext = e.path().extension().string();
if (e.is_regular_file(ec) && (ext == ".fenv" || ext == ".apk")) files.push_back(e.path());
}
std::sort(files.begin(), files.end());
// Footprint + vista pairs the user (or Frame Studio) listed: "haven2025.apk calming.apk" per line.
std::istringstream pairs(readText(rootDir() / "pairs.txt"));
std::string line;
while (std::getline(pairs, line)) {
std::istringstream l(line);
std::string a, b;
if (!(l >> a >> b) || !fs::exists(envs / a, ec) || !fs::exists(envs / b, ec)) continue;
Env e;
e.name = pretty(fs::path(a).stem().string()) + " + " + pretty(fs::path(b).stem().string());
e.kind = "Footprint + Vista";
e.files = {a, b};
out.push_back(e);
}
for (const fs::path& p : files) {
Env e;
e.files = {p.filename().string()};
if (p.extension() == ".fenv") {
FenvManifest m;
std::vector<uint8_t> thumb;
if (!fenvReadManifest(p.string(), m, &thumb)) continue;
e.name = m.name.empty() ? pretty(p.stem().string()) : m.name;
e.kind = m.sources.size() > 1 ? "Frame environment, footprint + vista" : "Frame environment";
e.thumb = decodeBytes(thumb);
} else {
e.name = pretty(p.stem().string());
const std::string fn = p.filename().string();
e.kind = fn.find("-vista-") != std::string::npos ? "Meta home, vista only" : fn.find("-footprint-") != std::string::npos ? "Meta home, footprint only" : "Meta home";
}
out.push_back(e);
}
for (Env& e : out) {
for (const auto& f : e.files) e.key += f + "\n";
if (e.thumb.rgba.empty()) e.thumb = loadImage(thumbPath(e.key));
}
return out;
}
std::string currentKey() {
std::istringstream lines(readText(rootDir() / "current"));
std::string key, n;
while (std::getline(lines, n)) if (!n.empty()) key += n + "\n";
return key;
}
bool setCurrent(const Env& e) {
const fs::path tmp = rootDir() / "current.part";
{
std::ofstream f(tmp);
for (const auto& n : e.files) f << n << "\n";
if (!f) return false;
}
std::error_code ec;
fs::rename(tmp, rootDir() / "current", ec); // atomic: Frame Home never reads half a file
if (ec) return false;
LOG("switched to %s", e.name.c_str());
return true;
}
// ── Deleting an environment ──
// Removes the card's files from envs/ (both of a footprint + vista pair), their Frame Studio companions
// (<file>.world.json, <file>.edits.json), the thumbnails naming them, the pairs.txt lines naming them and, for a .fenv,
// its unpack folders in the cache. Never touches anything but plain environment file names directly in envs/. When
// `current` names a deleted file it is moved to another environment first (or emptied when none is left).
// A name in envs/ that may be deleted: a plain .fenv / .apk file name, no path parts.
bool deletableName(const std::string& n) {
if (n.empty() || n.size() > 255 || n[0] == '.' || n.find_first_of("/\\\n\r") != std::string::npos || n.find("..") != std::string::npos) return false;
const std::string ext = fs::path(n).extension().string();
return ext == ".fenv" || ext == ".apk";
}
// Writes `current` (atomically, like setCurrent); an empty list empties it.
bool writeCurrent(const std::vector<std::string>& files) {
const fs::path tmp = rootDir() / "current.part";
{
std::ofstream f(tmp);
for (const auto& n : files) f << n << "\n";
if (!f) return false;
}
std::error_code ec;
fs::rename(tmp, rootDir() / "current", ec);
return !ec;
}
// The .fenv cache entries of one envs/ file: "<stem>-<path tag>-<size>-<time>" folders and "<stem>-<path tag>.lock".
// The tag hashes the full path, so the name must be computed while the file still exists.
struct CacheDoomed { fs::path root; std::string key; };
CacheDoomed cacheEntriesOf(const fs::path& file) {
return {fs::u8path(fenvCacheDir()), file.stem().u8string() + "-" + fenv_detail::pathTag(file)};
}
int removeCacheEntries(const CacheDoomed& c) {
if (c.key.size() < 10) return 0;
const auto digits = [](std::string t) {
if (!t.empty() && t[0] == '-') t.erase(0, 1);
return !t.empty() && t.find_first_not_of("0123456789") == std::string::npos;
};
std::error_code ec;
int n = 0;
{
// An unpack of this file running right now (the background preparer, Frame Home) finishes first.
const fenv_detail::ExtractLock lock(c.root / (c.key + ".lock"));
std::vector<fs::path> doomed;
for (const auto& e : fs::directory_iterator(c.root, ec)) {
const std::string fn = e.path().filename().u8string();
if (fn.size() <= c.key.size() + 1 || fn.compare(0, c.key.size() + 1, c.key + "-") != 0) continue;
const std::string rest = fn.substr(c.key.size() + 1); // exactly "<size>-<time>"
const size_t dash = rest.find('-');
if (dash == std::string::npos || !digits(rest.substr(0, dash)) || !digits(rest.substr(dash + 1))) continue;
if (!e.is_directory(ec) || e.is_symlink(ec)) continue;
doomed.push_back(e.path());
}
for (const fs::path& d : doomed) if (fs::remove_all(d, ec) > 0 && !ec) ++n;
}
fs::remove(c.root / (c.key + ".lock"), ec);
return n;
}
struct DeleteResult {
bool ok = false;
std::string error;
std::vector<std::string> removed; // what went, relative to ~/frame-home (cache folders: "cache/<name>")
bool currentChanged = false;
std::string newCurrent; // the environment shown now ("" when none is left)
};
// `all` is the list the card came from (where a new current one is picked from). cacheInBackground: the cache
// folders go in a thread of their own (they can be big, and an unpack in progress is waited for).
DeleteResult deleteEnv(const Env& e, const std::vector<Env>& all, bool cacheInBackground) {
DeleteResult r;
const fs::path envs = rootDir() / "envs";
std::error_code ec;
if (e.files.empty()) { r.error = "nothing to delete"; return r; }
for (const auto& f : e.files) {
if (!deletableName(f) || (envs / f).parent_path() != envs) { r.error = "refusing to delete \"" + f + "\""; return r; }
const fs::file_status st = fs::symlink_status(envs / f, ec);
if (!fs::is_regular_file(st) && !fs::is_symlink(st)) { r.error = f + " is not there"; return r; }
}
auto doomed = [&](const std::string& f) { return std::find(e.files.begin(), e.files.end(), f) != e.files.end(); };
// 1. Move `current` off it first, so Frame Home never points at a missing file.
std::vector<std::string> cur;
{
std::istringstream lines(readText(rootDir() / "current"));
for (std::string n; std::getline(lines, n);) if (!n.empty()) cur.push_back(n);
}
if (std::any_of(cur.begin(), cur.end(), doomed)) {
const Env* next = nullptr;
for (const Env& o : all)
if (std::none_of(o.files.begin(), o.files.end(), doomed) &&
std::all_of(o.files.begin(), o.files.end(), [&](const std::string& f) { return fs::exists(envs / f, ec); })) { next = &o; break; }
if (!(next ? setCurrent(*next) : writeCurrent({}))) { r.error = "can't change ~/frame-home/current"; return r; }
r.currentChanged = true;
r.newCurrent = next ? next->name : "";
}
// 2. The files (cache names first: they hash the path of the file as it exists).
std::vector<CacheDoomed> caches;
for (const auto& f : e.files) if (fs::path(f).extension() == ".fenv") caches.push_back(cacheEntriesOf(envs / f));
for (const auto& f : e.files) {
if (!fs::remove(envs / f, ec) || ec) { r.error = "couldn't delete " + f + (ec ? ": " + ec.message() : ""); return r; }
r.removed.push_back("envs/" + f);
for (const char* comp : {".world.json", ".edits.json"})
if (fs::remove(envs / (f + comp), ec)) r.removed.push_back("envs/" + f + comp);
}
// 3. pairs.txt: lines naming a deleted file go (rewritten whole, renamed into place).
std::vector<std::string> pairKeys; // the thumbnails of those pairs go too
{
const std::string text = readText(rootDir() / "pairs.txt");
std::istringstream lines(text);
std::string kept, line;
bool changed = false;
while (std::getline(lines, line)) {
std::istringstream l(line);
std::string a, b;
if ((l >> a >> b) && (doomed(a) || doomed(b))) { pairKeys.push_back(a + "\n" + b + "\n"); changed = true; continue; }
kept += line + "\n";
}
if (changed) {
const fs::path tmp = rootDir() / "pairs.txt.part";
bool ok;
{ std::ofstream o(tmp); o << kept; o.close(); ok = !o.fail(); }
if (ok) fs::rename(tmp, rootDir() / "pairs.txt", ec);
if (!ok || ec) LOG("couldn't update pairs.txt");
else r.removed.push_back("pairs.txt (" + std::to_string(pairKeys.size()) + (pairKeys.size() == 1 ? " line)" : " lines)"));
}
}
// 4. Thumbnails: the card's, each file's own, each pair's that named one.
std::vector<std::string> keys = {e.key};
for (const auto& f : e.files) keys.push_back(f + "\n");
keys.insert(keys.end(), pairKeys.begin(), pairKeys.end());
for (const auto& k : keys) {
const fs::path t = thumbPath(k);
if (t.parent_path() == envs / ".thumbs" && fs::remove(t, ec)) r.removed.push_back("envs/.thumbs/" + t.filename().string());
}
// 5. The unpacked copies (they'd be cleaned up eventually anyway).
if (!caches.empty()) {
if (cacheInBackground) std::thread([caches] { for (const auto& c : caches) removeCacheEntries(c); }).detach();
else for (const auto& c : caches) for (int i = removeCacheEntries(c); i > 0; --i) r.removed.push_back("cache/" + c.key + "-*");
}
LOG("deleted %s (%zu files)", e.name.c_str(), r.removed.size());
r.ok = true;
return r;
}
// ── Drawing (software, into the overlay's RGBA buffer) ──
constexpr float kPi = 3.14159265f;
uint32_t mix(uint32_t a, uint32_t b, float t) {
t = std::clamp(t, 0.0f, 1.0f);
auto ch = [&](int s) { const float x = (a >> s & 255), y = (b >> s & 255); return static_cast<uint32_t>(x + (y - x) * t + 0.5f) << s; };
return ch(16) | ch(8) | ch(0);
}
struct Canvas {
std::vector<uint8_t> px = std::vector<uint8_t>(static_cast<size_t>(W) * H * 4);
int cx0 = 0, cy0 = 0, cx1 = W, cy1 = H; // clip rectangle (exclusive max)
void clip(float x0, float y0, float x1, float y1) {
cx0 = std::clamp(static_cast<int>(x0), 0, W); cy0 = std::clamp(static_cast<int>(y0), 0, H);
cx1 = std::clamp(static_cast<int>(std::ceil(x1)), 0, W); cy1 = std::clamp(static_cast<int>(std::ceil(y1)), 0, H);
}
void noClip() { cx0 = 0; cy0 = 0; cx1 = W; cy1 = H; }
void blend(int x, int y, uint32_t rgb, float a) {
if (x < cx0 || y < cy0 || x >= cx1 || y >= cy1 || a <= 0.002f) return;
uint8_t* p = &px[(static_cast<size_t>(y) * W + x) * 4];
if (a >= 0.998f) { p[0] = rgb >> 16; p[1] = rgb >> 8; p[2] = rgb; p[3] = 255; return; }
const float r = (rgb >> 16 & 255), g = (rgb >> 8 & 255), b = (rgb & 255);
p[0] = static_cast<uint8_t>(p[0] + (r - p[0]) * a + 0.5f);
p[1] = static_cast<uint8_t>(p[1] + (g - p[1]) * a + 0.5f);
p[2] = static_cast<uint8_t>(p[2] + (b - p[2]) * a + 0.5f);
p[3] = 255;
}
// A row of pixels [xa, xb) at y, already inside the clip.
void span(int xa, int xb, int y, uint32_t rgb, float a) {
if (xb <= xa) return;
uint8_t* p = &px[(static_cast<size_t>(y) * W + xa) * 4];
if (a >= 0.998f) {
const uint8_t r = rgb >> 16, g = rgb >> 8, b = rgb;
for (int x = xa; x < xb; ++x, p += 4) { p[0] = r; p[1] = g; p[2] = b; p[3] = 255; }
return;
}
const uint32_t ai = static_cast<uint32_t>(std::clamp(a, 0.0f, 1.0f) * 256.0f), ni = 256 - ai;
const uint32_t r = (rgb >> 16 & 255) * ai, g = (rgb >> 8 & 255) * ai, b = (rgb & 255) * ai;
for (int x = xa; x < xb; ++x, p += 4) {
p[0] = static_cast<uint8_t>((p[0] * ni + r) >> 8); p[1] = static_cast<uint8_t>((p[1] * ni + g) >> 8); p[2] = static_cast<uint8_t>((p[2] * ni + b) >> 8); p[3] = 255;
}
}
void clear(uint32_t rgb) {
for (size_t i = 0; i < px.size(); i += 4) { px[i] = rgb >> 16; px[i + 1] = rgb >> 8; px[i + 2] = rgb; px[i + 3] = 255; }
}
// Coverage of a rounded rectangle at pixel centre (px, py): rt the top corners' radius, rb the bottom ones'.
static float cover(float px, float py, float x0, float y0, float x1, float y1, float rt, float rb) {
const float r = py < (y0 + y1) * 0.5f ? rt : rb;
const float dx = std::max({x0 + r - px, px - (x1 - r), 0.0f}), dy = std::max({y0 + r - py, py - (y1 - r), 0.0f});
float d;
if (dx > 0 && dy > 0) d = std::sqrt(dx * dx + dy * dy) - r;
else { // straight edges: distance to the nearest side
d = std::max({x0 - px, px - x1, y0 - py, py - y1});
}
return std::clamp(0.5f - d, 0.0f, 1.0f);
}
void rect(float x0, float y0, float x1, float y1, float r, uint32_t rgb, float a = 1.0f, float rb = -1) {
if (rb < 0) rb = r;
const int ya = std::max(static_cast<int>(std::floor(y0)), cy0), yb = std::min(static_cast<int>(std::ceil(y1)), cy1);
const int xa = std::max(static_cast<int>(std::floor(x0)), cx0), xb = std::min(static_cast<int>(std::ceil(x1)), cx1);
const float rmax = std::max(r, rb);
for (int y = ya; y < yb; ++y) {
// pixels surely inside skip the distance maths
const bool innerRow = y >= y0 + 1 && y + 1 <= y1 - 1;
const bool midRow = y >= y0 + rmax + 1 && y + 1 <= y1 - rmax - 1;
const float in0 = midRow ? x0 + 1 : x0 + rmax + 1, in1 = midRow ? x1 - 1 : x1 - rmax - 1;
int sa = xb, sb = xb; // the solid span [sa, sb)
if (innerRow) { sa = std::clamp(static_cast<int>(std::ceil(in0)), xa, xb); sb = std::clamp(static_cast<int>(std::floor(in1)), sa, xb); }
for (int x = xa; x < sa; ++x) blend(x, y, rgb, a * cover(x + 0.5f, y + 0.5f, x0, y0, x1, y1, r, rb));
span(sa, sb, y, rgb, a);
for (int x = sb; x < xb; ++x) blend(x, y, rgb, a * cover(x + 0.5f, y + 0.5f, x0, y0, x1, y1, r, rb));
}
}
// An outline `t` thick inside the rounded rectangle's edge.
void ring(float x0, float y0, float x1, float y1, float r, float t, uint32_t rgb, float a = 1.0f) {
const int ya = std::max(static_cast<int>(std::floor(y0)), cy0), yb = std::min(static_cast<int>(std::ceil(y1)), cy1);
const int xa = std::max(static_cast<int>(std::floor(x0)), cx0), xb = std::min(static_cast<int>(std::ceil(x1)), cx1);
const float ri = std::max(0.0f, r - t);
for (int y = ya; y < yb; ++y) {
const bool straightRow = y >= y0 + r + 1 && y + 1 <= y1 - r - 1; // left / right edges only
const bool hollowRow = y >= y0 + t + 1 && y + 1 <= y1 - t - 1;
for (int x = xa; x < xb; ++x) {
if ((straightRow && x >= x0 + t + 1 && x + 1 <= x1 - t - 1) || (hollowRow && x >= x0 + r + 1 && x + 1 <= x1 - r - 1)) {
x = std::max(x, static_cast<int>(straightRow ? x1 - t - 1 : x1 - r - 1) - 1); // skip the hollow middle
continue;
}
const float c = cover(x + 0.5f, y + 0.5f, x0, y0, x1, y1, r, r) - cover(x + 0.5f, y + 0.5f, x0 + t, y0 + t, x1 - t, y1 - t, ri, ri);
blend(x, y, rgb, a * c);
}
}
}
// Vertical fade of one colour, alpha a0 at y0 to a1 at y1.
void fade(float x0, float y0, float x1, float y1, uint32_t rgb, float a0, float a1) {
for (int y = std::max(static_cast<int>(y0), cy0); y < std::min(static_cast<int>(std::ceil(y1)), cy1); ++y) {
const float t = (y + 0.5f - y0) / std::max(1.0f, y1 - y0), a = a0 + (a1 - a0) * t;
if (a > 0.002f) span(std::max(static_cast<int>(x0), cx0), std::min(static_cast<int>(std::ceil(x1)), cx1), y, rgb, a);
}
}
// An image already sized to the box (see fit), with rounded corners; dim < 1 darkens it.
void blit(const Image& img, float x0, float y0, float rt, float rb, float dim = 1.0f) {
if (img.rgba.empty()) return;
const float x1 = x0 + img.w, y1 = y0 + img.h, rmax = std::max(rt, rb);
const int ix0 = static_cast<int>(x0), iy0 = static_cast<int>(y0);
const uint32_t d256 = static_cast<uint32_t>(std::clamp(dim, 0.0f, 1.0f) * 256.0f);
for (int y = std::max(iy0, cy0); y < std::min(iy0 + img.h, cy1); ++y) {
const bool cornerRow = y < y0 + rmax + 1 || y > y1 - rmax - 1;
const uint8_t* row = &img.rgba[static_cast<size_t>(y - iy0) * img.w * 4];
uint8_t* dst = &px[(static_cast<size_t>(y) * W) * 4];
for (int x = std::max(ix0, cx0); x < std::min(ix0 + img.w, cx1); ++x) {
const uint8_t* s = row + (x - ix0) * 4;
const float c = cornerRow ? cover(x + 0.5f, y + 0.5f, x0, y0, x1, y1, rt, rb) : 1.0f;
if (c >= 0.998f) {
uint8_t* p = dst + x * 4;
p[0] = static_cast<uint8_t>((s[0] * d256) >> 8); p[1] = static_cast<uint8_t>((s[1] * d256) >> 8); p[2] = static_cast<uint8_t>((s[2] * d256) >> 8); p[3] = 255;
} else {
blend(x, y, static_cast<uint32_t>(s[0] * dim) << 16 | static_cast<uint32_t>(s[1] * dim) << 8 | static_cast<uint32_t>(s[2] * dim), c);
}
}
}
}
void circle(float cx, float cy, float r, uint32_t rgb, float a = 1.0f) { rect(cx - r, cy - r, cx + r, cy + r, r, rgb, a); }
// A spinner: an arc `span` radians long whose head is at angle `head`, fading out toward its tail.
void arc(float cx, float cy, float r, float t, float head, float span, uint32_t rgb, float a = 1.0f) {
for (int y = static_cast<int>(cy - r - t); y <= static_cast<int>(cy + r + t); ++y)
for (int x = static_cast<int>(cx - r - t); x <= static_cast<int>(cx + r + t); ++x) {
const float dx = x + 0.5f - cx, dy = y + 0.5f - cy, d = std::sqrt(dx * dx + dy * dy);
const float radial = std::clamp(t * 0.5f + 0.5f - std::abs(d - r), 0.0f, 1.0f);
if (radial <= 0) continue;
float rel = std::fmod(head - std::atan2(dy, dx), 2 * kPi);
if (rel < 0) rel += 2 * kPi;
const float along = rel < span ? 1.0f - rel / span : 0.0f;
blend(x, y, rgb, a * radial * (0.12f + 0.88f * along));
}
}
};
// `img` scaled to w x h, cropped to that aspect ("cover"), bilinear.
Image fit(const Image& img, int w, int h) {
Image out;
if (img.rgba.empty() || w <= 0 || h <= 0) return out;
out.w = w; out.h = h;
out.rgba.resize(static_cast<size_t>(w) * h * 4);
const float ba = static_cast<float>(w) / h, ia = static_cast<float>(img.w) / img.h;
float sx0 = 0, sy0 = 0, sw = static_cast<float>(img.w), sh = static_cast<float>(img.h);
if (ia > ba) { sw = img.h * ba; sx0 = (img.w - sw) * 0.5f; } else { sh = img.w / ba; sy0 = (img.h - sh) * 0.5f; }
for (int y = 0; y < h; ++y) {
const float fy = std::clamp(sy0 + (y + 0.5f) / h * sh - 0.5f, 0.0f, img.h - 1.0f);
const int y0 = static_cast<int>(fy), y1 = std::min(y0 + 1, img.h - 1);
const float ty = fy - y0;
for (int x = 0; x < w; ++x) {
const float fx = std::clamp(sx0 + (x + 0.5f) / w * sw - 0.5f, 0.0f, img.w - 1.0f);
const int x0 = static_cast<int>(fx), x1 = std::min(x0 + 1, img.w - 1);
const float tx = fx - x0;
for (int c = 0; c < 4; ++c) {
auto at = [&](int xx, int yy) { return static_cast<float>(img.rgba[(static_cast<size_t>(yy) * img.w + xx) * 4 + c]); };
const float v = (at(x0, y0) * (1 - tx) + at(x1, y0) * tx) * (1 - ty) + (at(x0, y1) * (1 - tx) + at(x1, y1) * tx) * ty;
out.rgba[(static_cast<size_t>(y) * w + x) * 4 + c] = static_cast<uint8_t>(v + 0.5f);
}
}
}
return out;
}
struct Font {
std::vector<uint8_t> ttf;
stbtt_fontinfo info{};
float scale = 1, size = 0;
int ascent = 0;
bool ok = false;
struct Glyph { int w = 0, h = 0, xo = 0, yo = 0; float adv = 0; std::vector<uint8_t> bm; };
mutable std::map<int, Glyph> glyphs; // rasterised once
bool load(const char* path, float px) {
std::ifstream f(path, std::ios::binary);
ttf.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (ttf.empty() || !stbtt_InitFont(&info, ttf.data(), 0)) return false;
size = px;
scale = stbtt_ScaleForPixelHeight(&info, px);
int d, g;
stbtt_GetFontVMetrics(&info, &ascent, &d, &g);
ascent = static_cast<int>(ascent * scale);
glyphs.clear();
return ok = true;
}
// Loads the first of `paths` that exists.
bool load(std::initializer_list<const char*> paths, float px) {
for (const char* p : paths) if (load(p, px)) return true;
return false;
}
const Glyph& glyph(int c) const {
auto it = glyphs.find(c);
if (it != glyphs.end()) return it->second;
Glyph g;
int adv, lsb;
stbtt_GetCodepointHMetrics(&info, c, &adv, &lsb);
g.adv = adv * scale;
if (unsigned char* bm = stbtt_GetCodepointBitmap(&info, scale, scale, c, &g.w, &g.h, &g.xo, &g.yo)) {
g.bm.assign(bm, bm + static_cast<size_t>(g.w) * g.h);
stbtt_FreeBitmap(bm, nullptr);
}
return glyphs.emplace(c, std::move(g)).first->second;
}
// UTF-8 decode (enough for names: ASCII, Latin-1, the middle dot, the ellipsis)
static std::vector<int> codes(const std::string& s) {
std::vector<int> out;
for (size_t i = 0; i < s.size();) {
const unsigned char c = s[i];
if (c < 0x80) { out.push_back(c); ++i; }
else if ((c >> 5) == 6 && i + 1 < s.size()) { out.push_back((c & 31) << 6 | (s[i + 1] & 63)); i += 2; }
else if ((c >> 4) == 14 && i + 2 < s.size()) { out.push_back((c & 15) << 12 | (s[i + 1] & 63) << 6 | (s[i + 2] & 63)); i += 3; }
else ++i;
}
return out;
}
float width(const std::vector<int>& cs) const {
if (!ok) return 0;
float w = 0;
for (size_t i = 0; i < cs.size(); ++i) {
w += glyph(cs[i]).adv;
if (i + 1 < cs.size()) w += stbtt_GetCodepointKernAdvance(&info, cs[i], cs[i + 1]) * scale;
}
return w;
}
float width(const std::string& s) const { return width(codes(s)); }
// Draws `s` with its top at y; cut at maxX with an ellipsis.
void draw(Canvas& cv, float x, float y, const std::string& s, uint32_t rgb, float maxX = 1e9f, float alpha = 1.0f) const {
if (!ok) return;
std::vector<int> cs = codes(s);
if (x + width(cs) > maxX) {
const float ell = glyph(0x2026).adv;
std::vector<int> cut;
float w = 0;
for (int c : cs) {
if (x + w + glyph(c).adv + ell > maxX) break;
w += glyph(c).adv;
cut.push_back(c);
}
while (!cut.empty() && cut.back() == ' ') cut.pop_back();
cut.push_back(0x2026);
cs = cut;
}
float pen = x;
const int base = static_cast<int>(std::lround(y)) + ascent;
for (size_t i = 0; i < cs.size(); ++i) {
const Glyph& g = glyph(cs[i]);
const int gx = static_cast<int>(std::lround(pen)) + g.xo;
for (int yy = 0; yy < g.h; ++yy)
for (int xx = 0; xx < g.w; ++xx)
if (const uint8_t v = g.bm[static_cast<size_t>(yy) * g.w + xx]) cv.blend(gx + xx, base + g.yo + yy, rgb, alpha * v / 255.0f);
pen += g.adv;
if (i + 1 < cs.size()) pen += stbtt_GetCodepointKernAdvance(&info, cs[i], cs[i + 1]) * scale;
}
}
// Vertically centred on cy (on the capital letters).
void drawMid(Canvas& cv, float x, float cy, const std::string& s, uint32_t rgb, float maxX = 1e9f, float alpha = 1.0f) const {
draw(cv, x, cy - ascent + size * 0.36f, s, rgb, maxX, alpha);
}
// Word wrap to maxW.
std::vector<std::string> wrap(const std::string& s, float maxW) const {
std::vector<std::string> lines;
std::istringstream in(s);
std::string word, line;
while (in >> word) {
const std::string t = line.empty() ? word : line + " " + word;
if (!line.empty() && width(t) > maxW) { lines.push_back(line); line = word; }
else line = t;
}
if (!line.empty()) lines.push_back(line);
return lines;
}
};
// ── The panel ──
// Colours (sRGB): a calm dark theme with one warm accent.
// Neutral greys; the accent is only ever the one primary action (Start Frame Home when it isn't running). What's
// selected or showing is marked in the text colour, not the accent.
constexpr uint32_t kBg = 0x111214, kSurface = 0x1b1c1f, kSurfaceHi = 0x26282c, kSunken = 0x0c0d0e, kLine = 0x2c2e33,
kText = 0xeceae6, kMuted = 0x9c9a95, kFaint = 0x6c6a66, kAccent = 0xe8a050, kOnAccent = 0x1c1307, kError = 0xd9534f;
constexpr float kR = 8; // corner radius: cards, buttons, controls
// What the Debug page shows: Frame Home's stats.json (every 2 s) and report.json (after each load), as lines.
struct DebugInfo {
std::vector<std::string> now; // how it's running
std::vector<std::string> costly; // GPU cost view: the costliest meshes
std::string home; // the report's summary
std::vector<std::pair<int, std::string>> problems; // (level, what), worst first
bool operator==(const DebugInfo& o) const { return now == o.now && costly == o.costly && home == o.home && problems == o.problems; }
};
DebugInfo readDebugInfo() {
DebugInfo d;
char b[256];
try {
const tinyjson::Value st = tinyjson::parse(readText(rootDir() / "stats.json"));
if (st.isObject() && st.has("budgetMs")) {
const float budget = st["budgetMs"].asFloat();
if (st.has("gpuMs")) {
std::snprintf(b, sizeof b, "GPU %.1f ms of %.1f a frame%s", st["gpuMs"].asFloat(), budget, st["gpuMs"].asFloat() > budget * 1.03f ? " (too slow)" : "");
std::string line = b;
if (st.has("cpuMs")) { // (a Frame Home from before the Debug page doesn't say)
std::snprintf(b, sizeof b, " · CPU %.1f ms · %.0f frames a second", st["cpuMs"].asFloat(), st["fps"].asFloat());
line += b;
}
d.now.push_back(line);
}
if (st.has("draws")) {
std::snprintf(b, sizeof b, "%d draws, %.2f M triangles · %d of %d meshes in view · %d x %d per eye (%.0f%%)",
static_cast<int>(st["draws"].asFloat()), st["triangles"].asFloat() / 1e6, static_cast<int>(st["drawn"].asFloat()),
static_cast<int>(st["meshes"].asFloat()), static_cast<int>(st["width"].asFloat()), static_cast<int>(st["height"].asFloat()),
st["scale"].asFloat() * 100);
d.now.push_back(b);
static const char* fovNames[4] = {"off", "gentle", "medium", "strong"};
const int fl = std::clamp(static_cast<int>(st["foveationLevel"].asFloat()), 0, 3);
std::snprintf(b, sizeof b, "Memory %.0f MB · %d textures still loading · foveation %s%s%s", st["ramMB"].asFloat(),
static_cast<int>(st["texturesLoading"].asFloat()), st.has("foveationLevel") ? fovNames[fl] : "?",
st["loading"].boolVal ? " · loading an environment" : "", st["dashboard"].boolVal ? " · Steam menu open" : "");
d.now.push_back(b);
}
if (st.has("costly"))
for (size_t i = 0; i < st["costly"].size(); ++i) {
std::snprintf(b, sizeof b, "%6.3f ms ", st["costly"][i]["ms"].asFloat());
d.costly.push_back(b + st["costly"][i]["name"].asString());
}
}
} catch (...) {}
try {
const tinyjson::Value r = tinyjson::parse(readText(rootDir() / "report.json"));
if (r.isObject() && r.has("meshes")) {
std::snprintf(b, sizeof b, "%s · %d meshes, %.2f M triangles, %d see-through · %.0f MB of textures · loaded in %.1f s",
r["format"].asString().c_str(), static_cast<int>(r["meshes"].asFloat()), r["triangles"].asFloat() / 1e6,
static_cast<int>(r["seeThrough"].asFloat()), r["textureMB"].asFloat() + r["decodedMB"].asFloat(), r["loadSec"].asFloat());
d.home = r["name"].asString() + "\n" + b;
for (size_t i = 0; i < r["problems"].size(); ++i)
d.problems.push_back({static_cast<int>(r["problems"][i]["level"].asFloat()), r["problems"][i]["what"].asString()});
}
} catch (...) {}
return d;
}
struct Panel {
Canvas cv;
Font title, head, body, label, small, tiny, glyphFont;
std::vector<Env> envs;
std::string current;
std::string toast;
bool toastError = false;
double toastUntil = 0;
int page = 0; // 0 environments, 1 settings, 2 debug
PerfSettings perf;
std::string stats; // a line from Frame Home's stats.json
DebugSettings dbg; // debug.json: the Debug page's views
DebugInfo info; // ...and what it shows of stats.json and report.json
static constexpr int kShotId = 9200, kDebugOffId = 9201;
// Frame Home (frame-home --system), as the panel's loop sees it; the header's button restarts or starts it.
enum class Home { Running, Stopped, TurnedOff, Restarting };
Home home = Home::Running;
bool restartAsked = false; // the button was clicked: the loop restarts or starts it
static constexpr int kHomeId = 9100;
// Layout
static constexpr int kCols = 3, kPad = 40, kTop = 112, kGap = 24, kThumbH = 216, kInfoH = 84, kCardH = kThumbH + kInfoH;
static int cardW() { return (W - kPad * 2 - (kCols - 1) * kGap) / kCols; }
// Clickable things, collected as they're drawn. ids: tabs 9000 + page, settings options row * 100 + option,
// environment cards kCardId + index, and on a card its delete button (kTrashId), and while it asks "Delete?"
// its Delete (kDelId) and Cancel (kCancelId) buttons.
static constexpr int kCardId = 10000, kTrashId = 30000, kDelId = 50000, kCancelId = 70000, kIdSpan = 20000;
static int cardOf(int id) { return id >= kCardId && id < kCancelId + kIdSpan ? (id - kCardId) % kIdSpan : -1; }
struct Hot { float x0, y0, x1, y1; int id; };
std::vector<Hot> hots;
int hotHover = -1;
int hotAt(float x, float y) const {
for (const Hot& h : hots) if (x >= h.x0 && x < h.x1 && y >= h.y0 && y < h.y1) return h.id;
return -1;
}
void addHot(float x0, float y0, float x1, float y1, int id, bool inContent) {
if (inContent) { y0 = std::max(y0, static_cast<float>(kTop)); y1 = std::min(y1, static_cast<float>(H)); }
if (y1 > y0) hots.push_back({x0, y0, x1, y1, id});
}
// The pointer, pressing, dragging and the scroll of each page (pixels, eased toward target).
float mx = -1, my = -1;
bool pointerIn = false, pressed = false, dragging = false;
float pressX = 0, pressY = 0, dragFrom = 0;
int pressHot = -1;
std::vector<std::pair<double, float>> dragTrail; // (time, y) of the last moves: the fling speed
struct Scroll { float pos = 0, target = 0, vel = 0, content = 0; double active = -10; };
// Fling after a drag: speed gain, top speed (px/s) and how fast it slows (1/s; lower coasts further).
// FH_SCROLL_FLING_GAIN / _MAX / _DECAY override.
float kFlingGain = 1.0f, kFlingMax = 12000.0f, kFlingDecay = 2.2f;
Scroll sc[3];
float viewH() const { return static_cast<float>(H - kTop); }
float maxScroll(int p) const { return std::max(0.0f, sc[p].content - viewH()); }
std::vector<float> hoverT; // per card, 0..1, eased
std::string confirmKey; // the card asking "Delete?" (its key; "" none)
double confirmStart = 0;
std::string switchingKey; // the card clicked: a spinner on it until switchUntil
double switchStart = 0, switchUntil = 0;
double now = 0; // seconds, steady clock
bool needRedraw = false; // set by render when what's under the pointer changed
static double clockNow() { return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(); }
// ── Input ──
bool onMove(float x, float y) {
mx = x; my = y; pointerIn = true;
if (pressed && !dragging && std::abs(y - pressY) > 10 && pressY >= kTop) { // a drag, not a click
dragging = true;
pressHot = -1;
pressY = y;
dragFrom = sc[page].pos;
}
if (dragging) {
Scroll& s = sc[page];
float v = dragFrom - (y - pressY);
const float m = maxScroll(page);
if (v < 0) v *= 0.35f; else if (v > m) v = m + (v - m) * 0.35f; // rubber band past the ends
s.pos = s.target = v;
s.vel = 0;
s.active = now;
dragTrail.push_back({clockNow(), y}); // the real time of the move (several can arrive in one poll)
while (dragTrail.size() > 2 && dragTrail.back().first - dragTrail.front().first > 0.1) dragTrail.erase(dragTrail.begin());
hotHover = -1;
return true;
}
const int h = hotAt(x, y);
if (h != hotHover) { hotHover = h; return true; }
return false;
}
bool onDown(float x, float y) {
mx = x; my = y; pointerIn = true;
pressed = true; dragging = false;
pressX = x; pressY = y;
pressHot = hotAt(x, y);
dragTrail.assign(1, {clockNow(), y});
Scroll& s = sc[page];
if (s.vel != 0) { s.vel = 0; s.target = std::clamp(s.pos, 0.0f, maxScroll(page)); } // a press catches a fling
return true;
}
// Returns the id clicked (press and release on the same thing, no drag), else -1.
int onUp(float x, float y) {
const bool wasDrag = dragging;
pressed = dragging = false;
if (wasDrag) {
Scroll& s = sc[page];
if (dragTrail.size() >= 2) {
const auto& a = dragTrail.front();
const auto& b = dragTrail.back();
const double dt = b.first - a.first;
if (dt > 0.008 && clockNow() - b.first < 0.1) s.vel = std::clamp(static_cast<float>(-(b.second - a.second) / dt) * kFlingGain, -kFlingMax, kFlingMax);
}
if (std::abs(s.vel) < 60) s.vel = 0;
s.active = now;
hotHover = hotAt(x, y);
return -1;
}
const int id = hotAt(x, y);
const int was = pressHot;
pressHot = -1;
if (id < 0 && was < 0) confirmKey.clear(); // a click on nothing: the "Delete?" question goes away
return id >= 0 && id == was ? id : -1;
}
void onLeave() {
pointerIn = false;
if (dragging) { dragging = false; sc[page].active = now; }
pressed = false;
pressHot = hotHover = -1;
}
void onScroll(float dy) { // pixels, positive = further down the list
if (dragging) return;
Scroll& s = sc[page];
s.vel = 0;
s.target = std::clamp(s.target + dy, 0.0f, maxScroll(page));
s.active = now;
}
// Advances the animations by dt; true while anything still moves (another frame wanted).
bool tick(double dt) {
bool moving = false;
const float fdt = static_cast<float>(std::min(dt, 0.1));
for (int p = 0; p < 2; ++p) {
Scroll& s = sc[p];
const float m = maxScroll(p);
if (!(dragging && p == page)) {
if (s.vel != 0) { // a fling: coast and slow down
s.target += s.vel * fdt;
s.vel *= std::exp(-fdt * kFlingDecay);
if (std::abs(s.vel) < 20) s.vel = 0;
if (s.target < 0 || s.target > m) { s.target = std::clamp(s.target, 0.0f, m); s.vel = 0; }
s.pos = s.target;
moving = true;
} else if (s.target < 0 || s.target > m) {
s.target = std::clamp(s.target, 0.0f, m);
}
const float d = s.target - s.pos;
if (std::abs(d) > 0.25f) { s.pos += d * (1 - std::exp(-fdt * 32.0f)); moving = true; } // catches up in ~30 ms
else if (d != 0) { s.pos = s.target; moving = true; }
}
if (now - s.active < 1.2) moving = true; // the scroll bar fades out
}
hoverT.resize(envs.size(), 0.0f);
for (size_t i = 0; i < hoverT.size(); ++i) {
const float want = page == 0 && cardOf(hotHover) == static_cast<int>(i) ? 1.0f : 0.0f, d = want - hoverT[i];
if (std::abs(d) > 0.01f) { hoverT[i] += d * (1 - std::exp(-fdt * 20.0f)); moving = true; }
else if (d != 0) { hoverT[i] = want; moving = true; }
}
if (!switchingKey.empty()) { moving = true; if (now >= switchUntil) switchingKey.clear(); }
if (!toast.empty()) { moving = true; if (now >= toastUntil) toast.clear(); }
if (!confirmKey.empty() && now - confirmStart < 0.3) moving = true; // the question fades in
return moving;
}
void say(const std::string& s, bool error = false, double seconds = 2.5) {
toast = s; toastError = error; toastUntil = now + seconds;
}
// ── Settings ──
// Rows 0-4 graphics, 5-9 and 10-11 movement, 12-14 the home, 15-19 the Debug page.
static constexpr int kRowCount = 22;
static constexpr const char* kRows[kRowCount] = {"Resolution", "Anti-aliasing", "Texture filtering", "Foveated rendering", "Foveation strength",
"Walking", "Teleport", "Turning", "Snap turn angle", "Walking speed",
"Comfort vignette", "Seat markers", "Volume", "Animations", "Behind the Steam menu",
"View", "World layer", "See-through surfaces", "Cut-out surfaces", "Sky", "Frame rate", "Keep cool"};
static constexpr const char* kHelp[kRowCount] = {
"Auto keeps every frame on time and sharpens whenever the home is light enough.",
"Smooths jagged edges. 4x looks best; lower is faster.",
"Keeps floors and walls sharp at an angle. Higher looks better.",
"Full detail where you look, coarser toward the edges: frees the GPU for more resolution.",
"How quickly detail falls off away from where you look.",
"Left stick: walk smoothly in the direction you push it.",
"Right stick forward, aim, let go. Instant blinks you there; Slide glides.",
"Right stick left and right.",
"How far each snap turn goes.",
"How fast smooth walking moves you.",
"Darkens the edges of view while the stick moves or turns you. Helps if smooth walking makes you queasy.",
"Markers on the seats near you. Hidden, pointing at a seat still finds it.",
"All of the environment's sound.",
"Hold still stops the environment's moving parts; animated environments then cost less.",
"With the menu open the environment is only its backdrop. Half rate leaves the GPU to the menu and its apps.",
"Wireframe: every triangle edge. Meshes: each in its own colour. GPU cost: green is cheap, red is costly. Overdraw: bright where surfaces pile up.",
"Where you can walk (green), surfaces and colliders, spawns, seats, wall spots and gravity zones.",
"Glass, water, glow and fog. Leave them out to see whether they're what's slow.",
"Leaves, grass and fences. Leave them out to see whether they're what's slow.",
"The sky around the environment.",
"Half draws the environment every other frame and turns it with your head in between, as Meta's home does: each frame gets twice the GPU time.",
"Above this temperature the edges of view give up a little detail at a time until the headset cools (where you look stays sharp). A hot headset slows its GPU."};
std::vector<std::string> options(int row) const {
switch (row) {
case 0: return {"Auto", "80%", "100%", "120%", "140%"};
case 1: return {"Off", "2x", "4x"};
case 2: return {"4x", "8x", "16x"};
case 3: return {"Off", "Fixed", "Eye tracked"};
case 4: return {"Gentle", "Medium", "Strong"};
case 5: return {"Off", "Smooth"};
case 6: return {"Off", "Instant", "Slide"};
case 7: return {"Snap", "Smooth"};
case 8: return {"30°", "45°", "90°"};
case 9: return {"Slow", "Normal", "Fast"};
case 10: return {"Off", "Gentle", "Strong"};
case 11: return {"Shown", "Hidden"};
case 12: return {"Off", "25%", "50%", "75%", "100%"};
case 13: return {"Play", "Hold still"};
case 14: return {"Half rate", "Full rate"};
case 15: return {"Normal", "Wireframe", "Meshes", "GPU cost", "Overdraw"};
case 16: return {"Hidden", "Shown"};
case 20: return {"Full", "Half"};
case 21: return {"Off", "80°", "86°", "90°"};
default: return {"Shown", "Left out"};
}
}
int selected(int row) const {
switch (row) {
case 0: if (perf.autoResolution) return 0; return perf.resolution < 0.9f ? 1 : perf.resolution < 1.1f ? 2 : perf.resolution < 1.3f ? 3 : 4;
case 1: return perf.msaa >= 4 ? 2 : perf.msaa >= 2 ? 1 : 0;
case 2: return perf.anisotropy >= 16 ? 2 : perf.anisotropy >= 8 ? 1 : 0;
case 3: return perf.foveation;
case 4: return perf.foveationLevel - 1;
case 5: return perf.walk;
case 6: return perf.teleport;
case 7: return perf.turn;
case 8: return perf.snapAngle >= 90 ? 2 : perf.snapAngle >= 45 ? 1 : 0;
case 9: return perf.moveSpeed < 1.7f ? 0 : perf.moveSpeed < 2.5f ? 1 : 2;
case 10: return perf.vignette;
case 11: return perf.seatMarkers ? 0 : 1;
case 12: return static_cast<int>(std::lround(perf.volume * 4));
case 13: return perf.animations ? 0 : 1;
case 14: return perf.menuRate;
case 15: return dbg.view;
case 16: return dbg.world;
case 17: return dbg.hideSeeThrough;
case 18: return dbg.hideCutout;
case 20: return perf.frameRate;
case 21: return perf.tempLimit <= 0 ? 0 : perf.tempLimit <= 82 ? 1 : perf.tempLimit <= 88 ? 2 : 3;
default: return dbg.hideSky;
}
}
void choose(int row, int opt) {
switch (row) {
case 0: perf.autoResolution = opt == 0; if (opt > 0) perf.resolution = 0.6f + 0.2f * opt; break;
case 1: perf.msaa = opt == 2 ? 4 : opt == 1 ? 2 : 1; break;
case 2: perf.anisotropy = opt == 2 ? 16 : opt == 1 ? 8 : 4; break;
case 3: perf.foveation = opt; break;
case 4: perf.foveationLevel = opt + 1; break;
case 5: perf.walk = opt; break;
case 6: perf.teleport = opt; break;
case 7: perf.turn = opt; break;
case 8: perf.snapAngle = opt == 2 ? 90 : opt == 1 ? 45 : 30; break;
case 9: perf.moveSpeed = opt == 0 ? 1.4f : opt == 1 ? 2.0f : 3.0f; break;
case 10: perf.vignette = opt; break;
case 11: perf.seatMarkers = opt == 0 ? 1 : 0; break;
case 12: perf.volume = opt * 0.25f; break;
case 13: perf.animations = opt == 0 ? 1 : 0; break;
case 14: perf.menuRate = opt; break;
case 15: dbg.view = opt; break;
case 16: dbg.world = opt; break;
case 17: dbg.hideSeeThrough = opt; break;
case 18: dbg.hideCutout = opt; break;
case 20: perf.frameRate = opt; break;
case 21: perf.tempLimit = opt == 0 ? 0 : opt == 1 ? 80 : opt == 2 ? 86 : 90; break;
default: dbg.hideSky = opt; break;
}
perf.clamp();
}
bool dimmed(int row) const {
return ((row == 4 || row == 21) && perf.foveation == 0) || (row == 8 && perf.turn == 1) || (row == 9 && perf.walk == 0) ||
(row == 10 && perf.walk == 0 && perf.turn == 0 && perf.teleport != 2);
}
// The segmented control's option widths.
std::vector<float> segWidths(const std::vector<std::string>& opts) const {
std::vector<float> w;
for (const auto& o : opts) w.push_back(std::max(label.width(o) + 36.0f, 84.0f));
return w;
}
// One settings row at content y (screen y = y - scroll); returns its height.
float settingsRow(int row, float x0, float x1, float y, bool draw) {
const std::vector<std::string> opts = options(row);
const std::vector<float> ws = segWidths(opts);
float segW = 8;
for (float w : ws) segW += w;
const float textMax = x1 - 28 - segW - 32 - (x0 + 28);
const std::vector<std::string> help = small.wrap(kHelp[row], textMax);
const float h = std::max(96.0f, 30.0f + 30 + help.size() * 26.0f + 18);
if (!draw) return h;
const float sy = kTop + y - sc[page].pos;
if (sy > H || sy + h < kTop) return h;
const bool dim = dimmed(row);
body.draw(cv, x0 + 28, sy + 22, kRows[row], dim ? kMuted : kText, x0 + 28 + textMax);
for (size_t i = 0; i < help.size(); ++i) small.draw(cv, x0 + 28, sy + 58 + i * 26.0f, help[i], dim ? kFaint : kMuted);
// segmented control, right-aligned and centred in the row
const float cy = sy + h * 0.5f, segX = x1 - 28 - segW;
cv.rect(segX, cy - 26, segX + segW, cy + 26, kR, kSunken);
const int sel = selected(row);
float x = segX + 4;
for (size_t o = 0; o < opts.size(); ++o) {
const int id = row * 100 + static_cast<int>(o);
const bool on = static_cast<int>(o) == sel, hov = hotHover == id;
if (on) cv.rect(x, cy - 22, x + ws[o], cy + 22, kR - 2, dim ? 0x3a3b3e : kText);
else if (hov) cv.rect(x, cy - 22, x + ws[o], cy + 22, kR - 2, kSurfaceHi);
const uint32_t tc = on ? (dim ? kText : kBg) : hov ? kText : kMuted;
label.drawMid(cv, x + (ws[o] - label.width(opts[o])) * 0.5f, cy, opts[o], tc);
addHot(x, cy - 24, x + ws[o], cy + 24, id, true);
x += ws[o];
}
return h;
}
// A titled group of rows in one rounded card; returns the content y after it.
float settingsGroup(const char* name, const std::string& sub, const std::vector<int>& rows, float y) {
const float x0 = kPad, x1 = W - kPad;
const float sy = kTop + y - sc[page].pos;
head.draw(cv, x0 + 4, sy, name, kText);
if (!sub.empty()) small.draw(cv, x0 + 4 + head.width(name) + 16, sy + 8, sub, kFaint, x1);
y += 50;
float total = 0;
for (int r : rows) total += settingsRow(r, x0, x1, 0, false);
const float cy0 = kTop + y - sc[page].pos;
cv.rect(x0, cy0, x1, cy0 + total, kR, kSurface);
float ry = y;
for (size_t i = 0; i < rows.size(); ++i) {
if (i) { const float ly = kTop + ry - sc[page].pos; cv.rect(x0 + 28, ly, x1 - 28, ly + 1, 0, kLine); }
ry += settingsRow(rows[i], x0, x1, ry, true);
}
return y + total;
}
void renderSettings() {
float y = 12;
y = settingsGroup("Movement", "Applies right away", {5, 6, 7, 8, 9, 10, 11}, y);
y += 36;
y = settingsGroup("Environment", "Applies right away", {12, 13}, y);
y += 36;
y = settingsGroup("Graphics", stats.empty() ? "Performance and quality" : stats, {0, 20, 1, 2, 3, 4, 21, 14}, y);
y += 20;
const std::vector<std::string> note = small.wrap("Anti-aliasing, texture filtering and turning foveation on or off restart Frame Home (a few seconds).", W - 2 * kPad - 8);
for (const auto& l : note) { small.draw(cv, kPad + 4, kTop + y - sc[1].pos, l, kFaint); y += 26; }
sc[1].content = y + 40;
}
// ── Debug ──
// A titled card of text lines (colour, text), wrapped; returns the content y after it.
float infoCard(const char* name, const std::string& sub, const std::vector<std::pair<uint32_t, std::string>>& lines, float y) {
const float x0 = kPad, x1 = W - kPad, tx0 = x0 + 28, tx1 = x1 - 28;
const float sy = kTop + y - sc[page].pos;
head.draw(cv, x0 + 4, sy, name, kText);
if (!sub.empty()) small.draw(cv, x0 + 4 + head.width(name) + 16, sy + 8, sub, kFaint, x1);
y += 50;
std::vector<std::pair<uint32_t, std::string>> wrapped;
for (const auto& l : lines)
for (const auto& w : small.wrap(l.second, tx1 - tx0)) wrapped.push_back({l.first, w});
const float h = 24 + wrapped.size() * 30.0f + 14;
const float cy0 = kTop + y - sc[page].pos;
cv.rect(x0, cy0, x1, cy0 + h, kR, kSurface);
for (size_t i = 0; i < wrapped.size(); ++i) small.draw(cv, tx0, cy0 + 22 + i * 30.0f, wrapped[i].second, wrapped[i].first, tx1);
return y + h;
}
// A neutral button at content y (left x0); shown dimmed and not clickable when !enabled. Returns its width.
float button(float x0, float y, const char* text, int id, bool enabled) {
const float sy = kTop + y - sc[page].pos, bw = label.width(text) + 40;
const bool hov = enabled && hotHover == id;
cv.rect(x0, sy, x0 + bw, sy + 48, kR, !enabled ? kSurface : hov ? 0x34363b : kSurfaceHi);
label.drawMid(cv, x0 + 20, sy + 24, text, enabled ? kText : kFaint);
if (enabled) addHot(x0, sy, x0 + bw, sy + 48, id, true);
return bw;
}
void renderDebug() {
float y = 12;
y = settingsGroup("Views", "Applies right away; off again when Frame Home restarts", {15, 16}, y);
y += 36;
y = settingsGroup("Leave out", "To find the part of an environment that's slow", {17, 18, 19}, y);
y += 36;
{
std::vector<std::pair<uint32_t, std::string>> l;
for (const auto& n : info.now) l.push_back({kMuted, n});
if (l.empty()) l.push_back({kFaint, "Frame Home hasn't reported yet."});
y = infoCard("Now", "Every 2 seconds", l, y);
}
if (dbg.view == 3) {
y += 36;
std::vector<std::pair<uint32_t, std::string>> l;
for (const auto& c : info.costly) l.push_back({kMuted, c});
if (l.empty()) l.push_back({kFaint, "Measuring…"});
y = infoCard("Costliest meshes", "GPU time each, both eyes", l, y);
}
y += 36;
{
std::vector<std::pair<uint32_t, std::string>> l;
if (info.home.empty()) l.push_back({kFaint, "No report yet: it's written when an environment loads."});
else {
const size_t nl = info.home.find('\n');
l.push_back({kText, info.home.substr(0, nl)});
if (nl != std::string::npos) l.push_back({kMuted, info.home.substr(nl + 1)});
if (info.problems.empty()) l.push_back({kMuted, "Nothing wrong found."});
for (const auto& pr : info.problems)
l.push_back({pr.first >= 2 ? kError : pr.first == 1 ? kText : kMuted, std::string(pr.first >= 2 ? "Broken: " : pr.first == 1 ? "" : "Note: ") + pr.second});
}
y = infoCard("This environment", "Its report (logs/report.txt on the headset)", l, y);
}
y += 28;
float x = kPad;
x += button(x, y, "Save a Picture", kShotId, true) + 16;
button(x, y, "Turn Debug Views Off", kDebugOffId, dbg.any());
y += 48 + 14;
for (const auto& l : small.wrap("Pictures go to ~/frame-home/logs on the headset. GPU cost times every draw, which makes the GPU a little slower while it's on.", W - 2 * kPad - 8)) {
small.draw(cv, kPad + 4, kTop + y - sc[page].pos, l, kFaint);
y += 26;
}
sc[2].content = y + 40;
}
// ── Environments ──
float envContent() const {
const int rows = (static_cast<int>(envs.size()) + kCols - 1) / kCols;
return rows ? 8 + rows * (kCardH + kGap) - kGap + 40 : 0;
}
void card(size_t i) {
Env& e = envs[i];
const int cw = cardW();
const float x0 = static_cast<float>(kPad + (i % kCols) * (cw + kGap));
const float y0 = kTop + 8 + static_cast<float>(i / kCols) * (kCardH + kGap) - sc[0].pos;
const float x1 = x0 + cw, y1 = y0 + kCardH, imgB = y0 + kThumbH;
if (y0 > H + 4 || y1 < kTop - 4) return;
const bool cur = e.key == current, switching = e.key == switchingKey;
const float hv = i < hoverT.size() ? hoverT[i] : 0.0f;
const bool pressing = pressed && pressHot == 10000 + static_cast<int>(i);
cv.rect(x0, y0, x1, y1, kR, mix(kSurface, kSurfaceHi, hv));
if (!e.thumb.rgba.empty()) {
if (e.fitted.w != cw || e.fitted.h != kThumbH) e.fitted = fit(e.thumb, cw, kThumbH);
cv.blit(e.fitted, x0, y0, kR, 0, pressing ? 0.85f : 1.0f);
} else { // no picture yet: a plain panel with the name's first letter
cv.rect(x0, y0, x1, imgB, kR, kSunken, 1.0f, 0);
const std::vector<int> cs = Font::codes(e.name);
std::string letter = cs.empty() ? "?" : std::string(1, static_cast<char>(cs[0] < 0x80 ? std::toupper(cs[0]) : '?'));
glyphFont.draw(cv, (x0 + x1) * 0.5f - glyphFont.width(letter) * 0.5f, (y0 + imgB) * 0.5f - glyphFont.size * 0.5f, letter, kFaint);
}
if (switching) { // switching: darken the picture, spin
cv.rect(x0, y0, x1, imgB, kR, 0x000000, 0.55f, 0);
cv.arc((x0 + x1) * 0.5f, (y0 + imgB) * 0.5f, 22, 4, static_cast<float>((now - switchStart) * 2 * kPi), 4.2f, kText);
}
body.draw(cv, x0 + 20, imgB + 14, e.name, kText, x1 - 18);
if (switching) small.draw(cv, x0 + 20, imgB + 50, "Switching…", kText, x1 - 18);
else if (cur) small.draw(cv, x0 + 20, imgB + 50, "Showing now", kText, x1 - 18);
else small.draw(cv, x0 + 20, imgB + 50, e.kind, kMuted, x1 - 18);
const int ii = static_cast<int>(i);
if (!confirmKey.empty() && e.key == confirmKey) { // "Delete <name>?" over the card, Delete / Cancel
const float a = static_cast<float>(std::clamp((now - confirmStart) / 0.15, 0.0, 1.0));
cv.rect(x0, y0, x1, y1, kR, kSurface, a);
const float tx0 = x0 + 24, tx1 = x1 - 24;
const std::vector<std::string> q = body.wrap("Delete " + e.name + "?", tx1 - tx0);
float ty = y0 + 30;
for (size_t l = 0; l < q.size() && l < 2; ++l, ty += 34) body.draw(cv, tx0, ty, q[l], kText, tx1, a);
std::string what = e.files.size() > 1 ? "Removes both files of the pair from the headset." : "This removes it from the headset.";
if (cur) what += " Another environment will show instead.";
for (const auto& l : small.wrap(what, tx1 - tx0)) { small.draw(cv, tx0, ty + 6, l, kMuted, tx1, a); ty += 26; }
const float by1 = y1 - 22, by0 = by1 - 54, mid = (x0 + x1) * 0.5f;
const bool hc = hotHover == kCancelId + ii, hd = hotHover == kDelId + ii;
cv.rect(tx0, by0, mid - 6, by1, kR, hc ? 0x34363b : kSurfaceHi, a);
label.drawMid(cv, (tx0 + mid - 6) * 0.5f - label.width("Cancel") * 0.5f, (by0 + by1) * 0.5f, "Cancel", kText, 1e9f, a);
cv.rect(mid + 6, by0, tx1, by1, kR, hd ? 0xe46a66 : kError, a);
label.drawMid(cv, (mid + 6 + tx1) * 0.5f - label.width("Delete") * 0.5f, (by0 + by1) * 0.5f, "Delete", 0xffffff, 1e9f, a);
addHot(tx0, by0, mid - 6, by1, kCancelId + ii, true);
addHot(mid + 6, by0, tx1, by1, kDelId + ii, true);
cv.ring(x0, y0, x1, y1, kR, 2, kError, a);
} else {
if (cur) cv.ring(x0 - 3, y0 - 3, x1 + 3, y1 + 3, kR + 3, 2, kText);
else if (hv > 0.01f) cv.ring(x0, y0, x1, y1, kR, 1, 0x5c5e63, hv);
else cv.ring(x0, y0, x1, y1, kR, 1, kLine);
if (hv > 0.01f && !switching) { // the delete button, top right of the picture, while pointed at
const float bx = x1 - 34, by = y0 + 34;
const bool ht = hotHover == kTrashId + ii;
cv.rect(bx - 22, by - 22, bx + 22, by + 22, kR, ht ? kError : 0x0c0d0e, (ht ? 1.0f : 0.7f) * hv);
const uint32_t ic = 0xffffff;
const float ia = (ht ? 1.0f : 0.85f) * hv;
cv.rect(bx - 3, by - 12, bx + 3, by - 9, 1, ic, ia); // handle
cv.rect(bx - 10, by - 9, bx + 10, by - 6.5f, 1, ic, ia); // lid
cv.rect(bx - 7.5f, by - 4.5f, bx + 7.5f, by + 11, 2.5f, ic, ia); // can
for (float sx : {-3.5f, 0.0f, 3.5f}) // its ribs
cv.rect(bx + sx - 0.8f, by - 2, bx + sx + 0.8f, by + 8.5f, 0, ht ? kError : 0x2c2e33, ia);
addHot(bx - 26, by - 26, bx + 26, by + 26, kTrashId + ii, true);
}
}
addHot(x0, y0, x1, y1, kCardId + ii, true);
}
void renderEnvs() {
sc[0].content = envContent();
if (envs.empty()) {
const float cy = kTop + (H - kTop) * 0.42f;
const std::string a = "No environments yet", b = "Send one from Frame Studio: Frame > Install on Steam Frame.";
head.draw(cv, (W - head.width(a)) * 0.5f, cy - 40, a, kText);
small.draw(cv, (W - small.width(b)) * 0.5f, cy + 8, b, kMuted);
return;
}
for (size_t i = 0; i < envs.size(); ++i) card(i);
}
// ── The whole panel ──
void render() {
hots.clear();
cv.noClip();
cv.clear(kBg);
// Content, scrolled, under the header.
cv.clip(0, kTop, W, H);
if (page == 0) renderEnvs(); else if (page == 1) renderSettings(); else renderDebug();
cv.fade(0, H - 28, W, H, kBg, 0.0f, maxScroll(page) - sc[page].pos > 2 ? 0.9f : 0.0f); // more below
cv.fade(0, kTop, W, kTop + 22, 0x000000, sc[page].pos > 1 ? 0.35f : 0.0f, 0.0f); // shadow under the header
scrollbar();
cv.noClip();
// Header: the two pages as tabs (the open one underlined), and on the right Frame Home's state and button.
{
const char* names[3] = {"Environments", "Settings", "Debug"};
const float y0 = 30, y1 = 84, cy = (y0 + y1) * 0.5f;
float x = kPad;
for (int t = 0; t < 3; ++t) {
const bool on = page == t, hov = hotHover == 9000 + t;
const float w = head.width(names[t]);
head.drawMid(cv, x, cy - 2, names[t], on ? kText : hov ? 0xc4c2bd : kFaint);
if (on) cv.rect(x, y1 - 6, x + w, y1 - 3, 1, kText);
addHot(x - 12, y0, x + w + 12, y1, 9000 + t, false);
x += w + 40;
}
homeControl(W - kPad, cy);
cv.rect(0, kTop - 1, W, kTop, 0, kLine, sc[page].pos > 1 ? 1.0f : 0.0f);
}
drawToast();
// What's under the pointer may have moved (scrolling): hover follows on the next frame.
const int h = pointerIn && !dragging ? hotAt(mx, my) : -1;
needRedraw = h != hotHover;
hotHover = h;
}
// Frame Home's state and its button, right-aligned at x1. Running: a neutral Restart (it reloads the home from
// its file). Not running, or turned off by the layer's crash guard: Start / Turn On, the page's one primary
// action. Restarting: shown, not clickable.
void homeControl(float x1, float cy) {
const char* text = "";
const char* button = "";
bool enabled = true, primary = false;
switch (home) {
case Home::Running: text = "Frame Home is running"; button = "Restart Frame Home"; break;
case Home::Stopped: text = "Frame Home isn't running"; button = "Start Frame Home"; primary = true; break;
case Home::Restarting: text = ""; button = "Restarting…"; enabled = false; break;
case Home::TurnedOff: text = "Off: it failed to start twice"; button = "Turn Frame Home On"; primary = true; break;
}
const float bw = label.width(button) + 40, bx0 = x1 - bw, by0 = cy - 24, by1 = cy + 24;
const bool hov = enabled && hotHover == kHomeId;
const uint32_t fill = !enabled ? kSurface : primary ? (hov ? 0xf0b26a : kAccent) : hov ? 0x34363b : kSurfaceHi;
cv.rect(bx0, by0, x1, by1, kR, fill);
label.drawMid(cv, bx0 + 20, cy, button, !enabled ? kFaint : primary ? kOnAccent : kText);
if (enabled) addHot(bx0, by0, x1, by1, kHomeId, false);
if (*text) small.drawMid(cv, bx0 - 20 - small.width(text), cy, text, kFaint);
}
void scrollbar() {
const Scroll& s = sc[page];
const float m = maxScroll(page);
if (m <= 0) return;
const float trackY0 = kTop + 10, trackY1 = H - 10, track = trackY1 - trackY0;
const float len = std::max(56.0f, track * viewH() / s.content);
const float t = std::clamp(s.pos / m, 0.0f, 1.0f);
// past the ends (rubber band) the thumb shrinks
const float over = s.pos < 0 ? -s.pos : s.pos > m ? s.pos - m : 0.0f;
const float l = std::max(24.0f, len - over * 0.5f);
const float y0 = trackY0 + (track - l) * t;
const double since = now - s.active;
const bool hot = since < 0.8 || dragging;
const float a = hot ? 0.55f : since < 1.2 ? 0.55f - static_cast<float>(since - 0.8) / 0.4f * 0.3f : 0.25f;
const float x1 = W - 10, x0 = x1 - (hot ? 8 : 6);
cv.rect(x0, trackY0, x1, trackY1, 4, 0xffffff, 0.05f);
cv.rect(x0, y0, x1, y0 + l, 4, 0xffffff, a);
}
void drawToast() {
if (toast.empty()) return;
const float a = static_cast<float>(std::clamp(std::min((toastUntil - now) / 0.25, 1.0), 0.0, 1.0));
const float tw = body.width(toast) + 56, y1 = H - 24, y0 = y1 - 52;
cv.rect((W - tw) * 0.5f, y0, (W + tw) * 0.5f, y1, kR, toastError ? kError : 0x2c2e33, 0.97f * a);
body.drawMid(cv, (W - tw) * 0.5f + 28, (y0 + y1) * 0.5f, toast, kText, 1e9f, a);
}
// ── Actions ──
void loadFonts() {
const char *bold = "/usr/share/fonts/noto/NotoSans-Bold.ttf", *semi = "/usr/share/fonts/noto/NotoSans-SemiBold.ttf",
*medium = "/usr/share/fonts/noto/NotoSans-Medium.ttf", *regular = "/usr/share/fonts/noto/NotoSans-Regular.ttf",
*dejaB = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", *deja = "/usr/share/fonts/TTF/DejaVuSans.ttf";
title.load({bold, dejaB, deja}, 44);
head.load({bold, semi, dejaB, deja}, 30);
body.load({semi, medium, bold, deja}, 26);
label.load({semi, medium, bold, deja}, 22);
small.load({regular, deja}, 20);
tiny.load({bold, semi, dejaB, deja}, 17);
glyphFont.load({bold, dejaB, deja}, 96);
}
// Saves the settings like Frame Home expects (whole file, renamed into place: it applies them live).
void saveSettings(const PerfSettings& before) {
bool ok;
{
std::ofstream out(rootDir() / "settings.json.part");
out << perf.toJson();
out.close();
ok = !out.fail();
}
std::error_code ec;
if (ok) fs::rename(rootDir() / "settings.json.part", rootDir() / "settings.json", ec);
if (!ok || ec) { perf = before; say("Couldn't save (is ~/frame-home writable?)", true, 3); }
else say(perf.needsRestart(before) ? "Saved · Frame Home restarts with it" : "Saved");
}
// debug.json, the same way (Frame Home applies it within a second).
bool saveDebug(const DebugSettings& before, const char* done) {
bool ok;
{
std::ofstream out(rootDir() / "debug.json.part");
out << dbg.toJson();
out.close();
ok = !out.fail();
}
std::error_code ec;
if (ok) fs::rename(rootDir() / "debug.json.part", rootDir() / "debug.json", ec);
if (!ok || ec) { dbg = before; say("Couldn't save (is ~/frame-home writable?)", true, 3); return false; }
say(done);
return true;
}
// Deletes card i (after "Delete?" was answered), then shows what's left.
void removeEnv(size_t i) {
const Env e = envs[i];
const DeleteResult r = deleteEnv(e, envs, true);
if (!r.ok) { say("Couldn't delete " + e.name + (r.error.empty() ? "" : " (" + r.error + ")"), true, 4); return; }
envs = scanEnvs();
current = currentKey();
hoverT.assign(envs.size(), 0.0f);
if (switchingKey == e.key) switchingKey.clear();
sc[0].content = envContent();
sc[0].target = std::clamp(sc[0].target, 0.0f, maxScroll(0));
hotHover = -1;
say("Deleted " + e.name + (r.currentChanged && !r.newCurrent.empty() ? " · now showing " + r.newCurrent : ""), false, 3);
}
void click(int id) {
if (!confirmKey.empty()) { // asking "Delete?": its buttons answer; a click on the card itself does nothing
const int c = cardOf(id);
const bool same = c >= 0 && static_cast<size_t>(c) < envs.size() && envs[c].key == confirmKey;
if (same && id >= kDelId && id < kDelId + kIdSpan) { removeEnv(static_cast<size_t>(c)); return; }
confirmKey.clear();
if (same) return; // Cancel, or the card
}
if (id == kHomeId) {
if (home != Home::Restarting) restartAsked = true;
return;
}
if (id >= 9000 && id < 9003) {
if (page != id - 9000) { page = id - 9000; hotHover = -1; sc[page].active = now - 10; if (page == 2) info = readDebugInfo(); }
return;
}
if (id >= kTrashId && id < kTrashId + kIdSpan) {
const size_t i = static_cast<size_t>(id - kTrashId);
if (page != 0 || i >= envs.size()) return;
confirmKey = envs[i].key;
confirmStart = now;
return;
}
if (id >= kCardId && id < kCardId + kIdSpan) {
const size_t i = static_cast<size_t>(id - kCardId);
if (page != 0 || i >= envs.size()) return;
const Env& e = envs[i];
if (!switchingKey.empty() && switchingKey == e.key) return;
if (e.key == current && switchingKey.empty()) { say("Already showing " + e.name); return; }
if (!setCurrent(e)) { say("Couldn't switch (is ~/frame-home writable?)", true, 3); return; }
current = currentKey();
// Frame Home fades out, loads and fades in: a few seconds.
switchingKey = e.key;
switchStart = now;
switchUntil = now + 3.2;
return;
}
if (id == kShotId) {
const DebugSettings before = dbg;
dbg.shot = static_cast<int>(std::time(nullptr)); // a request newer than Frame Home's start
saveDebug(before, "Saving a picture to ~/frame-home/logs");
return;
}
if (id == kDebugOffId) {
const DebugSettings before = dbg;
dbg = DebugSettings();
dbg.shot = before.shot;
if (!(dbg == before)) saveDebug(before, "Debug views off");
return;
}
const int row = id / 100;
const bool debugRow = row >= 15 && row <= 19;
if (page == 1 && id >= 0 && row < kRowCount && !debugRow) {
const PerfSettings before = perf;
choose(id / 100, id % 100);
if (!(perf == before)) saveSettings(before);
}
if (page == 2 && id >= 0 && debugRow) {
const DebugSettings before = dbg;
choose(id / 100, id % 100);
if (!(dbg == before)) saveDebug(before, "Applied");
}
}
};
// What Frame Home reports in stats.json (GPU time against the frame budget, the resolution it settled on), as a
// line for the Settings page; empty while it hasn't reported.
std::string statsLine() {
try {
const tinyjson::Value st = tinyjson::parse(readText(rootDir() / "stats.json"));
if (!st.isObject() || !st.has("gpuMs")) return {};
char line[240];
const float gpu = st["gpuMs"].asFloat(), budget = st["budgetMs"].asFloat();
// Over the frame's time, frames come late and the home stutters: say so, and what helps.
std::snprintf(line, sizeof line, "%s%.1f of %.1f ms a frame, %d x %d per eye (%.0f%%), eye tracking %s", gpu > budget * 1.03f ? "Too slow, lower Resolution: " : "",
gpu, budget, static_cast<int>(st["width"].asFloat()), static_cast<int>(st["height"].asFloat()), st["scale"].asFloat() * 100,
!st["foveation"].boolVal ? "not used" : st["eyeTracking"].boolVal ? "on" : "waiting for your eyes");
return line;
} catch (...) {
return {};
}
}
volatile sig_atomic_t g_quit = 0;
// Bundles (.fbundle, fenv_bundle.h) in envs/ become the environments they carry: each is unpacked beside them as
// its own .fenv (an environment already there is kept: the same one is skipped, a different one of the same name
// makes this one "<name> 2.fenv"), then the bundle is deleted, as everything it holds is in envs/ now. One that
// can't be unpacked (damaged, or still being copied in) stays and is tried again once its size or time changes.
void unpackBundles() {
static std::set<std::string> failed; // "<file>|<size>|<time>" of bundles that didn't unpack
std::error_code ec;
const fs::path envs = rootDir() / "envs";
for (const auto& e : fs::directory_iterator(envs, ec)) {
if (!e.is_regular_file(ec) || !isFenvBundlePath(e.path().string())) continue;
const std::string state = e.path().filename().string() + "|" + std::to_string(e.file_size(ec)) + "|" +
std::to_string(static_cast<long long>(e.last_write_time(ec).time_since_epoch().count()));
if (failed.count(state)) continue;
std::vector<std::string> unpacked;
std::string err;
const auto t0 = std::chrono::steady_clock::now();
if (!fenvBundleExtract(e.path().string(), envs.string(), unpacked, err)) {
LOG("can't unpack the bundle %s: %s", e.path().filename().c_str(), err.c_str());
failed.insert(state);
continue;
}
fs::remove(e.path(), ec);
LOG("unpacked the bundle %s into %zu environments (%.1f s)", e.path().filename().c_str(), unpacked.size(),
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count());
}
}
// New environments are unpacked into the .fenv cache in the background as soon as they appear (a big one takes
// ~10 s on the Frame), so switching to one is as quick as switching back to it. Bundles are opened up first.
void prepareEnvironments() {
static std::atomic<bool> busy{false}, again{false};
again = true; // files added while a run is going get a run of their own
if (busy.exchange(true)) return;
std::thread([] {
setpriority(PRIO_PROCESS, 0, 15);
for (;;) {
while (again.exchange(false)) try {
unpackBundles();
std::error_code ec;
for (const auto& e : fs::directory_iterator(rootDir() / "envs", ec)) {
if (e.path().extension() != ".fenv") continue;
FenvManifest m;
std::vector<std::string> sources, models;
std::string edits, err;
const auto t0 = std::chrono::steady_clock::now();
if (!fenvExtract(e.path().string(), fenvCacheDir(), m, sources, models, edits, err)) { LOG("can't unpack %s: %s", e.path().filename().c_str(), err.c_str()); continue; }
const double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
if (sec > 0.5) LOG("unpacked %s ahead of time (%.1f s)", e.path().filename().c_str(), sec);
}
} catch (const std::exception& ex) { LOG("unpacking ahead of time stopped: %s", ex.what()); } // a damaged file must not kill the panel
busy = false;
if (!again || busy.exchange(true)) break; // asked again just as this run ended: go round once more
}
}).detach();
}
int runPanel() {
signal(SIGINT, [](int) { g_quit = 1; });
signal(SIGTERM, [](int) { g_quit = 1; });
vr::EVRInitError ierr = vr::VRInitError_None;
vr::VR_Init(&ierr, vr::VRApplication_Overlay);
if (ierr != vr::VRInitError_None) { LOG("VR_Init: %s", vr::VR_GetVRInitErrorAsEnglishDescription(ierr)); return 1; }
vr::VROverlayHandle_t ov = vr::k_ulOverlayHandleInvalid, icon = vr::k_ulOverlayHandleInvalid;
vr::EVROverlayError oe = vr::VROverlay()->CreateDashboardOverlay(kAppKey, "Environments", &ov, &icon);
if (oe != vr::VROverlayError_None) { LOG("CreateDashboardOverlay: %s", vr::VROverlay()->GetOverlayErrorNameFromEnum(oe)); vr::VR_Shutdown(); return 1; }
vr::VROverlay()->SetOverlayWidthInMeters(ov, 2.4f);
vr::VROverlay()->SetOverlayInputMethod(ov, vr::VROverlayInputMethod_Mouse);
const vr::HmdVector2_t mouseScale = {{static_cast<float>(W), static_cast<float>(H)}};
vr::VROverlay()->SetOverlayMouseScale(ov, &mouseScale);
{ // dashboard tab icon: a small orange tile
std::vector<uint8_t> ic(64 * 64 * 4);
for (int y = 0; y < 64; ++y) for (int x = 0; x < 64; ++x) {
uint8_t* p = &ic[(y * 64 + x) * 4];
const bool on = (x > 10 && x < 54 && y > 14 && y < 50) && !(x > 16 && x < 48 && y > 20 && y < 44 && (y - 20) * 32 > (x - 16) * 24);
p[0] = on ? 240 : 0; p[1] = on ? 148 : 0; p[2] = on ? 58 : 0; p[3] = on ? 255 : 0;
}
vr::VROverlay()->SetOverlayRaw(icon, ic.data(), 64, 64, 4);
}
Panel panel;
panel.loadFonts();
std::string lastScan;
auto rescan = [&]() {
std::error_code ec;
std::string sig;
for (const auto& e : fs::directory_iterator(rootDir() / "envs", ec)) sig += e.path().filename().string() + std::to_string(static_cast<long long>(fs::last_write_time(e.path(), ec).time_since_epoch().count()));
for (const auto& e : fs::directory_iterator(rootDir() / "envs" / ".thumbs", ec)) sig += e.path().filename().string() + std::to_string(static_cast<long long>(fs::last_write_time(e.path(), ec).time_since_epoch().count()));
sig += readText(rootDir() / "pairs.txt") + "|" + currentKey();
if (sig == lastScan) return false;
lastScan = sig;
prepareEnvironments();
panel.envs = scanEnvs();
panel.current = currentKey();
return true;
};
rescan();
panel.perf.fromJson(readText(rootDir() / "settings.json"));
panel.dbg.fromJson(readText(rootDir() / "debug.json"));
// With the Steam menu open its controllers are the menu's. Earlier versions took the sticks through SteamVR's
// overlay input override, which stopped the pointer clicking the dock: turn it back off.
{
vr::EVRSettingsError se = vr::VRSettingsError_None;
if (vr::VRSettings()->GetBool(vr::k_pch_SteamVR_Section, "globalActionSetPriority", &se)) {
vr::VRSettings()->SetBool(vr::k_pch_SteamVR_Section, "globalActionSetPriority", false, &se);
LOG("turned SteamVR's overlay input override (globalActionSetPriority) back off");
}
}
// Frame Home is the system environment: the compositor layer (layer/frame_home_layer.cpp) draws it in Aurora's
// place, and frame-home --system renders it. This panel starts with SteamVR, so it keeps that process running
// (restarting it if it stops) and makes sure the old way, Frame Home as SteamVR's home app, is off: no app, no
// dock entry, and Aurora's slot is where the home shows.
{
vr::EVRSettingsError se = vr::VRSettingsError_None;
if (vr::VRSettings()->GetBool(vr::k_pch_SteamVR_Section, "enableHomeApp", &se)) {
vr::VRSettings()->SetBool(vr::k_pch_SteamVR_Section, "enableHomeApp", false, &se);
LOG("Frame Home is the system environment now: SteamVR's home app turned off");
}
if (vr::VRSettings()->GetInt32(vr::k_pch_SteamVR_Section, "environmentMode", &se) != 1) {
vr::VRSettings()->SetInt32(vr::k_pch_SteamVR_Section, "environmentMode", 1, &se); // Aurora's pipelines are what the layer replaces
}
}
{ // its own SteamVR identity (hidden: no list, no dock), so its controller bindings are its own
const fs::path m = rootDir() / "frame-environment.vrmanifest";
std::ofstream(m) << "{\n \"source\": \"builtin\",\n \"applications\": [ {\n \"app_key\": \"framehome.environment\",\n"
" \"launch_type\": \"binary\",\n \"binary_path_linux\": \"" << (rootDir() / "bin" / "frame-home").string() << "\",\n"
" \"binary_path_linux_arm\": \"" << (rootDir() / "bin" / "frame-home").string() << "\",\n \"arguments\": \"--system\",\n"
" \"is_dashboard_overlay\": true,\n \"is_hidden\": true,\n"
" \"strings\": { \"en_us\": { \"name\": \"Frame Home\", \"description\": \"The environment\" } }\n } ]\n}\n";
const vr::EVRApplicationError ae = vr::VRApplications()->AddApplicationManifest(fs::absolute(m).c_str(), false);
if (ae != vr::VRApplicationError_None) LOG("registering the environment: %s", vr::VRApplications()->GetApplicationsErrorNameFromEnum(ae));
}
pid_t systemPid = -1;
bool adopted = false; // systemPid isn't our child (a panel before this one started it)
auto lastSpawn = std::chrono::steady_clock::now() - std::chrono::minutes(1);
auto keepSystem = [&](std::chrono::steady_clock::time_point now) {
if (systemPid > 0 && adopted) {
if (kill(systemPid, 0) == 0) return;
LOG("frame-home --system (pid %d) stopped: restarting it", static_cast<int>(systemPid));
systemPid = -1;
adopted = false;
} else if (systemPid > 0) {
int st = 0;
if (waitpid(systemPid, &st, WNOHANG) != systemPid) return;
LOG("frame-home --system stopped (%s %d): restarting it", WIFSIGNALED(st) ? "signal" : "exit", WIFSIGNALED(st) ? WTERMSIG(st) : WEXITSTATUS(st));
systemPid = -1;
}
std::error_code ec;
if (fs::exists(rootDir() / "layer" / "off", ec)) return; // the layer is off: Aurora
if (now - lastSpawn < std::chrono::seconds(5)) return; // a crash loop doesn't spin
lastSpawn = now;
// Still running from a panel before this one (the panel restarted): keep it, never a second renderer.
if (const std::vector<pid_t> running = runningWith("frame-home", "--system"); !running.empty()) {
systemPid = running.front();
adopted = true;
LOG("frame-home --system already running (pid %d): keeping it", static_cast<int>(systemPid));
return;
}
const std::string bin = (rootDir() / "bin" / "frame-home").string();
const pid_t pid = fork();
if (pid == 0) {
setsid();
setenv("STEAMVR_APPKEY", "framehome.environment", 1); // its own app to SteamVR, not ours (our input is legacy)
execl(bin.c_str(), bin.c_str(), "--system", static_cast<char*>(nullptr));
_exit(127);
}
if (pid > 0) { systemPid = pid; LOG("started frame-home --system (pid %d)", static_cast<int>(pid)); }
};
// The panel's pixels go to SteamVR in Vulkan images of our own (overlay_texture.h): SetOverlayRaw recreated the
// texture on every redraw and the panel flickered. SetOverlayRaw stays as the fallback.
OverlayTexture tex;
bool useTex = false;
{
std::string err;
useTex = tex.init(W, H, err);
if (useTex) LOG("panel texture: Vulkan, %d images (%s)", OverlayTexture::kSlots, OverlayTexture::format() == VK_FORMAT_R8G8B8A8_SRGB ? "sRGB" : "UNORM");
else LOG("panel texture: Vulkan unavailable (%s), using SetOverlayRaw", err.c_str());
}
// Thumbstick / trackpad scrolling while pointing at the panel. Smooth events are preferred; discrete ones only
// count while no smooth ones arrive (SteamVR may send both).
vr::VROverlay()->SetOverlayFlag(ov, vr::VROverlayFlags_SendVRSmoothScrollEvents, true);
vr::VROverlay()->SetOverlayFlag(ov, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true);
auto envFloat = [](const char* name, float def) { const char* v = std::getenv(name); return v && *v ? static_cast<float>(std::atof(v)) : def; };
// Phone-like speed: smooth deltas x420 (was 120) with a generous per-event cap; a discrete notch is one card row.
const float kSmoothPx = envFloat("FH_SCROLL_SMOOTH", 200.0f), kSmoothCap = envFloat("FH_SCROLL_CAP", 300.0f),
kDiscretePx = envFloat("FH_SCROLL_DISCRETE", static_cast<float>(Panel::kCardH + Panel::kGap) * 0.5f);
panel.kFlingGain = envFloat("FH_SCROLL_FLING_GAIN", panel.kFlingGain);
panel.kFlingMax = envFloat("FH_SCROLL_FLING_MAX", panel.kFlingMax);
panel.kFlingDecay = std::max(0.3f, envFloat("FH_SCROLL_FLING_DECAY", panel.kFlingDecay));
LOG("scrolling: smooth x%.0f (cap %.0f px), discrete %.0f px a notch, fling gain %.2f max %.0f decay %.2f", kSmoothPx, kSmoothCap, kDiscretePx,
panel.kFlingGain, panel.kFlingMax, panel.kFlingDecay);
// The header's button: Restart stops frame-home --system (keepSystem starts a new one as soon as it's gone);
// Start / Turn On clears the layer's off switch (the layer checks it every second) and starts it now.
pid_t restartingPid = -1;
std::chrono::steady_clock::time_point restartAt{}, killAt{};
bool restarting = false;
auto homeTick = [&](std::chrono::steady_clock::time_point now) {
std::error_code ec;
if (panel.restartAsked) {
panel.restartAsked = false;
restarting = true;
restartAt = now;
restartingPid = systemPid;
if (fs::exists(rootDir() / "layer" / "off", ec)) {
fs::remove(rootDir() / "layer" / "off", ec);
fs::remove(rootDir() / "layer" / "crashes", ec);
LOG("Frame Home turned back on from the panel");
}
if (systemPid > 0 && kill(systemPid, 0) == 0) {
LOG("restarting frame-home --system (pid %d) from the panel", static_cast<int>(systemPid));
kill(systemPid, SIGTERM);
killAt = now + std::chrono::seconds(4);
}
lastSpawn = now - std::chrono::minutes(1); // no 5 s wait: a new one as soon as the old one is gone
keepSystem(now);
}
if (restarting && restartingPid > 0 && systemPid == restartingPid && now > killAt && kill(restartingPid, 0) == 0) {
LOG("frame-home --system (pid %d) didn't stop: killing it", static_cast<int>(restartingPid));
kill(restartingPid, SIGKILL);
killAt = now + std::chrono::seconds(10);
}
// Restarting until a new process has been up a moment (it loads the home and fades in), 20 s at most.
if (restarting && (now - restartAt > std::chrono::seconds(20) ||
(systemPid > 0 && systemPid != restartingPid && now - lastSpawn > std::chrono::seconds(2))))
restarting = false;
const bool alive = systemPid > 0 && kill(systemPid, 0) == 0;
const Panel::Home h = restarting ? Panel::Home::Restarting : fs::exists(rootDir() / "layer" / "off", ec) ? Panel::Home::TurnedOff
: alive ? Panel::Home::Running : Panel::Home::Stopped;
if (h != panel.home) { panel.home = h; return true; }
return false;
};
double lastSmooth = -10;
int scrollLogs = 0;
bool dirty = true, wasVisible = false;
auto lastPoll = std::chrono::steady_clock::now(), lastHomeCheck = lastPoll;
double lastTick = Panel::clockNow(), lastUpload = 0;
while (!g_quit) {
if (const auto t = std::chrono::steady_clock::now(); t - lastHomeCheck > std::chrono::milliseconds(500) || panel.restartAsked) {
lastHomeCheck = t;
if (!panel.restartAsked) keepSystem(t);
dirty |= homeTick(t);
}
panel.now = Panel::clockNow();
vr::VREvent_t ev;
while (vr::VROverlay()->PollNextOverlayEvent(ov, &ev, sizeof ev)) {
const float mx = ev.data.mouse.x, my = H - ev.data.mouse.y; // GL space: bottom left is 0,0
switch (ev.eventType) {
case vr::VREvent_MouseMove: dirty |= panel.onMove(mx, my); break;
case vr::VREvent_MouseButtonDown:
if (ev.data.mouse.button == vr::VRMouseButton_Left) dirty |= panel.onDown(mx, my);
break;
case vr::VREvent_MouseButtonUp:
if (ev.data.mouse.button == vr::VRMouseButton_Left) {
if (const int id = panel.onUp(mx, my); id >= 0) panel.click(id);
dirty = true;
}
break;
case vr::VREvent_FocusLeave: panel.onLeave(); dirty = true; break;
case vr::VREvent_ScrollSmooth:
case vr::VREvent_ScrollDiscrete: {
const bool smooth = ev.eventType == vr::VREvent_ScrollSmooth;
if (smooth) lastSmooth = panel.now;
else if (panel.now - lastSmooth < 0.5) break;
if (scrollLogs < 12) { ++scrollLogs; LOG("scroll %s: dy %.4f (viewport %.3f)", smooth ? "smooth" : "discrete", ev.data.scroll.ydelta, ev.data.scroll.viewportscale); }
// ydelta > 0 scrolls up (toward the top of the list)
const float px = smooth ? std::clamp(-ev.data.scroll.ydelta * kSmoothPx, -kSmoothCap, kSmoothCap) : -ev.data.scroll.ydelta * kDiscretePx;
panel.onScroll(px);
dirty = true;
break;
}
case vr::VREvent_Quit: g_quit = 1; break;
default: break;
}
}
// Process-wide events (SteamVR asking us to quit).
while (vr::VRSystem()->PollNextEvent(&ev, sizeof ev)) if (ev.eventType == vr::VREvent_Quit) g_quit = 1;
const auto now = std::chrono::steady_clock::now();
if (now - lastPoll > std::chrono::seconds(2)) {
lastPoll = now;
if (rescan()) { dirty = true; panel.sc[0].content = panel.envContent(); }
PerfSettings ps;
if (ps.fromJson(readText(rootDir() / "settings.json")) && !(ps == panel.perf)) { panel.perf = ps; if (panel.page == 1) dirty = true; }
const std::string line = statsLine();
if (panel.stats != line) { panel.stats = line; if (panel.page == 1) dirty = true; }
DebugSettings ds; // (Frame Studio may change it too; Frame Home clears it when it starts)
const std::string dj = readText(rootDir() / "debug.json");
if (dj.empty()) ds = DebugSettings(), ds.shot = panel.dbg.shot;
if ((dj.empty() || ds.fromJson(dj)) && !(ds == panel.dbg)) { panel.dbg = ds; if (panel.page == 2) dirty = true; }
if (panel.page == 2) {
DebugInfo di = readDebugInfo();
if (!(di == panel.info)) { panel.info = std::move(di); dirty = true; }
}
}
const bool visible = vr::VROverlay()->IsDashboardVisible();
if (visible && !wasVisible) dirty = true; // the texture is kept, but show what changed while hidden
wasVisible = visible;
panel.now = Panel::clockNow();
const bool moving = panel.tick(panel.now - lastTick);
lastTick = panel.now;
// Redraw only when something visible changed; up to 90 times a second, so scrolling keeps up with the hand.
if (visible && (dirty || moving) && panel.now - lastUpload >= 1.0 / 90) {
lastUpload = panel.now;
panel.render();
std::string err;
if (useTex && !tex.upload(ov, panel.cv.px.data(), err)) {
LOG("panel texture: %s; using SetOverlayRaw from now on", err.c_str());
useTex = false;
}
if (!useTex) vr::VROverlay()->SetOverlayRaw(ov, panel.cv.px.data(), W, H, 4);
dirty = panel.needRedraw;
}
std::this_thread::sleep_for(std::chrono::milliseconds(!visible ? 16 : moving || dirty ? 2 : 8));
}
if (systemPid > 0) kill(systemPid, SIGTERM); // SteamVR is going: so is the environment
vr::VR_Shutdown();
tex.destroy(); // after VR_Shutdown: vrclient holds the images until then
return 0;
}
int runInstall() {
char self[4096] = {};
const ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
if (n <= 0) return 1;
std::error_code ec; // the installed copy is what SteamVR runs (see frame-home --install)
const fs::path installed = rootDir() / "bin" / "frame-switcher";
if (fs::exists(installed, ec)) std::snprintf(self, sizeof self, "%s", installed.c_str());
const fs::path manifest = rootDir() / "frame-switcher.vrmanifest";
std::ofstream(manifest) << "{\n \"source\": \"builtin\",\n \"applications\": [ {\n \"app_key\": \"" << kAppKey
<< "\",\n \"launch_type\": \"binary\",\n \"binary_path_linux\": \"" << self
<< "\",\n \"binary_path_linux_arm\": \"" << self
<< "\",\n \"arguments\": \"\",\n \"is_dashboard_overlay\": true,\n"
" \"strings\": { \"en_us\": { \"name\": \"Environments\",\n"
" \"description\": \"Switch the Steam Frame's environment\" } }\n } ]\n}\n";
vr::EVRInitError ierr = vr::VRInitError_None;
vr::VR_Init(&ierr, vr::VRApplication_Utility);
// 3: SteamVR not running — the installer registers the manifest in SteamVR's config files instead.
if (ierr != vr::VRInitError_None) { LOG("VR_Init: %s (SteamVR not running)", vr::VR_GetVRInitErrorAsEnglishDescription(ierr)); return 3; }
vr::EVRApplicationError ae = vr::VRApplications()->AddApplicationManifest(fs::absolute(manifest).c_str(), false);
LOG("AddApplicationManifest: %s", vr::VRApplications()->GetApplicationsErrorNameFromEnum(ae));
vr::EVRApplicationError al = vr::VRApplications()->SetApplicationAutoLaunch(kAppKey, true);
LOG("auto-launch with SteamVR: %s", vr::VRApplications()->GetApplicationsErrorNameFromEnum(al));
// A running (older) panel is replaced by this binary.
stopCopies("frame-switcher");
const vr::EVRApplicationError le = vr::VRApplications()->LaunchApplication(kAppKey);
LOG("start: %s", vr::VRApplications()->GetApplicationsErrorNameFromEnum(le));
vr::VR_Shutdown();
return ae == vr::VRApplicationError_None ? 0 : 1;
}
// Is somebody looking at Frame Home? It draws at the display's rate then, and a frame a second when nobody is (the
// headset is off, or a game runs). A stats.json older than 10 s: Frame Home isn't running.
bool frameHomeShowing() {
std::error_code ec;
const fs::path statsPath = rootDir() / "stats.json";
const auto written = fs::last_write_time(statsPath, ec);
if (ec || fs::file_time_type::clock::now() - written > std::chrono::seconds(10)) return false;
try {
return tinyjson::parse(readText(statsPath))["fps"].asFloat() > 5;
} catch (...) {
return false;
}
}
// Renders a thumbnail for every environment that has none (Frame Home's headless snapshot, low priority). Each one
// loads and draws a whole home on the GPU, which nice can't hold back: they're made only while nobody is looking at
// Frame Home, so they never stutter the home you're in (29 at once took 7 minutes of stutter, 2026-10-21).
int runThumbs() {
std::error_code ec;
fs::create_directories(rootDir() / "envs" / ".thumbs", ec);
const std::string bin = (rootDir() / "bin" / "frame-home").string();
int made = 0;
for (const Env& e : scanEnvs()) {
if (!e.thumb.rgba.empty()) continue;
bool waited = false;
while (frameHomeShowing()) {
if (!waited) LOG("thumbnails wait while Frame Home is being looked at");
waited = true;
std::this_thread::sleep_for(std::chrono::seconds(10));
}
const fs::path out = thumbPath(e.key);
// (an argv, not a shell line: an environment's name can hold any character)
std::vector<std::string> args = {bin, "--snapshot", out.string(), "--size", "384", "--cam", "0,1.6,0,0,-5"};
for (const auto& f : e.files) args.push_back((rootDir() / "envs" / f).string());
std::vector<char*> argv;
for (std::string& a : args) argv.push_back(a.data());
argv.push_back(nullptr);
LOG("thumbnail: %s", e.name.c_str());
const pid_t pid = fork();
if (pid == 0) {
setpriority(PRIO_PROCESS, 0, 19);
const int null = ::open("/dev/null", O_RDWR);
if (null >= 0) { dup2(null, 1); dup2(null, 2); }
execv(argv[0], argv.data());
_exit(127);
}
int st = 0;
if (pid > 0 && waitpid(pid, &st, 0) == pid && WIFEXITED(st) && WEXITSTATUS(st) == 0) ++made;
}
LOG("%d thumbnails made", made);
return 0;
}
// Self-check of deleteEnv in a made-up ~/frame-home under `dir` (HOME and the .fenv cache are pointed there).
int runTestDelete(const fs::path& dirIn) {
std::error_code ec;
if (fs::exists(dirIn, ec)) { std::fprintf(stderr, "test-delete: %s exists; give a new folder\n", dirIn.c_str()); return 2; }
fs::create_directories(dirIn, ec);
const fs::path dir = fs::canonical(dirIn, ec);
setenv("HOME", dir.c_str(), 1);
setenv("FRAME_FENV_CACHE", (dir / "cache").c_str(), 1);
const fs::path root = rootDir(), envs = root / "envs", th = envs / ".thumbs", cache = dir / "cache";
if (root != dir / "frame-home") return 2;
fs::create_directories(th, ec);
fs::create_directories(cache, ec);
auto put = [](const fs::path& p, const std::string& s = "x") { std::ofstream(p, std::ios::binary) << s; };
for (const char* f : {"alpha.apk", "foot.apk", "vista.apk", "beta.apk", "gamma.fenv", "gammax.fenv"}) put(envs / f);
put(envs / "alpha.apk.world.json"); put(envs / "alpha.apk.edits.json"); put(envs / "foot.apk.world.json");
put(th / "alpha.apk+.ppm"); put(th / "foot.apk+vista.apk+.ppm"); put(th / "foot.apk+.ppm"); put(th / "beta.apk+.ppm");
put(root / "pairs.txt", "foot.apk vista.apk\nbeta.apk missing.apk\n");
put(root / "current", "alpha.apk\n");
put(root / "keep.txt"); put(dir / "outside.apk");
const std::string gkey = cacheEntriesOf(envs / "gamma.fenv").key, xkey = cacheEntriesOf(envs / "gammax.fenv").key;
fs::create_directories(cache / (gkey + "-123-456") / "sub", ec); put(cache / (gkey + "-123-456") / "sub" / "scene.bin");
fs::create_directories(cache / (gkey + "-1--4645286059281504627"), ec); put(cache / (gkey + ".lock"), "");
fs::create_directories(cache / (xkey + "-9-9"), ec); fs::create_directories(cache / (gkey + "-junk"), ec);
fs::create_directories(cache / "gamma-deadbeef-1-2", ec);
int fails = 0, checks = 0;
auto check = [&](bool ok, const std::string& what) { ++checks; if (!ok) ++fails; std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str()); };
auto mk = [](std::vector<std::string> files, const std::string& name) { Env e; e.files = std::move(files); e.name = name; for (auto& f : e.files) e.key += f + "\n"; return e; };
auto has = [&](const fs::path& p) { return fs::exists(fs::symlink_status(p, ec)); };
// Refusals: nothing outside envs/, no path parts, environment files only, must exist.
for (const std::string bad : {"../outside.apk", "../keep.txt", "..", "a/b.apk", "/etc/passwd", "keep.txt", ".thumbs", "alpha.apk.world.json", "nope.apk", ""}) {
const DeleteResult r = deleteEnv(mk({bad}, bad), {}, false);
check(!r.ok, "refuses \"" + bad + "\" (" + r.error + ")");
}
check(has(dir / "outside.apk") && has(root / "keep.txt") && has(envs / "alpha.apk.world.json"), "refusals left everything alone");
std::vector<Env> all = scanEnvs();
check(all.size() == 5, "scan: 1 pair + 4 APKs (" + std::to_string(all.size()) + ")"); // .fenv fakes don't parse: made by hand below
// The current one: current moves to another first.
{
const Env* a = nullptr;
for (const Env& e : all) if (e.key == "alpha.apk\n") a = &e;
check(a != nullptr, "found alpha");
if (a) {
const DeleteResult r = deleteEnv(*a, all, false);
check(r.ok, "deletes alpha " + r.error);
check(r.currentChanged && !currentKey().empty() && currentKey().find("alpha") == std::string::npos, "current moved off alpha to \"" + r.newCurrent + "\" (" + currentKey().substr(0, currentKey().size() - 1) + ")");
check(!has(envs / "alpha.apk") && !has(envs / "alpha.apk.world.json") && !has(envs / "alpha.apk.edits.json") && !has(th / "alpha.apk+.ppm"), "alpha's file, world, edits and thumbnail gone");
check(!has(root / "current.part"), "no current.part left over");
}
}
// A footprint + vista pair: both files, the pairs.txt line, the thumbnails.
{
all = scanEnvs();
const Env* p = nullptr;
for (const Env& e : all) if (e.files.size() == 2) p = &e;
check(p != nullptr, "found the pair card");
if (p) {
put(root / "current", p->key);
const DeleteResult r = deleteEnv(*p, all, false);
check(r.ok, "deletes the pair " + r.error);
check(!has(envs / "foot.apk") && !has(envs / "vista.apk") && !has(envs / "foot.apk.world.json"), "both files of the pair (and the world) gone");
check(!has(th / "foot.apk+vista.apk+.ppm") && !has(th / "foot.apk+.ppm"), "the pair's thumbnails gone");
const std::string pairs = readText(root / "pairs.txt");
check(pairs == "beta.apk missing.apk\n", "pairs.txt keeps the other line only: \"" + pairs + "\"");
check(currentKey() != p->key && !currentKey().empty(), "current moved off the pair");
}
}
// A single file named in a pairs.txt line: the line goes too.
{
const DeleteResult r = deleteEnv(mk({"beta.apk"}, "Beta"), scanEnvs(), false);
check(r.ok && !has(envs / "beta.apk") && !has(th / "beta.apk+.ppm"), "deletes beta and its thumbnail");
check(readText(root / "pairs.txt").empty(), "the pair line naming beta went");
}
// A .fenv: its cache folders (only its own) go.
{
put(root / "current", "gamma.fenv\n");
const DeleteResult r = deleteEnv(mk({"gamma.fenv"}, "Gamma"), {mk({"gamma.fenv"}, "Gamma"), mk({"gammax.fenv"}, "Gammax")}, false);
check(r.ok && !has(envs / "gamma.fenv"), "deletes gamma.fenv");
check(!has(cache / (gkey + "-123-456")) && !has(cache / (gkey + "-1--4645286059281504627")) && !has(cache / (gkey + ".lock")), "its cache folders and lock gone");
check(has(cache / (xkey + "-9-9")) && has(cache / (gkey + "-junk")) && has(cache / "gamma-deadbeef-1-2"), "other cache folders kept");
check(currentKey() == "gammax.fenv\n", "current moved to gammax");
}
// The last one: current is emptied.
{
const DeleteResult r = deleteEnv(mk({"gammax.fenv"}, "Gammax"), {mk({"gammax.fenv"}, "Gammax")}, false);
check(r.ok && r.currentChanged && r.newCurrent.empty() && currentKey().empty() && has(root / "current"), "the last one: current emptied");
}
check(has(dir / "outside.apk") && has(root / "keep.txt") && has(root / "pairs.txt") && has(th), "nothing outside envs/ touched");
std::printf("%d of %d checks passed\n", checks - fails, checks);
return fails ? 1 : 0;
}
} // namespace
int main(int argc, char** argv) {
const std::string a = argc > 1 ? argv[1] : "";
if (a == "--install") return runInstall();
if (a == "--thumbs") return runThumbs();
if (a == "--unpack-bundles") { unpackBundles(); return 0; }
if (a == "--test-delete" && argc > 2) return runTestDelete(argv[2]);
if (a == "--render" && argc > 2) { // the panel as it looks now, to a PPM (no SteamVR needed)
// frame-switcher --render out.ppm [hover] [page] [scroll px] [switching card]
// (FH_RENDER_CONFIRM=n: card n asks "Delete?"; FH_RENDER_TOAST=text: a toast)
Panel panel;
panel.loadFonts();
panel.envs = scanEnvs();
panel.current = currentKey();
panel.now = Panel::clockNow();
const int hover = argc > 3 ? std::atoi(argv[3]) : -1;
if (argc > 4) panel.page = std::clamp(std::atoi(argv[4]), 0, 2);
panel.perf.fromJson(readText(rootDir() / "settings.json"));
panel.dbg.fromJson(readText(rootDir() / "debug.json"));
panel.info = readDebugInfo();
panel.stats = statsLine();
panel.hoverT.assign(panel.envs.size(), 0.0f);
// page 0: hover is a card number, or a raw id (>= 10000: e.g. 30000 + n is card n's delete button)
const int hoverId = panel.page == 0 && hover >= 0 ? (hover >= Panel::kCardId ? hover : Panel::kCardId + hover) : -1;
if (const int c = Panel::cardOf(hoverId); c >= 0 && c < static_cast<int>(panel.envs.size())) { panel.hotHover = hoverId; panel.hoverT[c] = 1; }
if (const char* c = std::getenv("FH_RENDER_CONFIRM"); c && *c && std::atoi(c) >= 0 && std::atoi(c) < static_cast<int>(panel.envs.size())) {
panel.confirmKey = panel.envs[std::atoi(c)].key; // card c asking "Delete?"
panel.confirmStart = panel.now - 1;
}
if (const char* t = std::getenv("FH_RENDER_TOAST"); t && *t) panel.say(t);
if (const char* h = std::getenv("FH_RENDER_HOME"); h && *h) panel.home = static_cast<Panel::Home>(std::clamp(std::atoi(h), 0, 3));
if (argc > 6 && std::atoi(argv[6]) >= 0 && std::atoi(argv[6]) < static_cast<int>(panel.envs.size())) {
panel.switchingKey = panel.current = panel.envs[std::atoi(argv[6])].key;
panel.switchStart = panel.now - 0.3;
}
panel.render(); // once to lay out (content height), then as it shows
if (argc > 5) {
Panel::Scroll& s = panel.sc[panel.page];
s.pos = s.target = std::clamp(static_cast<float>(std::atof(argv[5])), 0.0f, panel.maxScroll(panel.page));
s.active = panel.now;
}
if (const char* n = std::getenv("FH_RENDER_BENCH")) { // time the drawing alone
const auto t0 = std::chrono::steady_clock::now();
const int reps = std::max(1, std::atoi(n));
for (int i = 0; i < reps; ++i) panel.render();
std::fprintf(stderr, "render: %.2f ms\n", std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count() / reps);
}
if (panel.page != 0 && hover >= 0) panel.hotHover = hover; // settings: hover an option id (row * 100 + option)
else if (panel.page == 0 && hover >= 0) panel.hotHover = hoverId;
panel.render(); // (draws with that hover; no pointer, so it's cleared after)
FILE* f = std::fopen(argv[2], "wb");
if (!f) return 1;
std::fprintf(f, "P6\n%d %d\n255\n", W, H);
for (size_t q = 0; q + 3 < panel.cv.px.size(); q += 4) std::fwrite(&panel.cv.px[q], 1, 3, f);
std::fclose(f);
return 0;
}
if (a == "--list" || a == "--set" || a == "--delete") {
const std::vector<Env> envs = scanEnvs();
const std::string cur = currentKey();
if (a == "--list") {
for (size_t i = 0; i < envs.size(); ++i)
std::printf("%c %2zu %-40s %-28s %s\n", envs[i].key == cur ? '*' : ' ', i + 1, envs[i].name.c_str(), envs[i].kind.c_str(),
[&] { std::string f; for (const auto& x : envs[i].files) f += (f.empty() ? "" : " + ") + x; return f; }().c_str());
return 0;
}
if (argc < 3) { std::fprintf(stderr, "usage: frame-switcher %s NAME|NUMBER\n", a.c_str()); return 2; }
const std::string want = argv[2];
for (size_t i = 0; i < envs.size(); ++i) {
std::string files;
for (const auto& x : envs[i].files) files += (files.empty() ? "" : "+") + x;
if (want == std::to_string(i + 1) || want == envs[i].files.front() || want == files || want == envs[i].name) {
if (a == "--set") return setCurrent(envs[i]) ? 0 : 1;
const DeleteResult r = deleteEnv(envs[i], envs, false);
if (!r.ok) { std::fprintf(stderr, "frame-switcher: %s\n", r.error.c_str()); return 1; }
for (const auto& x : r.removed) std::printf("removed %s\n", x.c_str());
if (r.currentChanged) std::printf("now showing: %s\n", r.newCurrent.empty() ? "(nothing left)" : r.newCurrent.c_str());
return 0;
}
}
std::fprintf(stderr, "frame-switcher: no environment \"%s\" (see --list)\n", want.c_str());
return 1;
}
return runPanel();
}