Skip to content

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

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

feat(html5): HTML5Runtime -- native WebView runtime for Chromium-based games#1570
jeremybernstein wants to merge 35 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 for higher performance and lower battery/thermal impact. Wine remains the default; titles opt in via engine fingerprinting with the same library, saves, achievements, and controller UX.

  • Bug Fixes
    • GOG Galaxy cloud sync now uses a Heroic‑compatible protocol (gzip uploads, +00:00 timestamps, ETag, DELETE on missing, GalaxyConfig.json client_id fallback).
    • Steam achievements: seed the bitmask from userStats.stats and subtract already‑earned on close‑time sync; applies to both Wine and HTML5 paths.
    • Steam Cloud: reconcile cache when completeAppUploadBatch returns EResult.Fail to prevent permanent conflict loops; benefits Wine and HTML5.
    • Preferences: DataStore moved to a private thread to avoid IO‑pool starvation deadlocks.
    • Controls: on‑screen joystick only emits directions past the deadzone to prevent stuck key/axis input.
    • Tests: LevelDB/IndexedDB save‑sync fixtures are included and forced binary; .gitignore keeps LevelDB .log files under fixture dirs to fix CI savesync failures.

Written for commit 3de8f68. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64501651-5b97-409c-8f38-0d889026d202

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Comment @coderabbitai help to get the list of available commands.

@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

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

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

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

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

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

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

@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?

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).
…dlock

getPref/setPrefBlocking run `runBlocking { dataStore.data.first() }`. when invoked
from an IO-dispatched coroutine (e.g. FrontendSyncManager.syncGame launches on
Dispatchers.IO then reads a pref) they block that IO thread waiting for the datastore
read -- which itself needed an IO thread. once concurrent readers reach the IO pool
size (64) the pool is fully starved and every reader parks forever.

surfaced as an intermittent robolectric suite hang (~1/3 runs): jstack showed all 64
IO workers parked in getPref under FrontendSyncManager.onInstallStatusChanged. latent
in production too (bulk install-status bursts). datastore now reads/writes on a
dedicated single thread, always runnable regardless of IO-pool pressure.
…E -- split to own PR]

handleShooterJoystickMove / handleRightJoystickMove (InputControlsView)
and STICK-type ControlElement.handleTouchMove fired isDown=true on ALL
4 GAMEPAD bindings every tick regardless of newStates[i]. on the html5
path that saturated KEY_UP/DOWN/LEFT/RIGHT keydowns simultaneously and
pinned virtualGamepad axis to whichever direction was dispatched last
(DOWN), making the joystick appear "stuck DOWN" / "inert".

gate by direction-past-deadzone, release explicitly when leaving.

