Skip to content

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

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

iOS support: JvmDowngrader build pipeline (MobiVM) + platform fixes#11190
shoeless wants to merge 36 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?

shoeless and others added 7 commits July 11, 2026 00:20
… fix)

An online guest crashed the instant it tapped a discovered server to join.
Live device stack (idevicesyslog):

  ExceptionInInitializerError
    at NetConnectUtil.getPrioritizedServerUrls (Stream.toList())
    at OnlineLobbyScreen.lambda$joinDiscoveredServer$0
  Caused by: ClassNotFoundException: java.util.stream.ReferencePipeline
    at xyz.wagyourtail.jvmdg.j16.stub.java_base.J_U_S_Stream.<clinit>

jvmdg's -c 52 downgrade rewrites every Stream.toList() (Java 16) into a call
on its J_U_S_Stream stub, whose static initializer reflectively loads
java.util.stream.ReferencePipeline and rethrows. MobiVM has no
java.util.stream.* (jvmdg relocates streams to java8.util.stream via
streamsupport), so the FIRST toList() actually executed on device dies. The
LAN/Tailscale discovery join path was the first to hit it; BondAi (soulbond)
is the other reachable caller. bridge.cfg's `type java/util/stream/ →
java8/util/stream/` rule remaps the stream TYPE but nothing mapped the jvmdg
J_U_S_Stream stub METHODS, so toList() fell through to the crashing stub.

Fix: add a bridge member rule redirecting J_U_S_Stream.toList to a
self-implemented forge.compat.JStream8.toList (drains the stream via its
iterator; must not call toList() itself or the bridge would rewrite it back
into a self-loop). Written against java8.util.stream.Stream so its descriptor
matches the redirected caller exactly. Verified: J_U_S_Stream.toList leaves the
bridge-unresolved report, and the device build links + installs cleanly (the
audit's MISSING-MEMBER for the app-side helper is the same benign false-positive
that the proven-working forge.compat.JString8.join shows).

An audit of the other forge-reachable unresolved jvmdg stubs (Predicate.not,
Optional.isEmpty, Objects.requireNonNullElseGet, CompletableFuture
.completeOnTimeout) found none with the broken-clinit shape — they have no
static initializer and are safe. The incomplete io.sentry.protocol Device/
OperatingSystem stubs are only invoked when GuiBase.hwInfo != null, which is
set on Android only, so they are inert on iOS. No other J_U_S_Stream-class
landmine remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m forge code

The J_U_S_Stream.toList crash (guest-join) was a jvmdg stub method reachable
from forge code with no bridge rule — its <clinit> loaded a MobiVM-absent class
and threw on first use. The linkage audit reported it but the pipeline ran with
`|| true`, so it shipped. This adds a real gate.

MobiVmBridge now scans its BRIDGE-UNRESOLVED set for jvmdg stub METHODs that
have a forge/* referrer and are not on a small GATE_ALLOWLIST of verified-safe
stubs (Predicate.not, Optional.isEmpty, Objects.requireNonNullElseGet,
CompletableFuture.completeOnTimeout — audited: no broken clinit). Any other such
method is emitted as BRIDGE-GATE-FAIL, and ios-pipeline.sh greps for that marker
after the bridge step and exits 1 with the offending members + fix instructions
(add a bridge.cfg member rule -> forge.compat helper, or allowlist after
verifying the stub's clinit is MobiVM-safe).

Verified: passes clean on the current (fixed) tree; trip-test removing the
JStream8 rule fails the build naming exactly J_U_S_Stream.toList <- BondAi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rkflows

Integrates the iOS build pipeline with GitHub Actions, two-tier:

- ios-compat-gate.yml (ubuntu, every PR touching Java/poms, ~minutes): new
  'audit' pipeline mode runs the full JvmDowngrader transform + MobiVmBridge +
  linkage gate WITHOUT any Apple tooling and fails when code reachable from
  Forge would crash on MobiVM (unbridged jvmdg stub / missing API). Lets anyone
  catch 'modern Java API breaks iOS' regressions per-PR without owning a Mac.
- ios-build-ipa.yml (macos-15, manual dispatch / release-time): new 'ipa' mode
  runs the full RoboVM AOT build with -Drobovm.iosSkipSigning=true and uploads
  an UNSIGNED forge-ios.ipa artifact. No certificates or jailbreak anywhere:
  users install it with Sideloadly/AltStore, which re-sign with their own
  (free) Apple ID. RoboVM SDK + AOT cache are actions-cached; SKIP_CACHE_CLEAR
  reuses the content-hashed AOT cache for fast rebuilds.

Pipeline script changes: portable in-place sed (GNU vs BSD) and APFS-clone
fallback to plain cp for Linux runners; the app-module bridge report now goes
through the same BRIDGE-GATE-FAIL check as the dependency pass; module install
includes the parent POM ('-pl .') so fresh CI clones resolve the ${revision}
parent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing; scope iOS file-path behavior to iOS only

Addresses review feedback about iOS workarounds bleeding into neighbour modules.
Adds GuiBase.setIsIOS()/isIOS() — the exact mirror of the existing Android
launcher flag — set by forge.ios.Main, and replaces every raw
Gdx.app.getType() == ApplicationType.iOS sniff in forge-gui-mobile with it
(Forge exit-flow + keyTyped, adventure Config.resPath, AudioClip, Assets,
FSkin). Several PR-added imports drop back out of shared files as a result.

Also FIXES a latent Android regression this PR would have shipped: the
internal()-path rerouting in FSkin.getFileHandle / Assets.getFileHandle /
HybridFileHandleResolver applied to Android too, but Android's assets are
extracted to storage and are NOT reachable via internal() APK paths — on
master Android always resolved these absolutely. The internal() behavior is
now scoped to iOS only (where the app bundle's /var symlink breaks absolute()
inside the sandbox); Android behavior is bit-identical to master again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on GuiBase.isIOS()

Continues the neighbour-module cleanup, fixing three more cases where
iOS-motivated changes silently altered other platforms' behavior:

- FSkinFont: translation-file and .fnt loading had been switched to internal()
  relative paths UNCONDITIONALLY — on Android those assets live in extracted
  storage (not APK-internal), so CJK glyph preloading and prebuilt-font lookup
  would silently fail. Both now route through FSkin.getFileHandle (iOS-only
  internal(), original absolute() elsewhere). The Nearest->Linear font-texture
  filter change is now iOS-only too (Retina smoothing); other platforms keep
  master's crisp Nearest filtering.
- ImageCache.toRelativePath rewrote any path containing /Documents/ or
  /Library/ — on desktop macOS the cache legitimately lives under ~/Library,
  so mobile-dev image loading would corrupt paths. All Documents-cache texture
  paths (direct-Pixmap load, downloaded-texture cache) are now explicitly
  GuiBase.isIOS()-gated instead of path-sniffed.
- LibGDXImageFetcher: the Scryfall JSON-API resolve (RoboVM can't follow the
  302 redirects) ran for every platform, doubling Scryfall API requests per
  image on Android/desktop where redirects work natively. Now iOS-only.

TextRenderer's BreakIterator fallback and FTextField's keyTyped handling stay
ungated by design: they are adaptive/platform-neutral (the fallback only
triggers where ICU is missing; control chars only arrive via keyTyped on iOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/robovm/)

Validated locally end-to-end: the ipa mode produces a 234MB
forge.ios.Main.ipa with an ad-hoc (identity-less) signature — Signature=adhoc,
TeamIdentifier not set, no embedded.mobileprovision — which is exactly the
form Sideloadly/AltStore accept and re-sign with the user's own Apple ID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an iOS (MobiVM/RoboVM) build pipeline that downgrades/bridges upstream Java bytecode to a Java-7-era runtime, plus a set of iOS-specific runtime/platform fixes (filesystem, rendering, input, audio, networking, logging) to make Forge run on-device and in the simulator.

Changes:

  • Introduce forge-gui-ios/pipeline/ tooling (JvmDowngrader + bridge + link audit) and CI workflows to continuously validate iOS/MobiVM compatibility.
  • Add iOS backend module code (launcher, logging, OpenAL suspension, compat shims/stubs) and RoboVM config/resources.
  • Apply iOS-targeted fixes across shared/mobile GUI for paths, textures/memory, input behavior, audio routing, and optional networking diagnostics.

Reviewed changes

Copilot reviewed 96 out of 114 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
forge-gui/src/main/java/forge/localinstance/properties/ForgeProfileProperties.java Allow iOS launcher to supply writable user/cache dirs via system properties.
forge-gui/src/main/java/forge/gui/GuiBase.java Add explicit iOS platform flag for shared branching.
forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java Make UPnP mapping degrade gracefully when UPnP classes/platform service are unavailable.
forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java Make diagnostic header resilient to missing modern APIs on iOS/MobiVM.
forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java Add explicit GC at end of GUI-hosted games to mitigate iOS memory pressure.
forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java Add iOS Scryfall redirect workaround + improve download robustness/logging.
forge-gui-mobile/src/forge/toolbox/FTextField.java Fix iOS delete/backspace delivered through keyTyped.
forge-gui-mobile/src/forge/sound/AudioMusic.java Null-guard music playback state.
forge-gui-mobile/src/forge/sound/AudioClip.java Route iOS SFX through AVAudioPlayer-backed Music to avoid OpenAL clock-domain crackle.
forge-gui-mobile/src/forge/Forge.java Adjust iOS exit/restart flow + iOS keyboard keyTyped handling.
forge-gui-mobile/src/forge/CachedCardImage.java Prevent image fetch failures from crashing the render thread on iOS.
forge-gui-mobile/src/forge/assets/TextRenderer.java Add BreakIterator fallback when ICU resources are missing on iOS/RoboVM.
forge-gui-mobile/src/forge/assets/ImageCache.java Add iOS texture/pixmap caching + downscaling and render-thread texture creation safeguards.
forge-gui-mobile/src/forge/assets/FSkinFont.java Use iOS-compatible FileHandle lookups + adjust font texture filtering on iOS.
forge-gui-mobile/src/forge/assets/FSkin.java Add iOS bundle/internal FileHandle support and iOS-specific skin/font marker handling.
forge-gui-mobile/src/forge/assets/Assets.java Add hybrid resolver for internal vs absolute resources; add card texture parameter and LRU card art cache; harden music loading.
forge-gui-mobile/src/forge/adventure/util/MapDialog.java Guard against null music when loading voice audio.
forge-gui-mobile/src/forge/adventure/util/Config.java Use ASSETS_DIR on iOS (and Android) for resource path resolution.
forge-gui-mobile/src/forge/adventure/scene/Scene.java Null-guard scene callbacks during early controller initialization on iOS.
forge-gui-ios/src/forge/ios/OSLogPrintStream.java Add os_log / NSLog bridging so logs are visible on iOS 26+ and older iOS versions.
forge-gui-ios/src/forge/ios/OpenALManager.java Add minimal ObjC binding to suspend OpenAL engine on iOS.
forge-gui-ios/src/forge/ios/Main.java Implement iOS launcher setup (dirs, timezone, cache invalidation, memory warning handling, logging redirection, OpenAL suspension).
forge-gui-ios/src/forge/ios/ForgeOSLog.java Provide native bridges for os_log entrypoints.
forge-gui-ios/src/forge/compat/package-info.java Document compat rewrite targets for the iOS pipeline.
forge-gui-ios/src/forge/compat/mgmt/RuntimeMXBean.java Provide java.lang.management whole-type remap interface.
forge-gui-ios/src/forge/compat/mgmt/package-info.java Document java.lang.management remap strategy.
forge-gui-ios/src/forge/compat/mgmt/MemoryUsage.java Provide java.lang.management.MemoryUsage remap class.
forge-gui-ios/src/forge/compat/mgmt/MemoryMXBean.java Provide java.lang.management.MemoryMXBean remap interface.
forge-gui-ios/src/forge/compat/mgmt/ManagementFactory.java Provide Runtime-backed management factory for boot-time probes.
forge-gui-ios/src/forge/compat/JThreadLocal8.java Bridge target for ThreadLocal.withInitial().
forge-gui-ios/src/forge/compat/JString8.java Bridge targets for Java 8 String/CharSequence members.
forge-gui-ios/src/forge/compat/JStream8.java Bridge target for Stream.toList() to avoid jvmdg stub crash on MobiVM.
forge-gui-ios/src/forge/compat/JRegex8.java Bridge targets for Matcher named-group API.
forge-gui-ios/src/forge/compat/JMisc8.java Bridge targets for assorted Java 8+ APIs used by upstream deps.
forge-gui-ios/src/forge/compat/JMath8.java Bridge targets for Java 8 Math statics.
forge-gui-ios/src/forge/compat/JIo8.java Bridge target for BufferedReader.lines().
forge-gui-ios/src/forge/compat/JFunction8.java Bridge target for missing Predicate.isEqual.
forge-gui-ios/src/forge/compat/JBoxed8.java Bridge targets for boxed primitive statics/methods.
forge-gui-ios/sentry-stub/src/io/sentry/Sentry.java Add no-op Sentry facade to satisfy shared code on iOS where real SDK is excluded.
forge-gui-ios/sentry-stub/src/io/sentry/ScopeType.java Stub enum for Sentry API compatibility.
forge-gui-ios/sentry-stub/src/io/sentry/ScopeCallback.java Stub interface for Sentry API compatibility.
forge-gui-ios/sentry-stub/src/io/sentry/protocol/SentryId.java Stub SentryId type.
forge-gui-ios/sentry-stub/src/io/sentry/protocol/OperatingSystem.java Stub OperatingSystem type.
forge-gui-ios/sentry-stub/src/io/sentry/protocol/Device.java Stub Device type.
forge-gui-ios/sentry-stub/src/io/sentry/IScope.java Stub IScope interface.
forge-gui-ios/sentry-stub/src/io/sentry/Hint.java Stub Hint type.
forge-gui-ios/sentry-stub/src/io/sentry/Contexts.java Stub Contexts type.
forge-gui-ios/sentry-stub/src/io/sentry/Breadcrumb.java Stub Breadcrumb type.
forge-gui-ios/sentry-stub/build.sh Script to build/install the Sentry stub jar into Maven local repo.
forge-gui-ios/sentry-stub/.gitignore Ignore stub build artifacts.
forge-gui-ios/robovm.xml Update RoboVM config for arm64, resources, force-linking, frameworks, and pngcrush behavior.
forge-gui-ios/robovm.properties.template Add tracked template for untracked per-developer robovm.properties generation.
forge-gui-ios/robovm.properties Remove tracked robovm.properties (now generated/untracked).
forge-gui-ios/pom.xml Add RoboVM plugin + iOS deps (controllers/box2d/freetype natives) + exclude incompatible deps + add supplies/stubs.
forge-gui-ios/pipeline/src/MobiVmLinkAudit.java Add linkage audit tool to gate missing classes/members and service registrations.
forge-gui-ios/pipeline/src/MobiVmBridge.java Add bridge/rewriter tool to remap missing Java 8 APIs + fix serializable-lambda markers + remap META-INF/services.
forge-gui-ios/pipeline/relocate-time.cfg Config for relocating ThreeTen into java.time.*.
forge-gui-ios/pipeline/README.md Document the iOS modern-Java pipeline and usage.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/SupportTypes.java Anchor file for supplied java.nio.file package.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardOpenOption.java Supply StandardOpenOption for MobiVM.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardCopyOption.java Supply StandardCopyOption for MobiVM.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/SimpleFileVisitor.java Supply SimpleFileVisitor for MobiVM.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/Paths.java Supply Paths.get for MobiVM.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/Path.java Supply minimal Path interface for MobiVM.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/OpenOption.java Supply OpenOption interface.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/NoSuchFileException.java Supply NoSuchFileException.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/LinkOption.java Supply LinkOption enum.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/InvalidPathException.java Supply InvalidPathException.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/ForgeFilePath.java File-backed Path implementation.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitResult.java Supply FileVisitResult.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitor.java Supply FileVisitor.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitOption.java Supply FileVisitOption.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystems.java Supply FileSystems.getDefault.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystemException.java Supply FileSystemException.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystem.java Supply minimal FileSystem.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/Files.java Supply key java.nio.file.Files operations backed by java.io.File.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileAlreadyExistsException.java Supply FileAlreadyExistsException.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/CopyOption.java Supply CopyOption interface.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipalLookupService.java Supply user principal lookup surface.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipal.java Supply UserPrincipal.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/ForgeFileAttributes.java File-backed BasicFileAttributes implementation.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileTime.java Supply FileTime.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileAttribute.java Supply FileAttribute.
forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/BasicFileAttributes.java Supply BasicFileAttributes.
forge-gui-ios/pipeline/java-supply/src/java/io/UncheckedIOException.java Supply Java 8 UncheckedIOException.
forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToLongFunction.java Supply minimal java.util.function stub for MobiVM’s Comparator signature needs.
forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToIntFunction.java Supply minimal java.util.function stub.
forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToDoubleFunction.java Supply minimal java.util.function stub.
forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/Function.java Supply minimal java.util.function stub.
forge-gui-ios/pipeline/ios-pipeline.sh Add full pipeline script (bootstrap, transform, audit, sim/device/ipa build flows).
forge-gui-ios/pipeline/bridge.cfg Add bridge rules mapping missing Java 8 surface to streamsupport/compat supplies.
forge-gui-ios/oslog_wrapper/ForgeOSLog.m Implement native os_log wrapper functions.
forge-gui-ios/oslog_wrapper/ForgeOSLog.h Header for os_log wrapper functions.
forge-gui-ios/Info.plist.xml Update capabilities/arm64 and add network/TLS and other iOS app settings.
.gitignore Ignore per-developer robovm.properties.
.github/workflows/ios-compat-gate.yml Add Linux CI job to run the iOS/MobiVM transform + bridge + link audit gate.
.github/workflows/ios-build-ipa.yml Add manual macOS workflow to build an unsigned iOS IPA artifact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread forge-gui-mobile/src/forge/assets/FSkin.java Outdated
Comment thread forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java Outdated
Comment thread forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java Outdated
Comment thread forge-gui-ios/src/forge/ios/OSLogPrintStream.java
Comment thread forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java Outdated
Comment thread forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java Outdated
Comment thread forge-gui-ios/src/forge/ios/OSLogPrintStream.java
shoeless and others added 3 commits July 11, 2026 09:08
Sentry was stubbed on iOS because the real io.sentry classes were never
bundled/linked into the RoboVM build — every telemetry call from shared code
threw NoClassDefFoundError, and on the choose-cards paths (Cultivate/Genesis
Hydra library searches) that silently killed the game thread. That was a
packaging gap, not a platform incompatibility: sentry-java targets Java 8 and
its HttpURLConnection transport works on RoboVM.

- forge-gui-ios now depends on the real sentry-java (8.21.1, the same artifact
  every other platform gets via forge-game) — the io.sentry exclusions and the
  forge.stubs:sentry-stub substitute are gone, stub sources deleted.
- The jvmdg pipeline transforms the sentry jar like any other dependency; the
  iOS compatibility gate passes with zero sentry findings (no unbridged stubs,
  no missing MobiVM APIs reachable).
- robovm.xml force-links io.sentry.** (the SDK wires integrations/transports
  reflectively, invisible to the tree-shaker — exactly how the original
  NoClassDefFoundError happened).
- forge.ios.Main initializes Sentry the same way the desktop launcher does
  (same project DSN, release/environment tags, external-config enabled).
  Events are only sent when the user's USE_SENTRY preference allows it; init
  failure is caught so telemetry can never break app launch.

Pending device verification: the previously-crashing choose-cards flows and a
delivered event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad HTTPS fallback

Device verification showed the desktop launcher's hardcoded fallback DSN
(https://...@sentry.asgardsrealm.net//3, no port) answers HTTP 530 to every
request — nothing serves the ingest API on 443. The checked-in
sentry.properties files show the self-hosted Sentry actually listens on plain
HTTP :9000, and that mobile platforms (Android's manifest,
forge-gui-mobile-dev) report to project 2 while desktop uses project 3. Point
iOS at the mobile project's real endpoint. RoboVM's Java sockets are not
subject to ATS, so http:// is usable here (same trade-off the other platforms
already accept, per the 'ideally this should be using HTTPS' note in
sentry.properties).

Side finding for upstream: any desktop install that loses sentry.properties
(the Main.java comment suggests some do) falls back to the dead HTTPS URL and
silently drops all crash reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Cloudflare Tunnel

Deeper endpoint investigation (thanks to reviewer skepticism) corrected the
previous commit's conclusion: sentry.asgardsrealm.net is served through a
Cloudflare Tunnel (the edge answers CF error 1033 'tunnel not connected' right
now, from multiple networks and clients). Under a tunnel, 443 is the ONLY
reachable path — the plain-HTTP :9000 DSNs in the checked-in sentry.properties
files are pre-tunnel relics that time out at the Cloudflare edge and can never
work again. The desktop launcher's hardcoded HTTPS fallback is actually the
CORRECT form (and since sentry.properties isn't packaged into the desktop jar,
it's what desktop uses at runtime).

Point iOS at the mobile project (2) via the tunnel-served HTTPS form, matching
Android's manifest. Delivery is verified client-side to the Cloudflare edge;
events will flow once the upstream cloudflared connector reconnects (currently
error 1033 — flagged to the maintainers, along with the stale :9000
sentry.properties).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tool4ever

Copy link
Copy Markdown
Contributor

I'm gonna do a manual review after you're done with those Copilot findings 👍

Would you be able to support this port further (probably users will find more bugs) in case we decide to merge it?

The ~120-line resolveScryfallUrl/extractImageUrl JSON-API path existed
because "RoboVM's OkHttp can't follow Scryfall's 302 redirects" — an
on-simulator probe proves that claim false: the bundled okhttp follows
the api.scryfall.com -> cards.scryfall.io cross-host 302 fine (200,
image bytes, correct final URL), and with the workaround deleted, card
images verifiably download end-to-end through plain redirect-following.
The workaround also doubled the Scryfall API request count per image.

Kept from the old block: connect/read timeouts on the connection (a
stalled connection shouldn't hang the download thread forever). Also
fixes the setless-token retry logging the outer exception's message
instead of its own (pre-existing upstream bug, flagged by Copilot).

The real fetch failure the workaround was likely mis-attributed to is
process-local getaddrinfo failures under the game-start fetch burst —
which hit the workaround's JSON call identically (same host).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shoeless and others added 3 commits July 11, 2026 16:19
- FSkin.getFileHandle: reroute to internal() ONLY for paths under
  ASSETS_DIR (the read-only bundle). Writable cache/Documents paths keep
  absolute() — the old blanket reroute silently broke every cache-dir
  lookup on iOS: downloaded skins and planechase pics were invisible,
  and the generated .fnt font cache never hit (fonts re-generated from
  TTF on every launch).
- HostedMatch: gate the per-game System.gc() to iOS — it exists for the
  iOS jetsam ceiling; desktop/Android shouldn't pay the GC stall.
- NetworkLogWriter/FServerManager: narrow catch(Throwable) to
  catch(LinkageError | Exception) — still degrades gracefully on
  MobiVM-missing classes, no longer swallows OutOfMemoryError.
- OSLogPrintStream: buffer UTF-8 bytes in print(String) instead of
  truncating chars to 8 bits, and decode the buffer explicitly as UTF-8
  in flushBuffer() — non-ASCII (localized strings, accented card names)
  no longer garbles in os_log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Booting parses ~32k card rules + builds ~100k PaperCards + loads skin
assets — a transient allocation spike that leaves ~200MB of freed-but-
unmapped bytes in the GC heap once the home screen is idle. Two full
GCs return those pages to iOS (the collector needs the second pass to
unmap what the first freed); device-measured on the feature branch:
GC heap 690→492MB before a game starts. iOS-gated — other platforms
don't sit against a per-process memory ceiling, so they skip the stall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS processes default to a 256-fd soft limit. The app's classpath jars
get pinned open by ordinary classloader resource probes (87 jars x 2
fds ≈ 177 via the shared JarFile cache), match load holds ~70 more, and
the game-start image-fetch burst opens dozens of sockets on top. Once
past the limit, everything fails at once and none of it says "EMFILE"
up front: lazy texture opens throw GdxRuntimeException "Couldn't load
file" (card-frame/PW/nyx textures -> broken match board while the game
itself keeps running), getaddrinfo returns "Unable to resolve host"
(it needs a descriptor), and AssetManager errors on the default card
image. The only honest signal was the nested cause in the saved crash
log: java.io.FileNotFoundException: ... open failed: EMFILE.

FileDescriptorLimit (new, Bro binding for getrlimit/setrlimit) raises
the soft limit to min(hard, OPEN_MAX=10240) first thing in main().
Verified in the simulator: match load + full image-fetch burst runs at
276 fds with correctly rendered card frames and working downloads.

Also stop Sentry's classpath config scans (external configuration +
debug-meta probes) — hygiene, not the fix: there is no external
sentry.properties on iOS (the DSN is set in code), so probing every
bundled jar for it is pure waste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tool4ever

Copy link
Copy Markdown
Contributor

Normal test-build still fails on your branch:

Error: Failed to execute goal on project forge-gui-ios: Could not resolve dependencies for project forge:forge-gui-ios:jar:2.0.14-SNAPSHOT
Error: dependency: forge.stubs:java-function-stubs:jar:1.0 (compile)
Error: Could not find artifact forge.stubs:java-function-stubs:jar:1.0 in central (https://repo.maven.apache.org/maven2)
Error: dependency: xyz.wagyourtail.jvmdowngrader:jvmdowngrader-java-api:jar:1.3.6-mobivm (compile)
Error: Could not find artifact xyz.wagyourtail.jvmdowngrader:jvmdowngrader-java-api:jar:1.3.6-mobivm in central (https://repo.maven.apache.org/maven2)
Error: dependency: forge.stubs:java-time-supply:jar:1.0 (compile)
Error: Could not find artifact forge.stubs:java-time-supply:jar:1.0 in central (https://repo.maven.apache.org/maven2)
Error: dependency: forge.stubs:java-nio-supply:jar:1.0 (compile)
Error: Could not find artifact forge.stubs:java-nio-supply:jar:1.0 in central (https://repo.maven.apache.org/maven2)

I suppose because a different workflow wouldn't find any cached artifacts?
is there a maven trick to ignore them?
since for normal development we don't need to see their shading anyway?

shoeless and others added 3 commits July 12, 2026 01:40
…ofile

forge-gui-ios depends on artifacts the iOS pipeline generates into the
local Maven repo (streamsupport / JvmDowngrader / relocated-ThreeTen
supplies) that are not published to Maven Central. Leaving the module in
the default <modules> made a plain `mvn install` — and CI's normal
test-build — fail to resolve those deps.

Gate it behind a non-default profile instead:  mvn -Pios ...
The iOS pipeline builds the module directly (cd forge-gui-ios), never
through the root reactor, so this is transparent to iOS builds; normal
development and CI no longer touch it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… detect

The simulator ran x86_64 under Rosetta (RoboVM defaults the sim arch to
the host JDK's arch, which is Intel here). Rosetta's CoreAudio translation
floods HALC_ProxyIOContext overloads (~24/sec) — the audio static — and
slows the whole sim. Devices were always native arm64 and unaffected.

Now the pipeline builds a native arm64 simulator app on Apple Silicon
(SIM_ARCH host-aware). Overloads drop ~24/sec -> ~0.1/sec (live-game
verified). Pieces:
- build_oslog: compile libForgeOSLog.a for the target flavor from source
  (arm64-device and arm64-simulator objects can't share one archive).
  sim() swaps in the sim flavor and restores the committed device lib on
  exit. The gdx/ObjectAL/freetype/box2d sim natives already ship as
  ios-arm64_x86_64-simulator xcframework slices in the -natives-ios jars.
- assemble_arm64_sim_app: RoboVM 2.3.24's robovm:ipad-sim can't launch
  arm64 sims on Apple Silicon (its DeviceType table predates them) and
  aborts before bundling the .app. We finish with the standalone
  AppCompiler (-config + -properties + -skipsign -d), then fix the two
  platform tags it gets wrong for a simulator target: vtool re-stamps the
  main binary (device LC_VERSION_MIN_IPHONEOS -> BUILD_VERSION platform 7)
  and we swap each framework's device slice for the simulator slice, then
  ad-hoc sign.
- JDK auto-detect: prefer a native arm64 JDK 17+ if installed, so the AOT
  compiler stops running under Rosetta. No-op until one is installed; the
  sim arch is pinned explicitly so a native JDK is otherwise harmless.

Intel hosts keep the existing x86_64 path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native-JDK use is an optional accelerator, not a requirement — the
build works on an x86_64 JDK, just slower under Rosetta. When on Apple
Silicon with no arm64 JDK 17+ found, print a one-time, non-failing note
pointing at the aarch64 Temurin download (and the gotcha that Homebrew's
temurin@17 cask under an Intel brew installs the x86_64 build). No
behavior change when a native JDK is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shoeless

Copy link
Copy Markdown
Author

Thanks — catching up on your review, and I fixed the test-build failure you flagged.

The test-build failure: good catch — the iOS module shouldn't be in the default reactor at all. It depends on artifacts the iOS pipeline generates into the local repo (the streamsupport / JvmDowngrader / relocated-ThreeTen supplies) that aren't on Maven Central, so a plain mvn install — and your test-build — can't resolve them. I've moved forge-gui-ios out of the default <modules> into a non-default ios profile:

<profiles>
  <profile>
    <id>ios</id>
    <modules>
      <module>forge-gui-ios</module>
    </modules>
  </profile>
</profiles>

So mvn install / test-build no longer touch it (and never see the shaded supplies — exactly as you said, normal development doesn't need them). The iOS build is unaffected: the pipeline builds the module directly (cd forge-gui-ios), never through the root reactor, and the compat-gate workflow calls the pipeline rather than a reactor build. Anyone who wants it in a reactor build can mvn -Pios .... Pushed.


On your three original points:

a) User delivery — good news: no jailbreak is needed at any point. Three working paths, in increasing order of convenience:

  • Sideloadly / AltStore: they take an unsigned IPA and re-sign it with the user's own (free) Apple ID before installing. I've added a Build iOS IPA workflow (macOS runner, manual dispatch) that produces exactly that unsigned IPA as an artifact, so release IPAs can ship alongside the desktop/Android builds. Free-account sideloads expire after 7 days (re-sideload to refresh); a $99 developer account extends that to a year.
  • TestFlight: works today — I've been distributing test builds to internal testers via TestFlight for months (build + upload is scripted). Needs an Apple developer account on the publishing side, but installs are one-tap for users and last 90 days.
  • Dev-signed direct install for anyone building from source (Xcode free account).

b) CI/CD — done, two tiers so the runtime cost lands where you suggested:

  • ios-compat-gate.yml (this PR): runs on ubuntu in a few minutes on any PR touching Java/poms. It executes the JvmDowngrader transform + link audit without any Apple tooling and fails when a change uses an API that would crash on iOS. This is the important one day-to-day: nobody needs a Mac to avoid breaking iOS.
  • ios-build-ipa.yml: the full RoboVM AOT build on a macOS runner (~30-60 min, so manual-dispatch / release-time only per your suggestion), uploading the unsigned IPA artifact from (a). The RoboVM AOT cache is persisted between runs, which cuts rebuild times substantially.

c) Sentry — done and device-verified. The reason it was stubbed isn't a platform incompatibility: the io.sentry classes simply weren't bundled/linked into the iOS build, so every telemetry call from shared code threw NoClassDefFoundError (and killed the game thread on some choose-card paths — that's why it became a no-op stub rather than left broken). The real sentry-java (8.21.1, the same artifact desktop ships) now goes through the downgrade pipeline cleanly, io.sentry.** is force-linked, and the iOS launcher initializes it exactly like the desktop launcher (same DSN; events only send when the user's USE_SENTRY preference allows).

Verified on hardware (iPad, iOS 26): the previously-crashing choose-cards flows (Cultivate/Genesis Hydra library searches) work with the real SDK linked, and init/capture/transport all function. I have iOS reporting to the mobile project (2), matching Android, rather than desktop's project 3 — let me know if that's not how you'd want mobile events routed.

A couple of things I ran into while verifying delivery — quite possibly me holding it wrong, so I'd value your steer on how you want this set up:

  1. sentry.asgardsrealm.net was returning CF error 1033 ("tunnel not connected") for me across a few networks/clients over several hours, so my test events made it to the Cloudflare edge but no further. Might just be a transient on the ingest side, or I could be hitting the wrong host — is that the endpoint mobile should be using?
  2. The checked-in sentry.properties DSNs use http://…:9000, whereas the Main.java fallback uses the plain-443 https://… form. I went with the https form for iOS (matching what desktop actually runs, since the properties file isn't packaged), but I wasn't sure which is canonical now — happy to switch iOS to whatever you'd prefer, and there's a small …//3 double-slash in the desktop fallback if that's worth tidying.

iOS is wired to the mobile project's DSN (https://…/2, same form as Android's manifest) and verified end-to-end up to the edge, so events should flow once the ingest side is reachable — just point me at the right target if I've picked wrong.


On the "workarounds in the neighbour modules" aside: addressed. iOS-specific behavior in forge-gui-mobile is now explicitly gated behind a GuiBase.isIOS() flag (the exact mirror of the existing Android launcher flag) instead of ApplicationType sniffing, and in the process a few places where iOS-motivated changes would have silently altered Android/desktop behavior (file-path resolution, Scryfall fetch strategy, font texture filtering) were scoped back to iOS-only. The genuinely platform-neutral hardening (null guards, BreakIterator fallback) stays shared.

@shoeless

Copy link
Copy Markdown
Author

Addressed all the Copilot findings (plus one bonus simplification) — pushed as two commits:

Copilot fixes:

  • FSkin.getFileHandle writable paths — correct catch. The iOS internal() reroute now applies only to paths under ASSETS_DIR (the app bundle); cache/Documents paths keep absolute(). This also turns out to make the generated .fnt font cache effective on iOS (it lives under the cache dir, so the lookup was silently missing and fonts were regenerating from TTF every launch) and un-breaks downloaded skins + planechase images there.
  • LibGDXImageFetcher wrong-exception log — fixed (t.getMessage()); note the e-vs-t mixup actually predates this PR, the diff just touched the line.
  • HostedMatch per-game System.gc() — now gated to iOS only. It's a deliberate mitigation for iOS's per-process memory (jetsam) ceiling on multi-game Commander matches; desktop/Android no longer pay the stall.
  • OSLogPrintStream — both Unicode issues fixed: print(String) now buffers UTF-8 bytes instead of truncating chars, and flushBuffer() decodes explicitly as UTF-8.
  • NetworkLogWriter / FServerManager catch (Throwable) — narrowed to catch (LinkageError | Exception): still degrades gracefully when MobiVM lacks a class (NoClassDefFoundError is a LinkageError), no longer swallows OutOfMemoryError and friends.

Bonus: the Scryfall "can't follow 302 redirects" workaround in LibGDXImageFetcher turned out to be obsolete — an on-simulator probe showed RoboVM's HTTP stack follows the api.scryfall.comcards.scryfall.io redirect fine (200, image bytes, correct final URL), and after deleting the ~120-line JSON-API resolution path, card images verifiably download end-to-end through plain redirect-following (fresh fetches → valid jpgs on disk → rendering). The file is now within ~20 lines of master (connection timeouts, a 0-byte-download guard, and the widened catches).

Verifying that also led me to the real image-fetch failure the workaround was mis-attributed to, and it turned out to be a bigger fish — a file-descriptor exhaustion (EMFILE) bug that I've now fixed (separate commit). iOS gives each process a 256-fd soft limit; the app's classpath jars get pinned open by ordinary classloader resource probes (~177 fds), a loaded match holds ~70 more, and the game-start image-fetch burst opens dozens of sockets on top. Once it tips past 256 everything fails at once, and none of it announces "EMFILE": lazy texture opens throw GdxRuntimeException: Couldn't load file (blank card frames / broken match board while the game keeps running), and getaddrinfo returns Unable to resolve host (it needs an fd) — which is why the Scryfall fetches looked like a DNS problem. The only honest signal was the nested Caused by: … open failed: EMFILE in the crash log. Fix is a one-liner in spirit: raise RLIMIT_NOFILE at launch (to min(hard, OPEN_MAX)). Verified in the simulator — a full match load plus wiped-cache fetch burst now runs with correctly rendered card frames and working downloads. This one was masking itself as several unrelated bugs (blank board, DNS failures, even audio glitches), so it's a good one to have out of the way.

@shoeless

Copy link
Copy Markdown
Author

Would you be able to support this port further (probably users will find more bugs) in case we decide to merge it?

Yes — happy to. This port is what I play on weekly across two iPads.

@tool4ever

Copy link
Copy Markdown
Contributor

RE Sentry:

  • sorry should have mentioned it's definitely down right now
  • I think a new project probably makes sense, not sure we need to create it in the web GUI first though before changing it in code
  • I'll investigate our definitive DSN format, not a block here

Comment thread forge-gui-ios/robovm.xml Outdated
Comment thread forge-gui-mobile/src/forge/assets/FSkin.java Outdated
shoeless and others added 6 commits July 12, 2026 10:24
Both are relics of the old hand-backport approach, flagged in review:
- The io.sentry.** entry carried a duplicate "No-op Sentry stubs / real
  SDK is excluded on iOS" comment — untrue since real Sentry was enabled;
  the correct entry + comment already exist lower in the file. Removed the
  stale comment and its duplicate pattern.
- The "java.util.function is NOT available - use forge.util.function
  backports instead" NOTE is false under the jvmdg pipeline: the bridge
  remaps java/util/function -> streamsupport, unmodified upstream uses
  java.util.function in 155 files, and forge.util.function is used in 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review flagged FSkin.getFileHandle as redundant to Assets.getFileHandle.
They did the same iOS internal()/absolute() resolution; unify into one.

The canonical helper now lives in Assets, next to HybridFileHandleResolver
(file resolution is an Assets concern, and this respects the existing
FSkin -> Assets dependency direction rather than inverting it). It keeps
the startsWith(ASSETS_DIR) guard so writable cache/Documents paths keep
absolute() on iOS. FSkin/FSkinFont now call Assets.getFileHandle; the old
private Assets copy (unguarded, one caller) and the FSkin copy are gone.
No behavior change — same-package access, no new imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ipeline

# Conflicts:
#	forge-gui-mobile/src/forge/adventure/util/Config.java
Follow-up to the maintainer's "Minor clean up" — the same fqn-to-import
tidy applied to our other iOS files, all where the type is (or is now)
imported and there's no name clash. No behavior change.

- Main.java: java.io.File param -> File
- OSLogPrintStream.java: java.nio.charset.StandardCharsets (x2) -> import
- ImageCache.java: java.util.IdentityHashMap / LinkedHashMap / java.io.FileInputStream -> imports
- Assets.java: java.util.LinkedHashMap (x3) + java.util.Map.Entry -> imports

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecation)

GitHub is force-running the @v4 actions on Node 24 and warning that Node
20 is deprecated. Pin the lowest major of each that natively targets
Node 24 (verified against each action's action.yml runs.using):
  actions/checkout        v4 -> v5
  actions/setup-java      v4 -> v5
  actions/cache           v4 -> v5
  actions/upload-artifact v4 -> v6   (v5 is still Node 20; v6 is the fix)

No input changes — checkout takes none, and setup-java (distribution/
java-version/cache), cache (path/key) and upload-artifact (name/path/
if-no-files-found) keep the same inputs across these majors. Only our
two iOS workflows are touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shoeless

Copy link
Copy Markdown
Author

@tool4ever , thanks for the feedback so far. Anything I can do to simplify the review?

@tool4ever

Copy link
Copy Markdown
Contributor

I'm fine with the code quality, we're just trying to test on a real device too
Might be next weekend before I have time myself though

@shoeless

Copy link
Copy Markdown
Author

Sounds good. I would like help verifying this on an android device. I run this on two physical iPads: an iPad (10th gen) on iOS 26.2 and an older iPad on iOS 16.7, so it's covered across a wide OS range. Full games vs. AI, adventure mode, and online multiplayer all work, and I've had this exact branch on-device (audio, card rendering, the memory fixes) throughout.

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.

3 participants