feat(html5): HTML5Runtime -- native WebView runtime for Chromium-based games#1570
feat(html5): HTML5Runtime -- native WebView runtime for Chromium-based games#1570jeremybernstein wants to merge 35 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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? |
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.
056a7a8 to
c84dc3a
Compare
There was a problem hiding this comment.
Can we not have this as a separate library and pull this in as a submodule?
There was a problem hiding this comment.
can you explain why this is required?
|
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) |
|
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. |
|
Pre-existing Steam/GOG bugfixes that are real but shouldn't be buried here, own PRs with tests, merged first:
I think the achievements are already fixed |
|
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. |

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 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.GalaxyConfig.jsonclient_id fallback).userStats.statsand subtract already‑earned on close‑time sync; applies to both Wine and HTML5 paths.completeAppUploadBatchreturnsEResult.Failto prevent permanent conflict loops; benefits Wine and HTML5..gitignorekeeps LevelDB.logfiles under fixture dirs to fix CI savesync failures.Written for commit 3de8f68. Summary will update on new commits.