iOS support: JvmDowngrader build pipeline (MobiVM) + platform fixes#11190
Open
shoeless wants to merge 13 commits into
Open
iOS support: JvmDowngrader build pipeline (MobiVM) + platform fixes#11190shoeless wants to merge 13 commits into
shoeless wants to merge 13 commits into
Conversation
Fixes required to run on iOS/RoboVM, all guarded to leave desktop and Android behavior unchanged: - ForgeProfileProperties: honor forge.ios.userDir/cacheDir system properties so data/cache land in the writable Documents sandbox instead of the read-only app bundle (image cache, prefs, downloads). - ImageCache: bypass AssetManager for downloaded images in Documents/cache (libGDX iOS can't load those FileHandles) - decode bytes to Pixmap, retain Pixmaps (iOS textures need them alive), LRU-capped texture cache sized by device RAM, RGB565 downscale to 512px; texture-to-path keying for border records (Texture.toString isn't unique); defer preloadCache to the GL thread (background-thread GL uploads render blank); invalidate cached card art when a texture loads from disk (previously only downloads triggered refresh). - Assets: HybridFileHandleResolver (absolute paths -> absolute, relative -> internal on iOS/Android); default image via internal(); RGB565/no-mipmap card texture parameter; LRU-capped cardArtCache; mana-icon sprite sheet loaded with R/B channel swap (iOS/Metal PNG channel order made red mana render blue). - FSkin/FSkinFont: resolve bundled skin/fonts/translations via internal() relative paths (absolute bundle paths fail on device: /var symlink + sandbox realpath -> silent fallback_skin with no sprite sheets = blank buttons/icons); font version marker moved to writable local storage; Linear text filtering for Retina. - LibGDXImageFetcher: resolve Scryfall API URLs to direct CDN URLs via the JSON API (RoboVM's OkHttp can't follow Scryfall's 302 redirects), connection/read timeouts, zero-byte download guard, widened catch so a RuntimeException can't silently kill a downloader thread. - adventure Config: resPath() returns ASSETS_DIR on iOS (desktop relative-path probes never match the app bundle). - TextRenderer: BreakIterator fallback with simple word breaking (RoboVM ships no ICU break-iterator data). - CachedCardImage: guard fetch() so a fetch failure can't kill the render thread. - HostedMatch: per-game System.gc() for GUI-hosted matches only (keeps a 4GB iPad under its jetsam ceiling across a long match; headless sims bypass HostedMatch and skip it). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runs UNMODIFIED upstream Java on MobiVM via the bytecode pipeline in forge-gui-ios/pipeline (JvmDowngrader -c 52 + MobiVmBridge -> streamsupport backports + app-supplied java.time/java.nio.file). Build against the transformed repo clone: mvn ... -Dmaven.repo.local=<repo>/tmp/ios-m2. - pom.xml: pipeline supply dependencies (streamsupport, streamsupport- cfuture, jvmdowngrader-java-api:1.3.6-mobivm, java-time-supply, java-nio-supply), sentry/jupnp/jetty exclusions, jupnp provided-scope for the IDeviceAdapter signature. - robovm.xml: force-link java8.**, xyz.wagyourtail.jvmdg.**, java.time.**, java.nio.file.**, forge.compat.**, UncheckedIOException, Assets$*/ImageCache$* nested classes; prune stale minlog/feature-only roots. - robovm.properties is now UNTRACKED (developer-specific bundle id); generated from robovm.properties.template + APP_ID in the untracked .env. Register your own App ID. - src/forge/compat: bytecode-rewrite targets for Java 8 members missing from MobiVM's Java-7-era runtime that streamsupport doesn't cover (String.join/chars, Math exact ops, boxed statics/unsigned, BufferedReader.lines, File.toPath, Files.walk, FileChannel.open, Matcher named groups, Date.from, ThreadLocal.withInitial, Predicate.isEqual, jvmdg's StringBuilder.append(byte) record-toString quirk) plus a java.lang.management remap suite (apfloat/tinylog probe it at boot). Referenced only from rewritten bytecode, never source. - Main.java: upstream API-drift updates (getApp signature, jupnp-typed UPnP stub, convertToPNG), audio-format override (interface default's Set.of field doesn't initialize on MobiVM), physical-RAM logging. - sentry-stub: Breadcrumb class + removeExtra/addBreadcrumb(Breadcrumb) (upstream now calls these on hot game paths). - Port from the ios-java8-compatibility fork: os_log wrapper + System.out/err redirection (iOS 26 log redaction), fallback skin, build-versioned card-cache clearing, NSTimeZone init, Info.plist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tracked home for the tooling that lets unmodified upstream Java run on MobiVM's Java-7-era runtime (previously living untracked in tmp/): - ios-pipeline.sh: single entry point (classpath | sim | device). Self-bootstraps third-party jars (JvmDowngrader, streamsupport, ThreeTen) on first run. No machine- or developer-specific values: device UDIDs, signing identity, provisioning profile, team id, and the bundle identifier all come from the untracked repo-root .env. - src/MobiVmBridge.java: ASM bytecode rewriter - whole-type remaps to streamsupport (java8.*), hierarchy-aware member redirection for Java 8 defaults/statics missing from MobiVM (rules in bridge.cfg), method-reference Handle rewriting in indy bootstrap args, jar entry renaming for relocations. - src/MobiVmLinkAudit.java: resolves every member reference in the final jars against the real runtime + supplies; its report is the complete porting workload after an upstream merge. - bridge.cfg / relocate-time.cfg: the rewrite rule sets. - java-supply/: minimal java.nio.file + java.io.UncheckedIOException implementations compiled with --patch-module (MobiVM lacks the package entirely; call sites are hot at boot). - README.md: architecture, usage, and the hard-won integration facts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
forge-gui-ios/pom.xml depends on six local supply jars that a fresh clone could not previously build or resolve. Close the gap: - Track sentry-stub/build.sh (builds + installs the io.sentry no-op stubs; mvn now runs from the repo root so the tracked cwd-relative .mvn/maven.config resolves from anywhere) - Track the java.util.function stub sources (pipeline/java-function-stubs/): MobiVM's own Comparator signatures reference these types but its runtime omits the package, crashing serialization (EnumMap.readObject) - bootstrap() now builds both stub jars (mtime-guarded) and installs every pom supply into ~/.m2 idempotently: streamsupport, streamsupport-cfuture, the jvmdg api jar, java-nio-supply, java-function-stubs, and an unrelocated ThreeTen as the java-time-supply compile placeholder - README: document the fresh-clone bootstrap (JDK 17 + Maven + Xcode + .env is now the complete prerequisite list) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On physical-device builds (never simulator), RoboVM runs Apple's `pngcrush -iphone` on every bundled .png, converting them to CgBI format: BGRA byte order + premultiplied alpha. libGDX decodes with stb_image, which does not un-swap CgBI, so every bundled PNG rendered with R and B exchanged on device - blue buttons/logo showed yellow/gold (#0A5C79 -> #795C0A, logo #10E0F0 -> #F0E010), and FSkinColor theme colors pixel-sampled from the crushed sprites were corrupted the same way (masked only because the default palette swatches are swap-invariant grays). Mana icons were the previously-visible symptom, patched at runtime by a per-file channel swap. Fix the cause instead: set skipPngCrush on the libGDX-consumed <resource> blocks in robovm.xml - the only place RoboVM honors it (Resource.isSkipPngCrush, consumed by IOSTarget.processFile; verified in robovm-dist-compiler 2.3.24 bytecode). The <skipPngCrush> that sat in pom.xml's plugin <configuration> was a silent no-op: robovm-maven-plugin declares no such parameter, so Maven ignored it. App icons (Icon*.png) stay crushed - SpringBoard consumes those and handles CgBI. Remove the now-harmful runtime mana-icon swap machinery from Assets.java (swapRedBlueChannels / getTextureWithColorFix / colorFixedPixmaps / colorFixedTexturePaths + getTexture/loadTexture interceptions): with uncrushed PNGs it would double-swap on device, and it keyed on ApplicationType.iOS, which had been silently inverting mana icons in the simulator (where PNGs were never crushed) all along. Skipping pngcrush also removes its alpha premultiplication, which no runtime swap ever undid. Verified: all 855 libGDX-consumed PNGs in the device bundle are free of the CgBI chunk (only the 10 Icon*.png remain crushed, intentionally); device shows blue buttons/logo and correct mana colors; simulator mana icons now render correctly too (red was showing as blue there pre-fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emapping Two runtime bugs, both pipeline-level, found via an in-game crash on the "Report a crash" screen (AI v AI Commander, sim and device identically): 1. Every game with interacting static abilities crashed at start: ExceptionInInitializerError at org.jgrapht.graph.DefaultDirectedGraph (GameAction.findStaticAbilityToApply), caused by ClassCastException: SupplierUtil$$Lambda$14 cannot be cast to java.io.Serializable. RoboVM's AOT lambda plugin implements only FLAG_MARKERS/FLAG_BRIDGES of LambdaMetafactory.altMetafactory and silently drops FLAG_SERIALIZABLE (verified in robovm-dist-compiler 2.3.24 bytecode; no writeReplace/ SerializedLambda machinery exists in it at all), so serializable lambdas - `(Supplier<T> & Serializable) X::new`, all over ECJ-compiled jgrapht - come out NOT implementing Serializable and the compiler-emitted `checkcast java/io/Serializable` throws. Even untransformed upstream jgrapht would fail on RoboVM. MobiVmBridge now converts the ignored bit into an explicit FLAG_MARKERS java/io/Serializable marker, which RoboVM honors (192 sites fixed across the classpath; restores type-compatibility only - actually serializing such a lambda still throws, nothing does). 2. The crash screen's Save button silently did nothing: ZoneRulesException "No time-zone data files registered" from LocalDateTime.now() - the relocated ThreeTen java.time supply never registered its zone-rules provider because the META-INF/services entry kept its pre-relocation name and content, so ServiceLoader (which looks up the RELOCATED interface name) found nothing. MobiVmBridge now remaps services entry names and impl lines through the type rules (15 more entries fixed in the main pass). MobiVmLinkAudit gains matching gates so upstream merges can't silently regress either: it fails on any altMetafactory site with FLAG_SERIALIZABLE but no explicit Serializable marker, and flags META-INF/services entries naming classes that exist nowhere on the final classpath. The unrelocated java-time-supply compile placeholder is excluded from transform/audit (the relocated supply replaces it in the build clone). Verified in the simulator: Estrid/Adaptive Enchantment AI v AI Commander game (previously died at game start) runs past turn 5; user-verified multiple games to completion on device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adventure mode had never run on iOS (the pre-pipeline fork predates upstream's controller support and never got past the splash either). Four independent blockers, found by walking the crash ladder in the simulator: 1. ClassNotFoundException: com.badlogic.gdx.controllers.IosControllerManager Forge.openAdventure -> Controllers.addListener reflectively loads the iOS controllers backend, but gdx-controllers-ios was never a dependency of forge-gui-ios (only gdx-controllers-core via forge-gui-mobile). Added 2.2.4 (Java-7 bytecode, lands in the pipeline KEEP set) + the GameController framework in robovm.xml. 2. NPE at Scene$SceneControllerListener.connected(Scene.java:19): IosControllerManager fires connected() during Controllers initialization for already-present controllers - before any scene exists, so Forge.getCurrentScene() is null. Null-guarded all five listener methods (upstreamable: any platform would crash if a controller connected before the first adventure scene). 3. ClassNotFoundException: com.ray3k.tenpatch.TenPatchDrawable while loading res/adventure/common/skin/ui_skin.json: the skin references TenPatchDrawable by NAME (libGDX skin JSON), invisible to RoboVM's reachability analysis -> tree-shaken. forceLink com.ray3k.tenpatch.**. 4. UnsatisfiedLinkError: c.b.g.physics.box2d.World.newWorld: the Box2D Java classes ship via forge-gui-mobile but the iOS NATIVES were never linked. Added gdx-box2d-platform:natives-ios, whose embedded META-INF/robovm/ios config + xcframework (device arm64 + sim arm64/x86_64) RoboVM merges automatically. Verified in the simulator end-to-end: mode selector -> adventure menu -> character creation -> world generation -> tutorial dungeon renders and the opening quest advances. Also: ios-pipeline.sh now lets explicitly exported env vars (SIM_UDID etc.) take precedence over .env values, so builds can target a secondary simulator without editing .env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio was disabled on iOS (config.useAudio=false) to dodge an OpenAL-init crash in the fork's early Java-8 port. That reason no longer applies: ObjectAL.framework is now properly packaged (x86_64 + arm64) via gdx's natives, and the app boots cleanly with audio enabled. All Forge audio ships as .mp3, which ObjectAL decodes for both sound effects (preloadEffect via AudioToolbox) and music (OALAudioTrack via AVAudioPlayer). - Main.java: config.useAudio = true. - Main.java isSupportedAudioFormat: implement directly instead of the interface default (whose Set.of static field NPEs on MobiVM). Returns true for mp3/m4a/aac/wav/caf/aiff; excludes .ogg (no native iOS Vorbis decoder). This gate is what lets SoundSystem discover playable files - it had to return false while audio was off. Harden the one audio path that isn't already null-safe (it crashed the app during adventure testing: getMusic threw "iOS audio is not enabled" uncaught from the render loop when a dialog voice file loaded): - Assets.getMusic: wrap the synchronous load in try/catch, return null on failure instead of propagating. Callers already treat null as "no audio". - MapDialog: only build the audio pair when getMusic returns non-null (previously would NPE on the Pair's null Music). - AudioMusic.isPlaying: null-guard to match every other method. Verified in the simulator: app boots with audio enabled, a full Commander game and adventure run with no audio load failures or exceptions - and music is audible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The iOS audio path (ObjectAL/AVAudioPlayer) renders mono MP3s as static noise; stereo files play correctly. Exactly 7 of 92 bundled audio files were mono, including all four Shandalar wizard-dialog voice clips (WizPAR1-4) - the 10s voice made the static obvious, while the mono SFX (button_press 0.17s, sprocket, daytime) were short enough to mask it. Re-encoded each in place to 2-channel stereo (mono signal duplicated to both channels; verified L/R RMS identical), same .mp3 filenames and comparable bitrates. Stereo is universal, so no change in behavior on desktop/Android. Device-independent content fix. Wizard voice verified clean on iOS (was static, now plays correctly). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ied) - Main.java: config.overrideRingerSwitch = true. ObjectAL honors the device silent/ringer switch by default (honorSilentSwitch = !overrideRingerSwitch), so game audio was completely silent on a device in silent mode - the simulator ignores the switch, which masked it. A game should play regardless of the ringer switch. (Device-verified: sound now plays.) - Forge.java: on iOS, exit/restart return to the main menu instead of the no-op that hung the app. iOS apps can't self-terminate (App Store guideline / HIG), so the device adapter's exit()/restart() are no-ops - the normal exit flow left the app stuck on the ClosingScreen, forcing a force-quit. Now adventure exit -> classic home (switchToClassic), classic exit -> home. (Device-verified: exiting works.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-verified progress) Hosting an online game crashed in sequence on the jvmdg/MobiVM gaps: 1. NoClassDefFoundError: xyz/wagyourtail/jvmdg/util/Utils. jvmdg's downgradeApi emits the runtime API stubs but omits the util/* runtime helpers those stubs call (Utils, Function, Pair, IOFunction); the pipeline bundled only exc/*. NetworkLogWriter was the first path to reach util/Utils. Bundle util/* too (15 Java-7 classes, no java-8-gap APIs, MobiVM-safe raw). NOT version//runtime/ (runtime-downgrader machinery that drags in ~850KB of shaded ASM, unused in static mode). 2. NoSuchMethodError: MethodHandles.lookup() via ProcessHandle.current(). NetworkLogWriter.writeSystemInfoHeader logs the PID (Java 9 ProcessHandle); jvmdg's stub needs MethodHandles trusted-lookup, which MobiVM lacks. The catch(IOException) missed this Error. Guard the PID lookup and make the whole diagnostic-header write catch Throwable - it's non-essential and must never break hosting. Both device-verified: hosting now progresses past these to later stages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keyboard (device-verified): on iOS the software keyboard delivers one
keyTyped per tap and routes backspace (0x08) / delete (0x7F) through
keyTyped rather than keyDown. Two upstream behaviors in
Forge.MainInputProcessor.keyTyped broke this:
- the printable-ASCII filter (' '..'~') dropped delete, so backspace did
nothing in text fields (Settings/Deck Manager search);
- the consecutive-character de-dup (lastKeyTyped/keyTyped) blocked repeated
letters ("aa" in "Kaalia") because iOS gives no keyUp to reset it.
Gate the iOS path to pass ' '..'~' plus \b/� straight through with no
de-dup; Android/desktop keep the existing de-dup unchanged. FTextField
.keyTyped now handles \b/� as deletion (mirrors its keyDown BACKSPACE).
UPnP (device-verified): hosting an online game crashed with
NoClassDefFoundError: org/jupnp/support/model/PortMapping. UPnP port
mapping via jupnp (provided scope) isn't available on iOS/MobiVM. It's
optional - LAN/direct hosting works without it - so wrap mapNatPort in
catch(Throwable) to degrade gracefully instead of killing hosting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The background music (AVAudioPlayer) developed a slow periodic crackle (~14-32s, scaling with music volume, present even when paused) whenever the OpenAL sound-effects engine was active. Root cause: OpenAL and AVAudioPlayer are two independent CoreAudio clients whose render cycles beat against each other; matching sample rates only shifted the beat period, never removed it. Fix - collapse everything to one clock domain: - AudioClip: on iOS, play sound effects through libGDX Music (AVAudioPlayer / mediaserverd), the same engine as the music, instead of Sound (OpenAL). This is the only Gdx.audio.newSound call site, so it covers all effects. Also drop a re-trigger while the (single per-type) player is still playing to avoid machine-gun truncation, skip the 30ms OpenAL voice-reuse delay on iOS, and evaluate the iOS check per-instance so it can't strand effects on the suspended engine. - Main.didBecomeActive: permanently suspend the OpenAL engine libGDX auto-initializes (its active 3D-mixer render cycle is what beats against mediaserverd). OpenALManager.setManuallySuspended(true). - OpenALManager: minimal RoboVM ObjC binding to ObjectAL's OpenALManager (sharedInstance, manuallySuspended). iOS-only; desktop/Android keep the OpenAL Sound path unchanged. Device-verified (diagnostic reported manuallySuspended=true and the crackle is gone). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
|
Nice work, obviously rather huge but hardly unexpected. A bit unfortunate there's still a few workarounds needed in the neighbour modules but tbh Android is probably just as guilty of that too 😆 Anyway three main points we need to think about: b) CI/CD - can your pipeline be integrated into our normal GitHub actions? if it's build time is way longer than the other platforms maybe only as release though c) Not having Sentry is a real loss, any chance to get that working somehow? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds working iOS support for Forge. Instead of hand-backporting modern Java to MobiVM's Java‑7‑era runtime, it transforms the built jars so unmodified upstream Java runs on iOS:
-c 52) rewrites Java 9–17 bytecode (records,List.of,String.repeat, switch expressions, string‑concat indy, …) down to Java 8.streamsupport(java.util.function/stream/Optional), relocated ThreeTen asjava.time, a hand‑builtjava.nio.filesupply, andforge.compat.java/javaxmember reference in the final jars against the real runtime + supplies, so an upstream merge that introduces a new Java‑8 gap is caught at build time rather than as a device crash.The pipeline lives under
forge-gui-ios/pipeline/(ios-pipeline.sh [classpath|sim|device]) and bootstraps its third‑party jars automatically.iOS platform fixes
skipPngCrushper‑resource inrobovm.xml).keyTypedon iOS).util/*runtime helpers; guardProcessHandle/UPnP for MobiVM gaps.FLAG_SERIALIZABLE(was crashing games with interacting static abilities) +ServiceLoaderentry remapping (fixes time‑zone data registration).Shared/upstream source is otherwise unchanged except a few genuinely‑iOS platform hooks (adventure resource path when
Gdx.app.getType()==iOS, aTextRendererBreakIteratorfallback, and anIOSAdapteraudio‑format override).Testing
Device‑verified on physical iPads (iOS 16 and iOS 26): boots, plays full games vs. AI, adventure mode, audio, and online hosting (online hosting is buggy will fix in upcoming PR). Also runs in the iOS Simulator (same RoboVM build).
Notes for reviewers
.env;robovm.propertiesis generated fromrobovm.properties.templateon first pipeline run.🤖 Generated with Claude Code