NON-SCOPE relative to the html5 merge work on this branch -- bug exists
in master independently (PR utkarshdalal#599, Feb 2026). split this commit out for
its own PR before merging html5 work upstream.
…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.
…ron/rmmv

condense the GMS sub-frame tap-normalization comment (no behavior change).
inline process injector (and nw.js stub) lacked process.cwd. RMMV boot
diagnostics call process.cwd() at parse time -> TypeError -> black screen.
add cwd()->'/' to the inline injector (authoritative, runs first) + nw.js
parity backfill. fixes "Welcome to Elderfield Demo".
…ron/rmmv

the warn fired on the NOT-imported branch -- it cried wolf when a game simply
doesn't use a plugin we stub. reword to accurate text + demote warn->log.
…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.
…tainers

a language change fired downloadApp to fetch language-specific depots. html5 titles
have no per-language depot, so the depot language filter empties the selection and the
download wedges forever. gate the re-download on runtime != webview.
…ron/rmmv

RMMV/MZ canvas fit-to-window: RM scales-to-fit only when Graphics._stretchEnabled,
which Graphics.initialize sets false (we present as desktop NW.js). wrap initialize
to re-assert stretch (+ min-fit for YEP), then let the engine's own _updateRealScale
/_centerElement lay out. survives OMORI reassigning Graphics to a subclass.
suppress Chromium's default <video> placeholder (white field + play-circle) that
flashes a frame before autoplay: a document-start hook + observer set a 1x1 black
poster on any <video> lacking one. never clobbers a title's own poster. base shim.
radio audio (CGMZ Music Player, RMMZ) silently no-op'd: isSongFileExists's base
path.join(dirname(mainModule.filename), '/') collapsed to '.' instead of './',
fusing the concat to '.media/...' so fs.existsSync missed -> buzzer+return.
normalize now keeps the trailing slash (node parity). + ShimPathExecTest regression.
…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 "".
…ron/rmmv

RMMV uploads each spritesheet as one BaseTexture and animates via the UV
window, so the whole sheet must fit a single GPU texture. WebView caps
MAX_TEXTURE_SIZE at 4096; VisuStella inject-animation sheets exceed it
(6912px wide), fail texImage2D, and sample opaque black over the target.

per-cell: blit the current cell from the full-res 2D source for inject
animations (full sharpness). fallback: downscale oversized bitmaps to fit,
logical size preserved via baseTexture resolution, for static giants.
nw.App.dataPath: delegate non-argv reads to the concrete appStub.

the __gnNwArgv override branch (Steam/OMORI launch args) replaced window.nw
with a proxy that only handled argv and deep-proxied everything else, shadowing
appStub. Impact-engine titles (CrossCode) read nw.App.dataPath to build their
save-file path, so on Steam launches -- which emit an argv -- dataPath came back
as a marker string and saves landed at a garbage path that never reached the
wine prefix. GOG emits no argv, skips the branch, kept the working appStub.
…ll roots

Steam's API sometimes returns a save file with an empty prefix and the root
token embedded in the filename (e.g. "%WinAppDataLocal%cc.save"). getFullFilePath
only decoded this for %GameInstall% (upstream utkarshdalal#508), so any other root fell
through with the token intact and resolved to a literal-named file under the
Steam userdata/remote staging dir instead of the game's real save location.

CrossCode (uploadPath="" + WinAppDataLocal root) hit this: cloud downloads landed
in the staging dir, the game's read path stayed empty, and getFilesDiff then
deleted the correctly-placed local copy as an orphan -- losing the save. Affects
Wine and HTML5 identically (shared sync path); latent for any title with this
UFS shape.

Generalize the decode to any leading %Root% token, resolving via the game's own
cloudPrefixToLocalPath (already encodes path + rootoverride), else the raw root
path for a recognized PathType. %GameInstall% behavior is unchanged.
…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.
Comment thread iq80-leveldb/NOTICE.md

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.

Can we not have this as a separate library and pull this in as a submodule?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

can you explain why this is required?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

remove these changes please

@utkarshdalal

utkarshdalal commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Hi @jeremybernstein - there's some scope creep in here, please remove the changes to PrefManager.kt, TouchGestureConfig.kt / InputControlsView.java, PerformanceHudView.kt, and any files that have only formatting changes (EmulationTab, EnvironmentTab, DrivesTab, ControllerBindingDialog, ElementEditorDialog, ProfileDialog, AmazonInstallDialog, GameManagerDialog, WorkshopManagerDialog)

@utkarshdalal

Copy link
Copy Markdown
Owner

Most of the cloud-save code in here shouldn't need to exist. SyncFileFilter's own header explains why it does: the NW.js save root is the entire Chromium User Data profile (~91MB of Crashpad dumps, ShaderCache, GPUCache), so we're pointing cloud sync at a raw browser profile and then filtering the junk back out. Fix the input instead — stage just the save (IndexedDB/LocalStorage leveldb + cc.save) into a clean dir at launch/exit and sync that. Then SyncFileFilter, propagateDeletions, and applyRemoteTombstones all go away, and SteamAutoCloud/GOGCloudSavesManager don't get touched at all. That deletes most of the shared-code diff.

The rest of the cloud/achievement changes are real bugs, but pre-existing Steam/GOG ones this PR happened to surface — not HTML5 requirements. Split them into their own small PRs with tests, merge first, rebase HTML5 on top: the EResult.Fail reconcile, the bare-token addpath fix, the cache-present rehydrate, the achievement reseed, and the GOG client_id fallback. Bundled here they change behavior for every Wine user under a 65k-line diff and can't be reverted independently.

The GOG "protocol correctness" changes (deterministic gzip/md5/ETag) are no-ops — output is byte-identical to current code. Delete them.

@utkarshdalal

Copy link
Copy Markdown
Owner

Pre-existing Steam/GOG bugfixes that are real but shouldn't be buried here, own PRs with tests, merged first:

  • EResult.Fail reconcile, bare-token addpath fix, cache-present rehydrate (SteamAutoCloud)
  • achievement reseed from live stats (SteamService)
  • GOG client_id fallback (GOGManager)

I think the achievements are already fixed

@utkarshdalal

Copy link
Copy Markdown
Owner

We'd also need to rebuild libsnappyjava.so with 16KB page size for the modern build for the Play Store - if we can't do that then this whole feature will need to be gated out of the modern build. If we have the source, it should be easy to do this though.

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.

4 participants