Skip to content

Cut idle CPU, binary size and bundled art on Windows - #2

Merged
Hyphaed merged 9 commits into
Hyphaed:gb/launcher-taurifrom
psnwd:pr-566
Sep 12, 2026
Merged

Hyphaed merged 9 commits into
Hyphaed:gb/launcher-taurifrom
psnwd:pr-566

Conversation

@psnwd

@psnwd psnwd commented Sep 12, 2026

Copy link
Copy Markdown

Four commits from the Windows pass.

before after
CPU, minimized 13.5% of one core 0.2%
CPU, unfocused but visible 17.7% 0.5%
Binary 27.3 MB 19.0 MB
public/art 12.97 MB 2.61 MB

Before/after are the same tree, with and without the change — not an estimate.

launcher-tauri: document starting the launcher off Linux
start.sh is bash, probes _Build/linux/install and shells out to ss.
RUNNING.md adds per-OS prerequisites, the emulator build that has to come
first, and the three ways to start it.

config: keep audio device keys out of an unset Kyty.ini
audio_output_device= and audio_input_device= were written
unconditionally, so every save injected two keys into the Kyty.ini shared
with the Qt launcher, and load_then_save_real_file_is_byte_identical
failed. Now written only when a device is set — the rule bvh_stub_enabled
already follows three lines above. Adds a round-trip test for both
directions.

launcher-tauri: stop spending CPU while nobody is watching
The hero Ken Burns pan, per-tile flux ring and shimmer, and the pulse dot
loop forever against 34 backdrop-filter rules, with nothing pausing on
hide, blur or a running game. lib/idle.ts sets data-idle when the window
is hidden, minimized, unfocused or a game is running; CSS parks the looping
animations and flattens live backdrop-filter.

Only the infinite animations are parked — a blanket rule also froze the
one-shot entrance animations, which is what reveals the tiles, so Home's
icons stayed invisible. Focus and minimize are both read from Tauri rather
than inferred, since minimize fires no reliable blur and leaves
document.hidden false.

Also here: webview2_tuning.rs asks WebView2 for
MemoryUsageTargetLevel::Low while a game runs, a release profile
(lto, codegen-units = 1, opt-level = "s", panic = "abort", strip),
right-click suppression outside editable fields, and emulator.rs's Write
import gated to cfg(unix).

launcher-tauri: re-encode the bundled art as WebP
Six 1024×1024 cover placeholders at ~1.5 MB each were most of the 13 MB.
Lossy quality 82 for the photographic art — SSIM 0.993/0.994 for the
ambients, 0.976/0.980 for the covers, which are only ever drawn blurred.
Lossless and bit-exact for kyty_mark and controller_diagram, which are
drawn sharp. grain.png stays PNG — tileable noise is what lossy encoding
turns into visible seams.

Not verified

  • MemoryUsageTargetLevel never executed — it needs a real game launch.
  • The cover placeholders were never on screen; every game in the test library
    has real icon art.
  • Windows only. Linux and macOS untested.

cargo test 84/84 and cargo test --release 84/84, up from 82 passed /
1 failed. tsc --noEmit clean, release build warning-free.

start.sh is the only documented way in, and it is a bash script that
probes ../../_Build/linux/install and shells out to ss -- none of which
applies on Windows, which is where this branch's real-machine pass is.

Add RUNNING.md: per-OS prerequisites, the emulator build step that has to
come first for Launch to work at all, and the three ways to start it
(tauri dev, a release binary, a bundle). Records the traps that cost time
here: the debug binary has devUrl baked in and dies standalone, Vite's
1421 is strictPort so a busy port fails the run outright, and
discover_emulator's walk-up is what makes a dev checkout work unconfigured.

Verified on Windows: npm ci, a release build, and the resulting binary
all run as written.
save() wrote audio_output_device= and audio_input_device= into
[GlobalConfiguration] unconditionally, so every save injected two keys
into the Kyty.ini the Qt launcher shares -- and broke
load_then_save_real_file_is_byte_identical, which exists to catch exactly
that.

bvh_stub_enabled three lines up already documents the rule these missed:
a launcher-tauri addition the Qt launcher knows nothing about is written
only when it is set. Empty is the default and read_from leaves
String::new() for an absent key, so absent and empty already mean the
same thing on load; emit the key only when a device is picked, and remove
it when one is cleared.

Adds a round-trip test covering both directions -- the existing one only
covered the empty case, which left nothing guarding that a selected
device still persists.
Measured on Windows with the window open and the user doing nothing, the
launcher sat at ~17% of one core across its eight processes and never
went quiet -- ~13% of that inside WebView2's GPU process. The hero Ken
Burns pan, the per-tile flux ring and shimmer, and the in-game pulse dot
all loop forever, composited against 34 backdrop-filter rules, and
nothing anywhere paused on hide, blur, or a running game. A control build
confirmed WebView2 does not throttle this on its own: minimized, it still
burned 13.5% of a core.

