Skip to content

feat(html5): HTML5Runtime -- native WebView runtime for Chromium-based games - #1570

Draft
jeremybernstein wants to merge 34 commits into
utkarshdalal:masterfrom
jeremybernstein:jb/html5-container
Draft

jeremybernstein wants to merge 34 commits into
utkarshdalal:masterfrom
jeremybernstein:jb/html5-container

Conversation

@jeremybernstein

@jeremybernstein jeremybernstein commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds HTML5Runtime -- a second, native game runtime alongside the existing Wine/Proton container. Chromium-based desktop games (NW.js, Electron, RPG Maker MV/MZ, Construct 2/3, GameMaker HTML5, Godot Web, Unity WebGL, Tyrano) run directly in Android WebView instead of stacking Chromium-in-NW.js-in-Wine-in-Box64. Same library, same download pipeline, same controller/achievement/cloud-save UX -- dramatically better perf, thermals, and battery.

The Wine path is untouched and remains the default. Everything here is additive and opt-in: a runtime column defaults to wine, the feature sits behind a FeatureGate flag, and titles are routed to WebView only when an engine is positively fingerprinted.

Core value: Steam-owned HTML5 games launch from the library and just run -- faster and cooler than under Proton -- with saves that round-trip through the existing Wine prefix so Steam Cloud / GOG Galaxy sync keep working with no per-title remap.

What's included

  • HTML5Runtime + WebView host -- per-engine pack/shim system, request interceptor (source game folder stays read-only; all patches applied in-flight).
  • Engine fingerprinting + install watcher -- detects the engine at install time and tags the container; bare-WebView fallback for self-contained wasm titles (Godot).
  • Runtime-aware launch path -- GameRuntime split; WebView suspends on activity background (Web Audio paused at both lifecycle boundaries).
  • Cloud save sync (Steam + GOG) -- Chromium LevelDB <-> Steam UFS, and a JS fs bridge <-> GOG Galaxy cloud. Both stores are supported and validated end-to-end on device (round-trip in both directions). Saves land where Steam UFS / GOG Galaxy expect because the runtime loads the Windows NW.js distribution (process.platform = win32, Windows-styled env vars, C:/... paths translated into the Wine prefix) -- no per-title remap. Epic save-sync is wired through the same path but is unconfirmed (no verified round-trip yet).
  • Input -- gamepad, touch, and physical mouse/keyboard inside the WebView; native-pad masking so engine input stacks (Unity InputSystem, Impact) don't double-read.
  • Achievements (Steam) -- Steam-aware close-time sync that emits into the same Goldberg event bus the Wine path already uses (no parallel achievement plumbing).
  • UI -- library/dialog/settings integration, opt-in service, "Reclaim Wine Storage" action (surgical wipe of drive_c/{windows,Program Files*}, preserves saves under drive_c/users/).
  • i18n -- 79 new runtime strings translated across 14 locales.
  • ANGLE-override advisory snackbar for the handful of titles that need a system WebView/driver swap (see Limitations).
  • Vendored :iq80-leveldb module (pure-Java LevelDB fork, per-thread snappy scratch) -- needed because Chromium's IDB/LevelDB format has edges the stock library mishandles on older Android.

Engine pack support

Pack Covers Status
pack:c3 Construct 2 + Construct 3 (incl. NW.js-wrapped) Validated (Hypnospace, SolCesto, Universe For Sale)
pack:rmmv RPG Maker MV + MZ Validated (OMORI, Look Outside, ISAT)
pack:nwjs Impact-engine titles (CrossCode-class) Validated (CrossCode, Alabaster Dawn)
pack:electron Electron-wrapped titles Validated (Wayward, Cookie Clicker, Tyrano-on-Electron)
pack:gms GameMaker Studio HTML5 export Validated (RouletteKnight, AutoNecrochess)
pack:godot Godot Web (self-contained wasm, bare-WebView) Validated (DnDG demo)
pack:unity Unity WebGL (brotli-over-loopback serving + canvas-fit) Validated (Perihelion)
pack:tyrano Tyrano builder titles Validated

Pack identifier is the runtime, not the packaging format (pack:c3 covers C2/C3 even when NW.js-wrapped; pack:nwjs is reserved for Impact engines specifically).

