Skip to content

iOS support: JvmDowngrader build pipeline (MobiVM) + platform fixes#11190

Open
shoeless wants to merge 13 commits into
Card-Forge:masterfrom
shoeless:feature/ios-jvmdg-pipeline
Open

iOS support: JvmDowngrader build pipeline (MobiVM) + platform fixes#11190
shoeless wants to merge 13 commits into
Card-Forge:masterfrom
shoeless:feature/ios-jvmdg-pipeline

Conversation

@shoeless

@shoeless shoeless commented Jul 9, 2026

Copy link
Copy Markdown

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:

  1. JvmDowngrader (-c 52) rewrites Java 9–17 bytecode (records, List.of, String.repeat, switch expressions, string‑concat indy, …) down to Java 8.
  2. MobiVmBridge (a small ASM tool) remaps the Java‑8 library surface MobiVM lacks onto backports: streamsupport (java.util.function/stream/Optional), relocated ThreeTen as java.time, a hand‑built java.nio.file supply, and forge.compat.
  3. MobiVmLinkAudit is the merge gate — it resolves every java/javax member 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

  • Audio — enable music + sound effects; play in silent mode; fix mono‑MP3‑as‑static; fix a classic‑mode music crackle by routing effects through AVAudioPlayer (single audio clock domain) and suspending the otherwise‑unused OpenAL engine.
  • Rendering — fix device‑only red/blue color corruption at the source (skipPngCrush per‑resource in robovm.xml).
  • Adventure mode — now runs on iOS (gamepad dep, scene null‑guards, TenPatch force‑link, Box2D natives).
  • Input — keyboard fixes (delete key + duplicate characters, delivered via keyTyped on iOS).
  • Online/multiplayer — bundle JvmDowngrader util/* runtime helpers; guard ProcessHandle/UPnP for MobiVM gaps.
  • Serialization — work around RoboVM's AOT lambda plugin dropping FLAG_SERIALIZABLE (was crashing games with interacting static abilities) + ServiceLoader entry 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, a TextRenderer BreakIterator fallback, and an IOSAdapter audio‑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

  • Developer‑specific config (bundle id, code‑signing identity, device UDIDs) is not tracked — it lives in an untracked repo‑root .env; robovm.properties is generated from robovm.properties.template on first pipeline run.
  • This is a large, self‑contained addition (build pipeline + iOS backend); happy to split it if that helps review.

🤖 Generated with Claude Code

shoeless and others added 13 commits July 8, 2026 22:25
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>
@tool4ever

tool4ever commented Jul 9, 2026

Copy link
Copy Markdown
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:
a) User delivery - not sure this would be worth the effort to maintain if jailbroken device was a requirement...
would this also work if we provide instructions for something like Sideloadly?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants