Repository navigation
feat(html5): HTML5Runtime -- native WebView runtime for Chromium-based games - #1570
jeremybernstein wants to merge 34 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueFinishing Touches
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. |
2f8652a to
0854d6f
Compare
|
here's a (lores) screen movie of a bunch of titles launching and running: |
| // 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. |
There was a problem hiding this comment.
Would be good to discuss the integration with steam in its various forms to understand the implications
There was a problem hiding this comment.
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).
| // 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. |
There was a problem hiding this comment.
Mentions a bug here in the comments
There was a problem hiding this comment.
Good catch, let me clean that up to the essentials.
| EPIC("EPIC_"), | ||
| AMAZON("AMAZON_"), | ||
| // Add other platforms here.. | ||
| ; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Oh weird. Yeah that's fine
| 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 | ||
| } |
There was a problem hiding this comment.
This seems like we're overloading what's already available in ContainerUtils?
There was a problem hiding this comment.
GameSource is IIRC the source of truth here, and ContainerUtils delegates to it. Or what specifically do you see being duplicated/overloaded?
There was a problem hiding this comment.
Have you tested out non-HTML5 games with the touch input? Just concerned about regressions
There was a problem hiding this comment.
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.
8a16a5e to
2e8c4aa
Compare
|
Hi, I hope it's okay of me to leave a comment. 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? |
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
|
|
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 :) |
|
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! |
|
@moi952 also let's speak on Discord as we're trying to do more testing for the Quest builds. |
I see there's a modern version, a legacy version, and a legacy XR version. What are the differences? |
|
@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). |
2e8c4aa to
c1f93bc
Compare
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. 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. 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? |
…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.
dc96da0 to
85ea8a7
Compare

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
runtimecolumn defaults towine, the feature sits behind aFeatureGateflag, 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
GameRuntimesplit; WebView suspends on activity background (Web Audio paused at both lifecycle boundaries).fsbridge <-> 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).drive_c/{windows,Program Files*}, preserves saves underdrive_c/users/).:iq80-leveldbmodule (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:c3pack:rmmvpack:nwjspack:electronpack:gmspack:godotpack:unitypack:tyranoPack identifier is the runtime, not the packaging format (
pack:c3covers C2/C3 even when NW.js-wrapped;pack:nwjsis reserved for Impact engines specifically).Scope notes
:iq80-leveldbmodule, 14-locale string files, and JS shim assets; the net app-logic surface isapp/gamenative/html5/*(~99 files) plus the runtime split.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:
process.platform = win32, Windows-styled env vars,C:/...translated into the prefix) precisely so the bridged files land at theAppData/ install paths the cloud already expects -- no per-title remapping.:iq80-leveldbreader/writer (Chromium IndexedDB uses a customidb_cmp1comparator and format edges the stock library mishandles).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:
+00:00timestamps, User-Agent,GalaxyConfig.jsonclient_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.completeAppUploadBatchreturnsEResult.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). OnlyuserStats.stats[].statValueholds the live earned bitmask.unlockTime[], so every sync re-uploaded the prior earned bits and silently undid any user-initiated reset. It now seeds fromuserStats.stats.Both achievement paths are shared: Wine and HTML5 both call
storeAchievementUnlocksandcloseApp'ssyncAchievementsFromGoldberg. 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
Limitations & Risks (with mitigations)
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.
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.
Shader precision on Adreno.
mediumpfp16 math produces banding/stripes on Adreno that desktop fp32 hides. Mitigation:shaderSourcehook promotesmediump -> highp(shipped; fixed Alabaster Dawn stripes + fuzzy shadows).Chromium LevelDB format edges. Save corruption risk where Chromium's IDB/LevelDB format meets a Java reader (
idb_cmp1comparator,.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.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.
VP8 webm cutscenes render as broken-image placeholders on devices without hardware VP8 (some Tyrano titles). Mitigation: documented limitation; transcode-to-VP9 workaround.
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.exeabsence (corrected over 3 iterations with native/web fixtures).Render Scale < 1 looks rough on some RMMV/nwjs titles. Mitigation: CSS
pixelatedinjection ships; deeper JS-shim fix (texParameteri / imageSmoothingEnabled) is follow-up.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.Back-compat. Mitigation:
runtimedefaults towine, all guards additive, feature behindFeatureGate; legacy suite green; Wine path is the no-regression bar.Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted...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
CrashHandlerwrapping itself on every app boot and double-dispatching crashes; it now installs once and dispatches once.commitFileUploadfor SHA-deduped blobs, and silent-rehydrates when cached content matches the cloud; decodes embedded and bare%Root%tokens in sync paths.userStats.statsso user resets aren't re-uploaded; only pushes newly earned ones.navigator.languageand Steam bridges; skips depot re-downloads on language changes for WebView.reclaimWineStoragerefuses to wipe a Wine container itself.game_exitedandgame_runtime_switchedtelemetry, mirroring the Wine event; new html5 controls profiles seed their on-screen layout from the default Wine profile.Refactors
:iq80-leveldb(pure-Java) with a per-thread snappy scratch fix and buildssnappy-javafrom source for 16 KB-aligned native libs; both are pinned as submodules under the GameNative org and checked out by CI.Written for commit 85ea8a7. Summary will update on new commits.