Scope notes

  • Steam-first. Steam UFS is the primary cloud target; GOG Galaxy save-sync also round-trips (both validated on device). Epic save-sync is wired in but unconfirmed -- no successful round-trip verified yet. Amazon HTML5 is a later milestone.
  • Diff size is inflated by the vendored :iq80-leveldb module, 14-locale string files, and JS shim assets; the net app-logic surface is app/gamenative/html5/* (~99 files) plus the runtime split.
  • The top commit (on-screen joystick ...) is an on-screen-control fix kept in this PR because some packs' touch input relies on it. Its [NON-SCOPE] commit label predates that decision.

Why the save-sync and achievement changes (and why they touch the Wine path)

A few changes in this PR sit in shared Steam/GOG-core code rather than the HTML5 package. They were required to make HTML5 saves and achievements round-trip, but they also correct behavior on the existing Wine path -- so they're called out here explicitly, and several could land independently of the HTML5 milestone.

Save sync. The cloud stores save files opaquely -- it has never cared that they're LevelDB. Under Wine the desktop Chromium / NW.js writes its IndexedDB / LocalStorage into the prefix and those files already round-trip like any other save. The HTML5 path is harder for three reasons, none of which the Wine path has to solve:

  • Two profiles, not one. The WebView writes its IndexedDB / LocalStorage (on-disk LevelDB) into the Android WebView profile dir, not the Wine-prefix paths Steam UFS / GOG Galaxy sync. So saves have to be bridged between the two profiles -- at runtime boundaries only (launch / exit), never live, so neither DB is mutated mid-game. The runtime loads the Windows NW.js distribution (process.platform = win32, Windows-styled env vars, C:/... translated into the prefix) precisely so the bridged files land at the AppData / install paths the cloud already expects -- no per-title remapping.
  • Chromium version skew. The Android WebView's Chromium and the desktop Chromium that wrote the cloud copy are different versions, so the bridge has to read/write the LevelDB itself rather than trust the engine -- hence the vendored :iq80-leveldb reader/writer (Chromium IndexedDB uses a custom idb_cmp1 comparator and format edges the stock library mishandles).
  • LevelDB multi-generation accretion. LevelDB is an LSM tree; cross-device restore plus accretive cloud sync can leave several DB generations (old + new MANIFEST / .log / .ldb) colliding in one dir. The Wine path leans on the desktop Chromium's own obsolete-file cleanup to tolerate that; the transcode path can't, so it wipe-and-rebuilds the game-facing DB at each boundary, prunes stale generations before read, and (HTML5-only) mirror-deletes cloud-only files. Without it, the rebuilt DB references stale tables (a "Frankenstein DB") and the game opens but can't load its saves.

Two of those needs reach into shared store code:

  • GOG cloud protocol correctness (gzip uploads with deterministic md5/ETag, +00:00 timestamps, User-Agent, GalaxyConfig.json client_id fallback) to match Galaxy / Heroic. Without these, GOG cloud round-trips diverge or fail. These protocol fixes apply to all GOG titles, Wine included -- a general GOG-cloud fix, not HTML5-specific.
  • two Steam UFS correctness fixes the round-trip work exposed: addpath subfolder handling, and a reconcile when completeAppUploadBatch returns EResult.Fail.

One deliberate asymmetry: destructive cloud deletion (mirror-deleting cloud-only files) is opt-in and HTML5-only on both stores -- gated by propagateDeletions (GOG) / the equivalent Steam gate, both keyed on "is this an HTML5 container." The Wine path on Steam and GOG stays accretive and never deletes cloud files. HTML5 needs mirror-delete because it rewrites the LevelDB wholesale at each boundary, so a stale cloud file would otherwise resurrect.

Achievements. Both achievement fixes stem from one Steam protocol detail: userStats.achievementBlocks[].unlockTime[] is sticky -- Steam keeps the timestamps populated even after an achievement is reset (Support, ClearAchievement, SAM). Only userStats.stats[].statValue holds the live earned bitmask.

  • Close-time sync previously rebuilt the base bitmask from the sticky unlockTime[], so every sync re-uploaded the prior earned bits and silently undid any user-initiated reset. It now seeds from userStats.stats.
  • Close-time sync now fetches user stats, subtracts the already-earned achievements, and pushes only what Steam doesn't already know -- falling back to the legacy additive push when the fetch fails (offline at close), so offline-earned unlocks aren't lost.

Both achievement paths are shared: Wine and HTML5 both call storeAchievementUnlocks and closeApp's syncAchievementsFromGoldberg. So these are Steam-core correctness fixes that benefit the existing Wine runtime too, and could merge independently of HTML5.

Recording

TODO: attach short clips -- one RMMV title (OMORI), one Construct (Hypnospace), one Impact (CrossCode) launching + a save round-trip through Steam Cloud.

Test plan

  • Wine launch path unchanged -- full legacy unit suite 1568/0 (14 skipped) green post-rebase.
  • Per-engine device launches: pack:c3 / rmmv / nwjs / electron / gms / godot / unity / tyrano (titles listed above).
  • Save round-trip both directions through Steam UFS (Look Outside Return Home) and GOG Galaxy (In Stars and Time).
  • Epic save-sync round-trip -- wired but not yet confirmed on device.
  • Achievements: reset on Steam -> in-game unlock -> live upload -> reflected on Steam web (Look Outside).
  • Gamepad d-pad + sticks + face buttons across all packs; physical mouse/keyboard.
  • Suspend/resume audio at QuickMenu and activity-background boundaries.
  • Reviewer: verify on a device with a stock (un-swapped) system WebView.

Limitations & Risks (with mitigations)

  1. WebView version variance. Older system Chromium lacks GPU caps some engines need. Mitigation: feature-detect on boot; minimum-version gate; engine fingerprint gates routing so unsupported titles stay on Wine.

  2. ANGLE-Vulkan vs GL conflict (per-title). A few titles (terra-engine, Alabaster Dawn) need ANGLE-Vulkan on newer WebView; others (RMMZ filter-RT titles like Look Outside) break with it. Mitigation: ANGLE-off is the global default (correct for the majority); an advisory snackbar flags the exception titles and points at the WebView-impl/Shizuku swap. Documented, not silent.

  3. Shader precision on Adreno. mediump fp16 math produces banding/stripes on Adreno that desktop fp32 hides. Mitigation: shaderSource hook promotes mediump -> highp (shipped; fixed Alabaster Dawn stripes + fuzzy shadows).

  4. Chromium LevelDB format edges. Save corruption risk where Chromium's IDB/LevelDB format meets a Java reader (idb_cmp1 comparator, .ldb/.sst, mmap covariant nio, CRC32C on API<34). Mitigation: vendored iq80 fork (per-thread snappy scratch, PureJavaCrc32C, release-8 for API<34). Two edges hit and mitigated; flagged to watch for a third.

  5. Save sync is asymmetric by design. WebView Chromium is the live save store during gameplay; the LevelDB <-> UFS rewrite happens only at runtime boundaries (launch / exit), not live two-way. Mitigation: intentional -- source folder stays read-only, sync at boundaries avoids mid-game corruption. Documented invariant.

  6. VP8 webm cutscenes render as broken-image placeholders on devices without hardware VP8 (some Tyrano titles). Mitigation: documented limitation; transcode-to-VP9 workaround.

  7. Engine native-vs-HTML5 export ambiguity. Several engines ship native + web exports sharing file extensions. Mitigation: fingerprint discriminates by engine-runtime presence (.wasm, html5game.js, Build/*.framework.js), not .exe absence (corrected over 3 iterations with native/web fixtures).

  8. Render Scale < 1 looks rough on some RMMV/nwjs titles. Mitigation: CSS pixelated injection ships; deeper JS-shim fix (texParameteri / imageSmoothingEnabled) is follow-up.

  9. Godot in-game quit freezes briefly then recovers via onRenderProcessGone (Godot's own synchronous cleanup, not our code). Mitigation: QuickMenu is the clean exit path; documented.

  10. Back-compat. Mitigation: runtime defaults to wine, all guards additive, feature behind FeatureGate; legacy suite green; Wine path is the no-regression bar.

Type of Change

  • Compatibility improvements
  • Performance / stability improvement
  • Other (new runtime -- requires prior approval / discussion)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted...
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds HTML5Runtime, a native Android WebView runtime that runs Chromium-based desktop games directly instead of stacking Chromium-in-NW.js-in-Wine. Wine stays the default; WebView routing is opt-in and only engages when an engine is positively fingerprinted. The runtime splits into Wine and WebView paths, and a running WebView suspends on activity background to match Wine pause semantics. The shared cloud-save and achievement fixes below also correct behavior on the existing Wine path. All 79 new runtime strings are translated into 15 locales.

Bug Fixes

  • Fixes CrashHandler wrapping itself on every app boot and double-dispatching crashes; it now installs once and dispatches once.
  • Fixes Steam Cloud sync: reconciles the cache after failed upload batches, skips commitFileUpload for SHA-deduped blobs, and silent-rehydrates when cached content matches the cloud; decodes embedded and bare %Root% tokens in sync paths.
  • Fixes Steam achievements: seeds unlocks from live userStats.stats so user resets aren't re-uploaded; only pushes newly earned ones.
  • Fixes GOG Galaxy cloud sync with a Heroic-compatible protocol and streamed gzip snapshots; gates uninstall on in-flight close-time uploads.
  • Self-classifies RMMV/MZ save substrate at runtime and scrubs LevelDB scratch; stubs Effekseer WASM on affected WebView builds; scopes CrossCode profile sync per store.
  • Caches OMORI's AES decrypt key for cold-boot/offline launches; reports container language on navigator.language and Steam bridges; skips depot re-downloads on language changes for WebView.
  • Exits to library on Godot engine-quit wedges; advises terra-engine titles to enable ANGLE-on-Vulkan.
  • Adds on-screen joystick deadzone and zero-axis stick release; stabilizes CI by marking leveldb fixtures binary and checking out submodules.
  • Keeps runtime resolution on the IO thread; config import and Reset-to-defaults no longer flip an html5 game back to Wine, and reclaimWineStorage refuses to wipe a Wine container itself.
  • Serves every html document with a CSP confining frames and objects to the game origin, so remote documents can't load in the game WebView; http(s) links open in the browser.
  • WebView sessions emit game_exited and game_runtime_switched telemetry, mirroring the Wine event; new html5 controls profiles seed their on-screen layout from the default Wine profile.

Refactors

  • Vendors :iq80-leveldb (pure-Java) with a per-thread snappy scratch fix and builds snappy-java from source for 16 KB-aligned native libs; both are pinned as submodules under the GameNative org and checked out by CI.
  • Adds declarative pack registration and typed engine/source ids.

Written for commit 85ea8a7. Summary will update on new commits.

View guided diff

@coderabbitai

coderabbitai Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
Finishing Touches

🧪 Generate unit tests (beta)

  • Create PR with unit tests
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@jeremybernstein
jeremybernstein force-pushed the jb/html5-container branch 5 times, most recently from 2f8652a to 0854d6f Compare June 15, 2026 06:50
@jeremybernstein

jeremybernstein commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor Author

here's a (lores) screen movie of a bunch of titles launching and running:
https://drive.google.com/file/d/1hc0w_ZKOPy1ulpSTyoTqXF5QlaHHWdyY/view?usp=sharing

Comment on lines +26 to +36
// Steam4C2 / Steam4C3 native binding stub.
// c2-on-NW.js Steam wrappers (Steam4C2.js) do:
// Steam4C2 = require('./Steam4C2-linux64') // or -win64/-osx64 etc.
// those are arch-suffixed native node addons we can't provide. without a stub,
// Steam4C2 is undefined and the chain `Steam4C2.__proto__ = EventEmitter.prototype`
// throws on first script execution. register a proxy noop for the family -- direct
// property writes survive (so `EventEmitter.call(Steam4C2)` initializes `_events`,
// and `Steam4C2._steam_events.on = ...` lands), prototype-chain reads pass through
// (so `Steam4C2.emit(...)` resolves to EventEmitter.prototype.emit after the proto
// assignment), and unknown methods (requestStats, activateGameOverlay, etc.) return
// a no-op so downstream calls don't TypeError.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to discuss the integration with steam in its various forms to understand the implications

@jeremybernstein jeremybernstein Jun 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quick-and-dirty version is: most WebView games use a combination of IndexDB and LocalStorage databases to manage their state natively.

Assuming that the game doesn't do any other file system access, but supports Steam Cloud, we export this data directly to Windows/WINE-shaped Chrome data folders at the expected location (/Users/xuser/AppData/...). SolCesto is a good example of this; it's terrible design -- there's a fair amount of churn as the DB auto-rewrites/optimizes records when loading data -- but it works.

If the game does perform other types of file system access via FS calls, we generally* turn off that mechanism and let the game manage the save files (again, to the WINE-shaped destination).

*there are some exceptions, of course, CrossCode, for instance requires both DB entries and FS-managed .save files to properly restore state.

Once the files are in place in their WINE-shaped final location, save-sync runs and the normal mechanism slots into place. There's nothing HTML5-specific about that, except that HTML5 titles can tombstone/delete cloud files (this is desirable and arguably necessary due to the huge DB churn mentioned above and in the PR notes).

For other stuff, achievements or general, games which need Steam services typically use either Steamworks.js or Greenworks.js, and those have been shimmed to serve byte-identical Goldberg files to disk, so that stuff like the AchievementWatcher continue to work as expected. Achievements, Stats, Cloud, UserID are all shimmed. Steam Overlay, Friends/Multiplayer, Workshop are not shimmed currently (Overlay is probably not shimmable, Multiplayer is (IMO) OOS for the initial version of this). There might be some shim gaps which come up, though -- EventEmitter, for instance, is stubbed to avoid crashing, but a game which truly depends on those callbacks won't work properly (haven't encountered one yet).

Comment on lines +18 to +35
// THE BUG (verified live in DevTools):
// for a sub-frame quick tap, ALL of these end-paths fire in the same task as pointerdown.
// _de transitions 0→1 (pointerdown) → 0 (any of pointerup/out/leave/cancel/_Y81) within a
// SINGLE task. The next IO poll observes _de=0 with _1Z2=0 -- no transition, no _hc[0]=1,
// no advance. long-presses work because _de stays at 1 across multiple poll intervals.
//
// fix:
// (a) swallow native pointerup + pointerout + pointerleave + pointercancel at canvas
// capture phase so the engine never sees them clear _de
// (b) swallow mouseup at window capture phase so _Y81 doesn't clear _de via touch.js's
// synthetic mouseup (touch.js dispatches one on touchend; it bubbles to window)
// (c) on touchend, dispatch a SYNTHETIC pointerup tagged with __gnGmsSynth after DEFER_MS
// (constant + derivation below). the synth bypasses our own swallow (tag check) and
// reaches _je as the only path that releases _de back to 0. the delay spans one IO poll
// interval so the engine's poll is guaranteed to observe _de=1 at least once.
//
// derived from engine architecture (poll cadence + XOR press-detection), not a magic
// latency. verified live with quick taps reliably advancing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentions a bug here in the comments

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, let me clean that up to the essentials.

EPIC("EPIC_"),
AMAZON("AMAZON_"),
// Add other platforms here..
;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

floating ;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it only looks weird because of the interleaved comment. It's correct and non-removable, but we could kill the comment if it is too ugly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh weird. Yeah that's fine

Comment on lines +6 to +28
enum class GameSource(val containerPrefix: String) {
STEAM("STEAM_"),
CUSTOM_GAME("CUSTOM_GAME_"),
GOG("GOG_"),
EPIC("EPIC_"),
AMAZON("AMAZON_"),
// Add other platforms here..
;

// container/app-id prefix scheme lives ONLY here. the canonical id form is
// <containerPrefix><numericId> (e.g. "STEAM_440"). matches/idOf replace the hand-rolled
// appId-startsWith / removePrefix calls formerly scattered across services + save-sync.
fun matches(containerId: String): Boolean = containerId.startsWith(containerPrefix)
fun idOf(containerId: String): String = containerId.removePrefix(containerPrefix)

companion object {
// the source whose prefix this container id carries, or null if none match.
fun fromContainerId(containerId: String): GameSource? =
entries.firstOrNull { it.matches(containerId) }
// the id portion after the recognized prefix (whole string if no known prefix).
fun idPart(containerId: String): String =
fromContainerId(containerId)?.idOf(containerId) ?: containerId
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like we're overloading what's already available in ContainerUtils?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GameSource is IIRC the source of truth here, and ContainerUtils delegates to it. Or what specifically do you see being duplicated/overloaded?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tested out non-HTML5 games with the touch input? Just concerned about regressions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't noticed any WINE-side regressions to touch input. Obviously my focus has been on HTML5 games, but I have been periodically confirming that WINE is still working as expected. The only non-isolated input change is the joystick deadzone fix, which should be correct no matter what (joystick output now only reports used directions, and properly releases once a direction is centered or reversed), but obviously should be triple-checked.

@jeremybernstein
jeremybernstein force-pushed the jb/html5-container branch 2 times, most recently from 8a16a5e to 2e8c4aa Compare June 17, 2026 07:50
@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Hi, I hope it's okay of me to leave a comment.
I looked at this PR and I see that it allows for better performance/battery life, etc. What performance improvements did you notice?

Thanks

@jeremybernstein

Copy link
Copy Markdown
Contributor Author

Hi, I hope it's okay of me to leave a comment. I looked at this PR and I see that it allows for better performance/battery life, etc. What performance improvements did you notice?

Thanks

Depends on how you measure it, but I was getting ~10x better battery efficiency (that is, the battery drains ~10x slower) with titles like Look Outside.

@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Hi, I hope it's okay of me to leave a comment. I looked at this PR and I see that it allows for better performance/battery life, etc. What performance improvements did you notice?
Thanks

Depends on how you measure it, but I was getting ~10x better battery efficiency (that is, the battery drains ~10x slower) with titles like Look Outside.

Wow, that's huge!

It doesn't improve performance in terms of FPS in games ?

@jeremybernstein

jeremybernstein commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi, I hope it's okay of me to leave a comment. I looked at this PR and I see that it allows for better performance/battery life, etc. What performance improvements did you notice?
Thanks

Depends on how you measure it, but I was getting ~10x better battery efficiency (that is, the battery drains ~10x slower) with titles like Look Outside.

Wow, that's huge!

It doesn't improve performance in terms of FPS in games ?

tbh I wasn't paying much attention to frame rate, but it's definitely not worse, particularly since most of the translation layers which eat cycles are gone. I think the only game I tested where frame rate would even be an issue was The Last Penelope, and I don't think I was able to get it running in Wine for comparison.

Sol Cesto ran really badly in Wine and it runs well in html5, but there are still moments with tons of resource loading where the audio stutters a bit. But it doesn't crash constantly at those moments, it just can't keep up compared to a desktop platform.

@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Hi, I hope it's okay of me to leave a comment. I looked at this PR and I see that it allows for better performance/battery life, etc. What performance improvements did you notice?
Thanks

Depends on how you measure it, but I was getting ~10x better battery efficiency (that is, the battery drains ~10x slower) with titles like Look Outside.

Wow, that's huge!
It doesn't improve performance in terms of FPS in games ?

tbh I wasn't paying much attention to frame rate, but it's definitely not worse, particularly since most of the translation layers which eat cycles are gone. I think the only game I tested where frame rate would even be an issue was The Last Penelope, and I don't think I was able to get it running in Wine for comparison.

Sol Cesto ran really badly in Wine and it runs well in html5, but there are still moments with tons of resource loading where the audio stutters a bit. But it doesn't crash constantly at those moments, it just can't keep up compared to a desktop platform.

Okay, before it's merged I think we should run some performance tests, you never know 🤣

Can I download the build and put it on my Meta Quest 3 to test it?

@jeremybernstein

Copy link
Copy Markdown
Contributor Author

Can I download the build and put it on my Meta Quest 3 to test it?

I don't know how to trigger a CI build for a PR. I have a local debug build I've been developing with, but I'd prefer to keep public-facing builds within the official build system. Looks like @utkarshdalal recently introduced a way to build-from-PR, but I don't have access to that feature.

@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Can I download the build and put it on my Meta Quest 3 to test it?

I don't know how to trigger a CI build for a PR. I have a local debug build I've been developing with, but I'd prefer to keep public-facing builds within the official build system. Looks like @utkarshdalal recently introduced a way to build-from-PR, but I don't have access to that feature.

Here https://github.com/utkarshdalal/GameNative/actions/runs/27674069013/job/81844996705?pr=1570

Capture d’écran 2026-06-17 à 14 54 18

@jeremybernstein

Copy link
Copy Markdown
Contributor Author

I don't think those builds have any artifacts (I've never found any), they are auto-run on every push to sanity-check that the code compiles. I was referring to #1561 .

@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

I don't think those builds have any artifacts (I've never found any), they are auto-run on every push to sanity-check that the code compiles. I was referring to #1561 .

Oh, well, yes, sorry, I thought the artifacts were automatically included since it's an action build.

Indeed, it would be great if an artifact were generated.

Too bad for me, I like to do tests :)

@utkarshdalal

Copy link
Copy Markdown
Owner

Here's the ad hoc signed build you can apply: https://github.com/utkarshdalal/GameNative/actions/runs/27697341947

Please do also check the regular XR build we've made for Quest and let me know how it works!

@utkarshdalal

Copy link
Copy Markdown
Owner

@moi952 also let's speak on Discord as we're trying to do more testing for the Quest builds.

@moi952

moi952 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Here's the ad hoc signed build you can apply: https://github.com/utkarshdalal/GameNative/actions/runs/27697341947

Please do also check the regular XR build we've made for Quest and let me know how it works!

I see there's a modern version, a legacy version, and a legacy XR version.

What are the differences?
Is there a Discord channel for the quest? I have the same username.

@jeremybernstein

Copy link
Copy Markdown
Contributor Author

@moi952 if anything HTML5-specific comes up in your test of the PR build (like, first principles, it works at all or it doesn't work in this way for game X), feel free to post here, or we can open a discussion thread somewhere on Discord (although I think here is more appropriate while this is still in PRgatory).

@moi952

moi952 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

@moi952 if anything HTML5-specific comes up in your test of the PR build (like, first principles, it works at all or it doesn't work in this way for game X), feel free to post here, or we can open a discussion thread somewhere on Discord (although I think here is more appropriate while this is still in PRgatory).

@moi952 if anything HTML5-specific comes up in your test of the PR build (like, first principles, it works at all or it doesn't work in this way for game X), feel free to post here, or we can open a discussion thread somewhere on Discord (although I think here is more appropriate while this is still in PRgatory).

Hi, I tested your version and I haven't noticed any regressions, but I also haven't seen any improvement in battery life or performance (based on the HUD which indicates power consumption, for example, I don't see any decrease in power consumption).

However, I think the games launch faster/more stably, but I'll have to revert to the previous version to be sure.

@utkarshdalal I left you a message in the general Discord channel because I don't know if you want to create a dedicated channel, if you want to discuss this privately, or anything else.
The fact that the application launches in landscape mode is really great :)
I haven't noticed any other "improvements," but according to the commits, the changes relate to the drivers to select for an XR device.

Overall, I find the GameNative experience very good. I need to address the loss of focus issue. For example, in the game's information screen, when I go to edit the container and save, I return to the game's information screen but lose focus. To launch the game, I have to press B (with a controller) and then return to the game's information screen to regain focus.

The same thing happens with the in-game quick menu; I often lose focus.
On the home screen, if I press the Xbox button, I lose focus.

When I lose focus, I have to pick up the Quest controller and click on the app so that my controller regains focus. DLSS doesn't have a sharpness setting. When I adjust the sharpness, it changes the colors, not the sharpness. This setting works fine on the FSR, though. I don't know if this is a problem specific to the Quest or if it's the same for everyone.

Ah yes, I don't know if it's only on the Quest, but my CPU usage is capped and often stays at 77%, so I'm frequently experiencing a CPU bottleneck. Is this a problem with the Quest? A lack of power?

jeremybernstein and others added 29 commits November 14, 2026 11:09
…ge ↔ gog fs

Bidirectional save sync between chromium WebView origin storage
(LocalStorage, IndexedDB blobs, OPFS) and the canonical wine save
locations under drive_c/users/. Steam UFS uses SteamCloud batch
protocol; GOG fs uses Heroic-compatible protocol; pack:nwjs Impact
storage adapter uses dual-write fs+leveldb. Includes leveldb-rewrite
v2.1 (idb_cmp1 + .ldb/.sst handling), opfs-mirror with BC-gated
worker hydration for cross-device restore, per-title forceMechanism
override, Crashpad/sync-file filter, and debug-menu Clear Steam Cloud
RPC for testing.
Html5InputBridge unifies controller/touch/keyboard routing into the
WebView. Gamepad emits W3C-standard 4 axes (no trigger entries) with
prototype-walk-safe button shim. Touchscreen-mode parity with master
QuickMenu. Kbd-on-gamepad swallow keeps Impact-engine input device
detection on gamepad. Process polyfill (execPath/__dirname/__filename)
and NW.js Node surface (events module, process.arch, module stub) for
require() compatibility.
Per-engine pack JSONs declare overrides (saves mechanism, shims,
patches, input spec); ProfileRegistry hoist applied cross-pack
defaults to host code so packs only carry overrides. Per-engine
shim JS handles engine-specific quirks (Impact storage adapter for
NW.js, RMMV plugin patches for Galv/Hime/Olivia/YEP, TyranoScript
tpatch overlay + bgmaudio race guard + measurement-based centering,
GMS canvas viewport cap, Electron AMD/UMD pollution unblock, etc.).
…ne Storage

- Library: html5 game badges, runtime-aware launch buttons, opt-in service
- Container config dialog: offers the html5 variant only when the install fingerprints as html5 (wine stays
  the default, nothing flips on its own); gates wine-only knobs for html5; adds gesture settings
- Launch re-fingerprints html5 containers when the install dir changes, so an update that swaps the engine is picked up
- Downloads & Storage: Reclaim Wine Storage action (surgical wipe of system dirs in drive_c, preserves saves)
- Settings: HUD config wiring, gesture config persistence, performance HUD additions
- PrefManager, AndroidEvent additions for html5 lifecycle signals
Html5Runtime runs html5 process setup on first html5 use (pre-launch
sync, WebView launch, a runtime flip), never at app start: Chromium
version gate, loopback port, leveldb health + crashpad cleanup (deferred
when another WebView already ran this session), save-sync subscription.
LevelDbRewriter sets snappy-java use.systemlib before any snappy class
loads. SteamService exposes steamCloudHandler() for the savesync
GreenworksCloudClient. WebViewContainer + runtime tests cover container
JSON round-trip and dispatch.
structural cleanups to the pack/engine/runtime plumbing -- no behavior change
(modern + legacy unit tests + full assemble green). five threads:

- declarative pack-shim registration: placement moves into the pack JSON
  (packShimPlacement: none/append/prepend); shim id+asset+url derived by
  convention (pack:foo -> html5/shims/packs/foo.js). the five per-pack if-blocks
  in resolveShimUrls collapse to one generic block. ProfileSchemaTest gains a
  disk-backed guard loading the shipped pack JSONs through the real resolver.

- ShimBundles convention: deriveShimPath also covers base shims
  (<id> -> html5/shims/<id>.js), dropping 28 const-triplets + map entries; only
  irregular ids (steamworks-noop->steamworks.js, nw-noop, greenworks-noop,
  js-yaml.min.js) keep explicit entries. ShimBundlesConventionTest locks it.

- typed engine-pack ids: EnginePackId consts replace scattered "pack:X" string
  literals (signatures + host/savesync comparisons) so a typo is a compile error.

- centralized container-id prefixes: GameSource owns the prefix scheme
  (containerPrefix + matches/idOf/fromContainerId/idPart); the hand-rolled
  appId.startsWith("STEAM_") / removePrefix calls across services + save-sync +
  install + ContainerUtils route through it -- the prefix strings live once.

- declarative viewport: EngineProfile.wideViewport (c3 + electron) replaces the
  per-engine useWideViewPort/loadWithOverviewMode string checks in WebViewScreen.

a new pack now touches ~one place: the EngineSignature object + packs/<id>.json
(+ shim JS if any); ShimBundles/resolveShimUrls need no edits.
covers html5/webview, save-sync, perf-hud, render-scale, reclaim-storage,
and debug cloud strings added by this branch. format specifiers (%n$x) and
%% literals preserved, apostrophes escaped per aapt2, brand/tech terms
(HTML5/WebView/Chromium/Wine/Steam/etc.) left untranslated.
terra-engine shaders exceed the vendor GLES 256-vector vertex uniform cap;
the android ANGLE override (ANGLE-on-Vulkan, cap 1024) is the only unblock.
suggest it via snackbar when launching a terra title without it -- gated to
WebView >= 118 (passthrough-decoder default flip, ui/gl/gl_features.cc
bisect; below that the override is inert, device-confirmed on WV109).
…inary

the *.log gitignore rule silently dropped leveldb write-ahead logs (000003.log)
-- the actual IndexedDB/LocalStorage records. fixtures parsed locally (files left
from capture) but a fresh CI checkout got them empty -> 0 keys -> 5 savesync tests
failed only on CI. force-add the 4 data files, negate *.log under leveldb fixture
dirs, and mark the fixture tree binary so EOL normalization can't corrupt CURRENT.
godot web (emscripten single-threaded) runs quit/teardown synchronously
on the renderer's only thread, then the canvas wedges permanently --
process doesn't die so onRenderProcessGone never fires and the user is
stuck on a dead frame. detect Main::cleanup()'s 'at exit' shutdown burst
in the console (nondeterministic which line flushes before the wedge, so
match the shared 'at exit' substring) and navigateBack to library, which
runs the existing teardown + outbound save-sync.
…PI channels

source language from the wine Container (the container-config dialog's source of
truth) instead of the WebViewContainer sidecar, which never carried it and silently
defaulted to english. fixes both navigator.language and the new Steam getCurrentGame-
Language / getCurrentUILanguage bridge (greenworks.js + steamworks.js). adds resolver
bcp47->goldberg inverse map + tests.
…ot / offline)

OMORI's AES decrypt key is a Steam launch arg (--<32hex>) that PICS only caches in-memory,
so a cold boot / offline launch had no key and .OMORI/.KEL passed through as ciphertext
(needed Steam contacted once per session). persist it on the html5 container instead:

- WebViewContainer gains a non-user-facing decryptionKey field (no config UI surfaces it,
  unlike wine's execArgs -- a --<32hex> blob can't be mistaken for garbage and cleared).
- loadByAppId seeds it write-once from the live PICS arg (without the transient language
  field, which is re-derived from wine each launch and not persisted).
- Html5PackSetup reads container.decryptionKey for both the AES key and nw.App.argv;
  drops the now-dead appId param from rememberHtml5PackSetup.

storing it on the container avoids a Room schema / parse-version bump and keeps it per-install.
back-compat: absent key decodes to "".
…ldb scratch

rmmv saves via Node fs only when our shim wins the StorageManager.isLocalMode
override (Utils.isNwjs is kept false so YEP_CoreEngine.initNwjs survives). a build
where the override misses -- web build, late/replaced StorageManager, RMMZ forage
-- saves to chromium leveldb instead. so the substrate can't be pinned statically
per pack or per title without shipping a release for every new title.

route at runtime: fs.js calls __gnFsBridge.markFsSaveMode() the instant it forces
isLocalMode=true, marking the container fs-authoritative at boot, BEFORE the first
write. the first sync boundary then reroutes to FsBridge and never uploads chromium
scratch. titles that never confirm local-mode keep the leveldb sync. self-selecting,
zero per-title config. (the lazy marker only tripped on the first fs write, so a
boot-scratch-then-exit session uploaded leveldb -- the original Look Outside leak.)

on the fsbridge outbound reroute, scrub the wine-side chromium leveldb staging
(Local Storage/leveldb, IndexedDB/<origin>.indexeddb.{leveldb,blob}) so prior or
degenerate-session scratch is tombstoned by the next UFS pass and can't be re-pulled
on a future misclassify. the resolver always builds these as leaf dirs distinct from
userDataRoot, so .rpgsave is never touched (guarded by a safety-invariant test).

also correct the stale "fsbridge is the universal default" comments: leveldb is the
static default; fs routing is the runtime decision.
…me flag

CrossCode saves to cc.save (fs) -- its chromium-LS is empty on both stores, so the
old "dual-write" rationale was wrong. but the chromium profile still matters on GOG:
GOG/Galaxy cloud mirrors the WHOLE chromium User Data dir (Sync Data, Web Data, Local
Storage, ...), so if the device scrubs the leveldb it churns against the PC (device
deletes -> PC re-uploads). keep the profile syncing on GOG_1252295864 for parity.

Steam CrossCode (STEAM_368340) syncs ONLY cc.save -- no chromium profile in its UFS
patterns -- so it rides the fs-authoritative fsbridge reroute like any fs title.

rename the confusing bypassFsBridgeReroute flag to syncChromiumProfile: same polarity
(true = keep the profile syncing), names the effect instead of the plumbing.
…ixes

The fix commits underneath this branch are the runtime-neutral halves of fixes
this work surfaced; they are split out for separate review and merge. This adds
what HTML5 needs on top of them:

- SteamService: steamCloudHandler() for GreenworksCloudClient,
  getLaunchArgumentsForOs, fetchUserStatsForApp for Html5AchievementSeed, and
  the chromiumProfileSync parameter on the sync and closeApp paths
- GOGCloudSavesManager / GOGService: the chromium-internal denylist call and
  mirror-delete, both gated on chromiumProfileSync so the Wine path is unchanged
- InputControlsView: Html5BindingSink plus the touchpadView/xServer null guards
  the WebView path needs, since neither exists there
- on-screen sticks send a zero axis value as a release instead of a press. The
  HTML5 sink reads a press with offset 0 as a digital tap (full deflection), so
  a stick held near centre stuck at full down/left. On Wine the axis is 0 either
  way.

Keeping these separate means the base commits can merge upstream on their own,
after which this branch rebases and they drop out by patch-id.
resolveRuntime returns wine without reading any container config when no
html5 container exists. The game page, its menu options, Open Container,
preLaunchApp and applyToContainerGated now resolve the runtime on IO; the
game page re-resolves it when applyToContainerGated reports a runtime
change, which now includes a first opt-in.
The wipe of drive_c/windows and Program Files was only guarded by the
storage dialog; it now checks the container's runtime before deleting.
A best-config map (import, server config, support suggestion) no longer
switches a container to or from the html5 variant; that stays with the
opt-in path and its fingerprint check. An imported html5 block now only
carries user settings onto this device's existing sidecar, keeping its
id, install path and layout, fingerprint, controls profile and decrypt
key; without a local sidecar it is dropped.
Reset applied the global defaults, whose variant is never html5, so the
game flipped to wine outside applyToContainerGated and then got a fresh
html5 sidecar anyway. An html5 game now resets with the html5 variant,
re-runs the opt-in before the wine container is written, and keeps the
decrypt key, the greenworks save route and the controls profile. The
reset runs on IO.
The JS bridges are exposed to every frame, so a document from outside the
game's loopback origin must never load. Main-frame navigations elsewhere
are blocked; an http(s) link the player taps opens in the browser.
Every html document the interceptors serve carries a CSP limiting frames,
objects and form targets to the game origin, data: and blob:, which covers
subframes that never reach shouldOverrideUrlLoading. XHR, fetch,
WebSocket and media requests are unchanged.
An html5 session now sends game_exited when its screen tears down, with
the game identity, runtime, session length and session id like the wine
event, plus the engine pack, system WebView package and version and
whether the renderer died when usage analytics is on. Wine-only fields
are left out. The wine game_exited gains runtime=wine.

game_runtime_switched (usage analytics only) fires when the runtime
actually changes: the game-page hint, the container dialog, or the
launch falling back to wine without OPFS support.
A new html5 controls profile now copies its on-screen elements from the
profile the wine path falls back to, so the layout looks the same in both
runtimes. The pack's gamepad->key synthesis is applied to the copied
bindings, as it already is for the physical controller, which keeps
electron's keyboard mapping and rmmv's Start/Select keys. Elements the
html5 input path can't deliver are left out. The per-pack overlay assets
are gone; back still opens the quick menu, so no extra menu button.
…5 games

Its two entries, physical controller mapping and touch gestures, are in
the in-game quick menu for html5 sessions exactly as for wine, and the
launch path already creates the controls profile on first run. Removes
the composables, the edited-state flag only the tab set, and their
strings in every locale.
… itself

Robolectric tests run on a bare Application, so the html5-containers root
was empty and every opt-in fixture failed to write its sidecar.
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.

5 participants