lib/idle.ts sets data-idle on the document root when the window is
hidden, minimized, unfocused, or a game is running; CSS parks the looping
animations and drops live backdrop-filter to a flat fill. Minimized now
measures 0.2%.

Only the infinite animations are parked. A blanket rule also froze the
one-shot entrance animations, and those reveal their elements -- Home
builds its tiles at opacity 0 and animates up with forwards/both, so
pausing them left the game icons invisible.

Both window signals are read from Tauri rather than inferred. Seeding
"focused" as true and waiting for a blur missed a launcher that starts in
the background, and minimize fires no reliable blur and leaves
document.hidden false; either left it animating at ~11-14% of a core.

Alongside that:

- webview2_tuning.rs asks WebView2 for MemoryUsageTargetLevel::Low while
  a game runs, the Windows counterpart to webkit_tuning.rs. Bound to that
  transition only, never to focus, since Low is documented to drop caches
  and swap. Not using TrySuspendAsync: it requires the controller to be
  invisible and throws ERROR_INVALID_STATE otherwise, so it would mean
  blanking the window first. This path is untested at runtime -- it needs
  a real game launch.
- A release profile (lto, codegen-units = 1, opt-level = "s",
  panic = "abort", strip): 27.3MB -> 19.0MB. Nothing uses catch_unwind,
  and cargo ignores panic for test targets, so cargo test --release still
  passes.
- lib/contextMenu.ts suppresses the native right-click menu, which offers
  page navigation and "save image as" in a gamepad UI. Editable fields
  keep theirs so paste still works in Settings, and dev builds keep
  Inspect.
- emulator.rs's Write import is gated to cfg(unix), where the only user
  of it lives; the build is warning-free again.
public/art was 12.97MB of PNG, most of it six 1024x1024 cover
placeholders at ~1.5MB each. Re-encoded with libwebp: 12.97MB -> 2.61MB,
and the shipped dist drops to 3.47MB.

Lossy (quality 82) for the photographic art, measured against the
originals rather than assumed -- SSIM 0.993/0.994 for the ambients and
0.976/0.980 for the covers. The covers carry the lowest figure and are
the least exposed to it: heroArt.ts only ever resolves them with
needsBlur, so they are never drawn sharp.

Lossless for the two pieces of UI art with alpha, kyty_mark and
controller_diagram, which are drawn at full fidelity -- both re-encode
bit-exact (SSIM inf) and still come out smaller.

grain.png stays PNG. It is a tileable noise texture, exactly the content
lossy encoding turns into visible tiling seams, and it is 45KB.

Not touched, and unreferenced by any source file: trophy_*.png (674KB)
and ambient_empty.png (562KB). Trophies.tsx renders each game's own
trophy icons through convertFileSrc, never these. They are 1.2MB of the
remaining 2.61MB if they turn out to be dead rather than planned.
`npm run i18n:coverage` died on every run here with "SyntaxError:
Unexpected token 'export'", so the guard added in 24a26b8 to stop locale
gaps regressing silently has never actually run on this platform.

The script strips types by regex before evaluating a catalog as a data:
URL module, and the DeepPartial strip ends in `:\s*T;\n`. Git checks these
files out CRLF on Windows (core.autocrlf), so that never matched `T;\r\n`,
the `export type` survived the strip, and the whole module failed to parse.
It reports coverage fine on Linux, which is why it went unnoticed.

Normalise to LF on read, before any of the strips run.
main.tsx wraps the initial createRoot().render in try/catch, which only
covers the first synchronous render. Anything throwing after mount
unmounts the tree and leaves an empty document -- and on a launcher driven
by a gamepad, with no devtools in a release build, there is no way out of
that except killing the process.

Adds an error boundary around App. It is deliberately dependency-free
below the failure point: no useT(), no theme tokens, explicit colours. If
the thing that broke is i18n or the theme, translating the error screen is
exactly what cannot be relied on. The stack is shown rather than hidden --
every bug report this project gets asks for one, and a user cannot copy it
out of a dead window.
Both .icon-button and .pill-button.primary painted an outline AND a
`0 0 0 2px` box-shadow ring on focus, so every focused icon button and the
Play button came up wearing two concentric rings a couple of pixels apart.
Most obvious on a modal's close button and on Home's Play, the largest
primary in the app.

Keeps the outline in both cases and drops the duplicate. The outline
follows border-radius on its own, so the inset copy on .primary bought
nothing; that button's drop shadow stays, since it is the lift off the
page rather than a focus indicator. Focus stays clearly visible either
way -- this UI is navigated by pad, where an invisible focus ring has been
a real bug before.
The community feed carries a per-OS breakdown next to its cross-platform
aggregate, and parse() read only the aggregate -- zero references to
"platforms". The two disagree often enough to matter: a title reported
InGame on Linux can be DoesntBoot on Windows, and the launcher showed the
Linux-influenced figure to everyone. KytyPS5#177 raises exactly this ("be aware
of the game compatibility across platforms ... probably not, especially on
macOS").

Prefer this platform's own entry, fall back to the aggregate when the feed
has no breakdown for it. The entry also carries the report count and the
emulator build the reports were filed against, both already in the payload
and both previously dropped: one report against a build from months ago is
a very different claim from twelve on the current one.

`platformSpecific` says which of the two is being shown, so the UI can
label it rather than leaving the user to guess. A locally-edited database
has no "platforms" key at all and falls straight through to the top level,
which is what it should do -- a local edit is an opinion, not a report, and
is never marked platform-specific or given a count.

Tests cover all three: per-platform winning over the aggregate, an entry
without a breakdown falling back without claiming to be platform-specific,
and a local edit staying neutral.
Nearly every issue on the tracker is a [GAME STATUS] or [GAME BUG] report,
and they are all typed out by hand while the launcher sits on most of what
the form asks for. Adds a Report status action that opens the upstream
form with the game's title, serial, the emulator build it just probed, and
the host OS already filled in -- the build field being the one a
hand-written report most often gets stale.

It opens the form and nothing more. A confirm step comes first, because
leaving the app for an external site should not happen on a single button
press, and the user completes the parts only they know and submits it
themselves. Modal rather than a native confirm(): the OS dialog's buttons
live outside FocusNav's DOM, so a controller-only session could not answer
it -- the same reason the saved-data confirm next to it is written out
longhand. Field names are the template's own `id:`s and GitHub ignores ones
it does not recognise, so a rename upstream degrades to a blank form.

`opener:allow-open-url` had to be granted for this: the capability listed
only allow-open-path, never opener:default, so openUrl was denied and the
button would have silently done nothing. Scoped to the issue tracker
rather than opened up to any URL.

Alongside it, in the same pane:

- The compatibility badge now shows its platform, report count and tested
  build on hover or pad focus, rather than a permanent line of fine print
  under it. `.ps-focused` reveals it as well as `:hover` -- a hover-only
  tooltip would hide it from the input this app is designed around.
- A note field under the local status dropdown. compatibility_set_comment
  has existed since the port with nothing calling it, so a user editing
  their own database could set a status and never record why.
- Modal takes a `dividers` prop. The folder and image browsers scroll a
  list under their header and keep the hairlines; a short confirm has
  nothing to scroll, so there the two rules were pure chrome.
@psnwd

psnwd commented Sep 12, 2026

Copy link
Copy Markdown
Author

fd150c88 — npm run i18n:coverage never ran on Windows. The DeepPartial
strip ends in :\s*T;\n and git checks out CRLF here, so export type survived
into the data: URL and the module failed to parse. Fine on Linux, which is why
it went unnoticed — meaning the guard from 24a26b84 has never executed on this
platform. Now fixed, and it immediately caught the keys below, so all 17
catalogs are filled (my translations, worth a native review).

7d1badc6 — the compatibility badge could show another platform's result.
The feed carries a per-OS breakdown next to its aggregate and parse() read
only the aggregate, so a title that is InGame on Linux but DoesntBoot on Windows
showed the blended figure to everyone (KytyPS5#177 raises exactly this). Now prefers
this platform's entry, falls back to the aggregate, and surfaces the report
count and tested build that were already in the payload and being dropped. Three
tests cover the split, the fallback, and a local edit staying neutral.

70bfa674 — report a game's status from the detail pane. Opens the upstream
form pre-filled with title, serial, probed emulator build and host OS; a confirm
step comes first and you submit it yourself. Needed opener:allow-open-url —
the capability listed only allow-open-path, so openUrl was denied and the
button would have silently done nothing. Scoped to the issue tracker. Same
commit: badge detail moved to a hover/pad-focus popup, a note field for the
local database (compatibility_set_comment had no caller), and a dividers
prop on Modal.

6f688e92 — a render crash no longer leaves a black window. main.tsx only
covered the initial synchronous render; anything throwing after mount left an
empty document with no way out but Task Manager. The boundary uses no useT()
and no theme tokens — if i18n or the theme is what broke, those are exactly what
cannot be relied on to report it.

4c488258 — one focus ring instead of two. .icon-button and
.pill-button.primary painted an outline and a 0 0 0 2px box-shadow, so
focused icon buttons and Play wore two concentric rings.

@Hyphaed
Hyphaed merged commit 20786a9 into Hyphaed:gb/launcher-tauri Sep 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants