getInputArguments();
+}
diff --git a/forge-gui-ios/src/forge/compat/mgmt/package-info.java b/forge-gui-ios/src/forge/compat/mgmt/package-info.java
new file mode 100644
index 000000000000..7e8a85158004
--- /dev/null
+++ b/forge-gui-ios/src/forge/compat/mgmt/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Whole-type remap targets for java.lang.management (absent on MobiVM).
+ * The iOS bridge pass (tmp/jvmdg/bridge.cfg) remaps the entire
+ * java.lang.management package prefix here; apfloat's ApfloatContext and
+ * tinylog's runtime provider probe these at boot.
+ */
+package forge.compat.mgmt;
diff --git a/forge-gui-ios/src/forge/compat/package-info.java b/forge-gui-ios/src/forge/compat/package-info.java
new file mode 100644
index 000000000000..586b8937d7f7
--- /dev/null
+++ b/forge-gui-ios/src/forge/compat/package-info.java
@@ -0,0 +1,15 @@
+/**
+ * Bytecode-rewrite targets for the iOS MobiVM build pipeline (tmp/jvmdg).
+ *
+ * MobiVM's runtime library is Java-7-era: it lacks java.util.function,
+ * java.util.stream, Optional, java.time, java.nio.file, and the Java 8
+ * default/static members on Map, List, Collection, Comparator, String, etc.
+ * The pipeline (JvmDowngrader -c 52 + MobiVmBridge) rewrites call sites in
+ * every app jar to the classes in this package (and to streamsupport /
+ * app-supplied java.* classes). Nothing references these classes from source;
+ * desktop and Android builds never load them.
+ *
+ * Bodies must be self-implemented: they must not call the very APIs they
+ * replace, since this module's classes are themselves bridged.
+ */
+package forge.compat;
diff --git a/forge-gui-ios/src/forge/ios/ForgeOSLog.java b/forge-gui-ios/src/forge/ios/ForgeOSLog.java
new file mode 100644
index 000000000000..37eb9ae71eb1
--- /dev/null
+++ b/forge-gui-ios/src/forge/ios/ForgeOSLog.java
@@ -0,0 +1,50 @@
+package forge.ios;
+
+import org.robovm.rt.bro.Bro;
+import org.robovm.rt.bro.annotation.Bridge;
+import org.robovm.rt.bro.annotation.Library;
+
+/**
+ * Native os_log wrapper for iOS 26+ compatibility.
+ *
+ * iOS 26 marks all NSLog/Foundation.log output as .
+ * This wrapper uses os_log with %{public}s to ensure logs are visible.
+ */
+@Library(Library.INTERNAL)
+public class ForgeOSLog {
+
+ static {
+ Bro.bind(ForgeOSLog.class);
+ }
+
+ /**
+ * Log a message at default level with public visibility.
+ */
+ @Bridge(symbol = "forge_os_log_public")
+ public static native void logPublic(String message);
+
+ /**
+ * Log a message at info level with public visibility.
+ */
+ @Bridge(symbol = "forge_os_log_info")
+ public static native void logInfo(String message);
+
+ /**
+ * Log a message at debug level with public visibility.
+ */
+ @Bridge(symbol = "forge_os_log_debug")
+ public static native void logDebug(String message);
+
+ /**
+ * Log a message at error level with public visibility.
+ */
+ @Bridge(symbol = "forge_os_log_error")
+ public static native void logError(String message);
+
+ /**
+ * Convenience method to log with FORGE: prefix.
+ */
+ public static void log(String message) {
+ logPublic("FORGE: " + message);
+ }
+}
diff --git a/forge-gui-ios/src/forge/ios/Main.java b/forge-gui-ios/src/forge/ios/Main.java
index 94b6badd7f7a..28e591163524 100644
--- a/forge-gui-ios/src/forge/ios/Main.java
+++ b/forge-gui-ios/src/forge/ios/Main.java
@@ -5,12 +5,19 @@
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Date;
+import java.util.SimpleTimeZone;
+import java.util.TimeZone;
import org.apache.commons.lang3.tuple.Pair;
-import org.jupnp.UpnpServiceConfiguration;
import org.robovm.apple.foundation.NSAutoreleasePool;
+import org.robovm.apple.foundation.NSTimeZone;
+import org.robovm.apple.foundation.NSBundle;
+import org.robovm.apple.foundation.NSProcessInfo;
import org.robovm.apple.uikit.UIApplication;
import org.robovm.apple.uikit.UIPasteboard;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
@@ -19,26 +26,361 @@
import com.badlogic.gdx.backends.iosrobovm.IOSFiles;
import forge.Forge;
+import forge.assets.ImageCache;
import forge.interfaces.IDeviceAdapter;
public class Main extends IOSApplication.Delegate {
+ private static int initCounter = 0;
+ private static final Object initLock = new Object();
+
+ // iOS 26+ requires os_log with public specifier - NSLog is fully redacted
+ // Older iOS: use NSLog (Foundation.log) which idevicesyslog captures reliably
+ private static void log(String message) {
+ try {
+ if (OSLogPrintStream.shouldUseOSLog()) {
+ ForgeOSLog.log(message);
+ } else {
+ org.robovm.apple.foundation.Foundation.log("%@",
+ new org.robovm.apple.foundation.NSString("FORGE: " + message));
+ }
+ } catch (Throwable t) {
+ // Last resort - may not be visible on older iOS
+ try {
+ org.robovm.apple.foundation.Foundation.log("%@",
+ new org.robovm.apple.foundation.NSString("FORGE: " + message));
+ } catch (Throwable t2) {
+ // Nothing we can do
+ }
+ }
+ }
+
+ // Static initializer runs when class is loaded, before main()
+ static {
+ synchronized (initLock) {
+ initCounter++;
+ log("Static initializer starting (count=" + initCounter + ")");
+ }
+ try {
+ // Create a custom timezone without file system access
+ NSTimeZone systemTimeZone = NSTimeZone.getSystemTimeZone();
+ if (systemTimeZone != null) {
+ String tzName = systemTimeZone.getName();
+ long offsetSeconds = systemTimeZone.getSecondsFromGMT();
+ int offsetMillis = (int) (offsetSeconds * 1000);
+
+ // Set both the property AND the default timezone programmatically
+ System.setProperty("user.timezone", tzName != null ? tzName : "UTC");
+ TimeZone.setDefault(new SimpleTimeZone(offsetMillis, tzName != null ? tzName : "UTC"));
+ log("Timezone set to " + tzName);
+ } else {
+ // Fallback if systemTimeZone is null
+ System.setProperty("user.timezone", "America/Los_Angeles");
+ TimeZone.setDefault(new SimpleTimeZone(-8 * 3600 * 1000, "America/Los_Angeles"));
+ log("Timezone set to fallback PST");
+ }
+ } catch (Throwable e) {
+ log("Exception in static initializer: " + e.getMessage());
+ e.printStackTrace();
+ // Catch everything including Errors to prevent static initializer failure
+ try {
+ System.setProperty("user.timezone", "America/Los_Angeles");
+ TimeZone.setDefault(new SimpleTimeZone(-8 * 3600 * 1000, "America/Los_Angeles"));
+ } catch (Throwable ignored) {
+ // If even the fallback fails, continue anyway
+ }
+ log("Static initializer completed (count=" + initCounter + ")");
+ }
+ }
+
+ private static int createAppCounter = 0;
+
+ private void copyEssentialResources(final String assetsDir) {
+ log("copyEssentialResources() starting");
+ try {
+ String bundlePath = NSBundle.getMainBundle().getBundlePath();
+ File bundleResDir = new File(bundlePath, "res");
+
+ log("Bundle res dir: " + bundleResDir.getAbsolutePath());
+
+ if (bundleResDir.exists() && bundleResDir.isDirectory()) {
+ // Copy essential small resource directories to avoid watchdog timeout
+
+ // Copy languages directory (9 small files)
+ File bundleLangDir = new File(bundleResDir, "languages");
+ File docsLangDir = new File(assetsDir + "/res", "languages");
+
+ if (bundleLangDir.exists() && !docsLangDir.exists()) {
+ log("Copying languages directory...");
+ copyDirectory(bundleLangDir, docsLangDir);
+ log("Languages copied successfully");
+ } else if (docsLangDir.exists()) {
+ log("Languages directory already exists");
+ } else {
+ log("Languages directory not found in bundle");
+ }
+
+ // Copy defaults directory (placeholder images like no_card.jpg)
+ File bundleDefaultsDir = new File(bundleResDir, "defaults");
+ File docsDefaultsDir = new File(assetsDir + "/res", "defaults");
+
+ if (bundleDefaultsDir.exists() && !docsDefaultsDir.exists()) {
+ log("Copying defaults directory...");
+ copyDirectory(bundleDefaultsDir, docsDefaultsDir);
+ log("Defaults copied successfully");
+ } else if (docsDefaultsDir.exists()) {
+ log("Defaults directory already exists");
+ } else {
+ log("Defaults directory not found in bundle");
+ }
+ } else {
+ log("No bundled resources found at: " + bundleResDir.getAbsolutePath());
+ }
+ } catch (Exception e) {
+ log("Error copying essential resources: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ private void copyFile(final File source, final File dest) throws IOException {
+ try (FileInputStream fis = new FileInputStream(source);
+ FileOutputStream fos = new FileOutputStream(dest)) {
+ byte[] buffer = new byte[8192];
+ int length;
+ while ((length = fis.read(buffer)) > 0) {
+ fos.write(buffer, 0, length);
+ }
+ }
+ }
+
+ private void copyDirectory(final File source, final File dest) throws IOException {
+ if (!dest.exists()) {
+ dest.mkdirs();
+ }
+
+ File[] files = source.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ File destFile = new File(dest, file.getName());
+ if (file.isDirectory()) {
+ copyDirectory(file, destFile);
+ } else {
+ // Only copy if destination doesn't exist (don't overwrite user changes)
+ if (!destFile.exists()) {
+ copyFile(file, destFile);
+ log("Copied " + file.getName());
+ }
+ }
+ }
+ }
+ }
@Override
protected IOSApplication createApplication() {
- final String assetsDir = new IOSFiles().getLocalStoragePath() + "/../../forge.ios.Main.app/";
-
- final IOSApplicationConfiguration config = new IOSApplicationConfiguration();
- config.useAccelerometer = false;
- config.useCompass = false;
- final ApplicationListener app = Forge.getApp(null, new IOSClipboard(), new IOSAdapter(), assetsDir, false, false, 0);
- final IOSApplication iosApp = new IOSApplication(app, config);
- return iosApp;
+ synchronized (initLock) {
+ createAppCounter++;
+ log("createApplication() starting (count=" + createAppCounter + ")");
+ }
+ try {
+ // Use the app bundle as assetsDir so resources are read directly from there
+ // This avoids copying files and hitting watchdog timeout
+ String bundlePath = NSBundle.getMainBundle().getBundlePath();
+ // Ensure path ends with / so relative paths are appended correctly
+ final String assetsDir = bundlePath.endsWith("/") ? bundlePath : bundlePath + "/";
+ log("Assets dir (bundle): " + assetsDir);
+
+ // Set writable directories for iOS using libGDX IOSFiles (Documents directory)
+ // This avoids iOS sandbox violations when trying to write to the read-only app bundle
+ String documentsPath = new IOSFiles().getExternalStoragePath();
+ log("Documents dir (writable): " + documentsPath);
+ System.setProperty("forge.ios.userDir", documentsPath);
+ System.setProperty("forge.ios.cacheDir", documentsPath + "cache/");
+
+ // Enable the static/replacement-ability memo on iOS. It's data-correct (proven 0-stale) and
+ // cuts allocations + speeds up AI evaluation — helps the constrained iPad CPU (fewer combat
+ // timeouts) and the jetsam memory ceiling. Set here, before any game/CardState class loads.
+ System.setProperty("forge.staticMemo", "on");
+
+ // Clear card cache when a new build is deployed. The cache stores
+ // pre-parsed card rules for fast startup, but stale caches cause bugs
+ // (e.g., duplicate triggers). Compare the app's CFBundleVersion to a
+ // stored marker — if they differ, this is a new deploy.
+ String appBuild = NSBundle.getMainBundle().getInfoDictionaryObject("CFBundleVersion").toString();
+ File cacheDir = new File(documentsPath + "cache/db/");
+ cacheDir.mkdirs();
+ File buildMarker = new File(cacheDir, ".build_version");
+ String cachedBuild = "";
+ if (buildMarker.exists()) {
+ try {
+ byte[] bytes = new byte[(int) buildMarker.length()];
+ FileInputStream fis = new FileInputStream(buildMarker);
+ fis.read(bytes);
+ fis.close();
+ cachedBuild = new String(bytes).trim();
+ } catch (IOException e) { /* treat as mismatch */ }
+ }
+ if (!appBuild.equals(cachedBuild)) {
+ File cardCache = new File(cacheDir, "cardcache.bin");
+ File cardsDb = new File(cacheDir, "cardsdb.bin");
+ if (cardCache.exists()) { cardCache.delete(); log("Cleared stale cardcache.bin (build " + cachedBuild + " -> " + appBuild + ")"); }
+ if (cardsDb.exists()) { cardsDb.delete(); log("Cleared stale cardsdb.bin"); }
+ try {
+ FileOutputStream fos = new FileOutputStream(buildMarker);
+ fos.write(appBuild.getBytes());
+ fos.close();
+ } catch (IOException e) {
+ log("Could not write build marker: " + e.getMessage());
+ }
+ } else {
+ log("Card cache up-to-date for build " + appBuild);
+ }
+
+ final IOSApplicationConfiguration config = new IOSApplicationConfiguration();
+ config.useAccelerometer = false;
+ config.useCompass = false;
+ config.useAudio = true; // ObjectAL/OpenAL audio (music + SFX); see IOSAdapter.isSupportedAudioFormat
+ // Play game audio even when the device ringer/silent switch is on
+ // (ObjectAL honorSilentSwitch = !overrideRingerSwitch). Without this,
+ // there is NO sound on a device in silent mode — the simulator ignores
+ // the silent switch, which masked it. Standard behavior for a game.
+ config.overrideRingerSwitch = true;
+ config.preferredFramesPerSecond = 60; // Smooth 60 FPS rendering
+ config.preventScreenDimming = true; // Keep screen on during gameplay
+
+ // Detect if running on iPad (UIUserInterfaceIdiomPad = 1)
+ boolean isTablet = org.robovm.apple.uikit.UIDevice.getCurrentDevice().getUserInterfaceIdiom()
+ == org.robovm.apple.uikit.UIUserInterfaceIdiom.Pad;
+
+ // (upstream GuiBase has no iOS flag; platform behavior is keyed off
+ // Gdx.app.getType() / the adapter now)
+
+ // Log physical device RAM (MB) for diagnostics. Upstream's getApp no
+ // longer takes a RAM hint (the feature branch's cache-sizing lever);
+ // if jetsam pressure resurfaces, reintroduce via HWInfo.
+ try {
+ long deviceRamMB = NSProcessInfo.getSharedProcessInfo().getPhysicalMemory() / (1024L * 1024L);
+ log("Physical device RAM: " + deviceRamMB + " MB");
+ } catch (Throwable t) {
+ log("Could not read physical memory: " + t.getMessage());
+ }
+
+ final ApplicationListener app = Forge.getApp(null, new IOSClipboard(), new IOSAdapter(), assetsDir, false, isTablet, 0);
+ IOSApplication iosApp = new IOSApplication(app, config);
+
+ // NOTE: sound effects now play through libGDX Music (AVAudioPlayer) on iOS
+ // (see forge.sound.AudioClip), NOT OpenAL. The OpenAL effects engine that
+ // libGDX auto-initializes is therefore unused; it is permanently suspended
+ // in didBecomeActive so its render cycle can't beat against the AVAudioPlayer
+ // music. The old world-gen sfxEngineControl toggle is intentionally NOT
+ // registered anymore - un-suspending OpenAL would reintroduce the beat, and
+ // AVAudioPlayer effects don't suffer the world-gen mixer-starvation static.
+
+ // Re-apply System.out/err redirection - IOSApplication replaces them with FoundationLogPrintStream
+ // which doesn't appear in Console.app on iOS 26+
+ try {
+ OSLogPrintStream outStream = new OSLogPrintStream("", false);
+ OSLogPrintStream errStream = new OSLogPrintStream("[ERR] ", true);
+ System.setOut(outStream);
+ System.setErr(errStream);
+ } catch (Throwable t) {
+ log("Failed to re-apply System.out/err redirection: " + t.getMessage());
+ }
+ return iosApp;
+ } catch (Throwable e) {
+ log("Exception in createApplication(): " + e.getMessage());
+ e.printStackTrace();
+ throw e;
+ }
+ }
+
+ @Override
+ public void didReceiveMemoryWarning(UIApplication application) {
+ // iOS memory-pressure relief valve. Forge previously ignored every memory warning and never reclaimed
+ // card textures mid-game, so a long game ratcheted native texture RSS toward the jetsam kill with no
+ // relief. Drop the whole texture cache (on-screen cards reload via needsUpdate) and force a GC — on the
+ // GL render thread, since textures are GL objects. Turns a hard jetsam kill into a recoverable flush.
+ log("didReceiveMemoryWarning -> flushing texture caches");
+ try {
+ Gdx.app.postRunnable(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ ImageCache.getInstance().disposeTextures();
+ System.gc();
+ } catch (Throwable t) {
+ log("memory-warning flush failed: " + t.getMessage());
+ }
+ }
+ });
+ } catch (Throwable t) {
+ log("could not post memory-warning flush: " + t.getMessage());
+ }
+ }
+
+ @Override
+ public void didBecomeActive(UIApplication application) {
+ // super MUST run first so ObjectAL's audio-session interruption recovery works.
+ super.didBecomeActive(application);
+ // Permanently suspend the OpenAL effects engine. Sound effects now play through
+ // libGDX Music (AVAudioPlayer) on iOS (see forge.sound.AudioClip), so OpenAL is
+ // unused - but libGDX auto-initializes it, and an ACTIVE OpenAL 3D-mixer render
+ // cycle beats against the AVAudioPlayer music/effects (mediaserverd) clock,
+ // producing the slow periodic music crackle. Suspending it (alcSuspendContext)
+ // stops that render cycle so everything is one clock domain = no beat. Re-applied
+ // on every foreground; the first didBecomeActive fires at launch after the audio
+ // device is open. Music (a separate AVAudioPlayer subsystem) is unaffected -
+ // verified previously: suspending OpenAL never stopped the background music.
+ try {
+ OpenALManager mgr = OpenALManager.sharedInstance();
+ String diag;
+ if (mgr != null) {
+ mgr.setManuallySuspended(true);
+ diag = "OpenAL effects engine suspended (manuallySuspended="
+ + mgr.isManuallySuspended() + "); effects route via AVAudioPlayer";
+ } else {
+ diag = "OpenAL suspend SKIPPED (sharedInstance null)";
+ }
+ log(diag);
+ } catch (Throwable t) {
+ log("OpenAL suspend failed: " + t.getMessage());
+ }
}
public static void main(String[] args) {
- final NSAutoreleasePool pool = new NSAutoreleasePool();
- UIApplication.main(args, null, Main.class);
- pool.close();
+ // Redirect System.out and System.err to os_log FIRST
+ // This ensures all logging (900+ calls) is visible in Console.app
+ // Note: IOSApplication will replace these, so we re-apply in createApplication()
+ try {
+ OSLogPrintStream outStream = new OSLogPrintStream("", false);
+ OSLogPrintStream errStream = new OSLogPrintStream("[ERR] ", true);
+ System.setOut(outStream);
+ System.setErr(errStream);
+ } catch (Throwable t) {
+ log("Failed to redirect System.out/err: " + t.getMessage());
+ }
+ try {
+ // Use iOS NSTimeZone API to avoid sandbox violations when accessing /etc/localtime
+ NSTimeZone systemTimeZone = NSTimeZone.getSystemTimeZone();
+ if (systemTimeZone != null) {
+ String tzName = systemTimeZone.getName();
+ long offsetSeconds = systemTimeZone.getSecondsFromGMT();
+ int offsetMillis = (int) (offsetSeconds * 1000);
+
+ // Set both the property AND the default timezone to prevent /etc/localtime access
+ System.setProperty("user.timezone", tzName != null ? tzName : "UTC");
+ TimeZone.setDefault(new SimpleTimeZone(offsetMillis, tzName != null ? tzName : "UTC"));
+ log("Timezone set to " + tzName + " in main()");
+ }
+
+ final NSAutoreleasePool pool = new NSAutoreleasePool();
+ log("Calling UIApplication.main()");
+ UIApplication.main(args, null, Main.class);
+ log("UIApplication.main() returned");
+ pool.close();
+ } catch (Throwable e) {
+ log("Exception in main(): " + e.getMessage());
+ e.printStackTrace();
+ throw e;
+ }
}
//special clipboard that works on iOS
@@ -60,6 +402,8 @@ public void setContents(final String contents0) {
}
private static final class IOSAdapter implements IDeviceAdapter {
+ private static final int IO_BUFFER_SIZE = 8192; // 8 KB buffer for file I/O operations
+
@Override
public boolean isConnectedToInternet() {
return true;
@@ -127,12 +471,26 @@ public void closeSplashScreen() {
@Override
public void convertToJPEG(InputStream input, OutputStream output) throws IOException {
-
+ // iOS compatibility: Simply copy the input stream to output
+ // No conversion needed - Scryfall already provides JPEG images
+ byte[] buffer = new byte[IO_BUFFER_SIZE];
+ int bytesRead;
+ while ((bytesRead = input.read(buffer)) != -1) {
+ output.write(buffer, 0, bytesRead);
+ }
+ output.flush();
}
@Override
public void convertToPNG(InputStream input, OutputStream output) throws IOException {
-
+ // Same rationale as convertToJPEG: pass the bytes through
+ // (upstream's iOS adapter is a no-op here; a copy is strictly safer)
+ byte[] buffer = new byte[IO_BUFFER_SIZE];
+ int bytesRead;
+ while ((bytesRead = input.read(buffer)) != -1) {
+ output.write(buffer, 0, bytesRead);
+ }
+ output.flush();
}
@Override
@@ -146,11 +504,27 @@ public ArrayList getGamepads() {
}
@Override
- public UpnpServiceConfiguration getUpnpPlatformService() {
- // not used
+ public org.jupnp.UpnpServiceConfiguration getUpnpPlatformService() {
+ // not used on iOS (jupnp is compile-only: provided scope in pom)
return null;
}
+ @Override
+ public boolean isSupportedAudioFormat(java.io.File file) {
+ // Implemented directly (not via the interface default) for two reasons:
+ // the default uses a Set.of interface-static field whose
+ // doesn't run on MobiVM (NPE), and iOS decodes a different format set.
+ // All Forge audio ships as .mp3; ObjectAL decodes mp3/m4a/aac/wav/caf
+ // /aiff via AudioToolbox (SFX) and AVAudioPlayer (music). iOS has no
+ // native Ogg Vorbis decoder, so .ogg is intentionally excluded.
+ if (file == null) {
+ return false;
+ }
+ String path = file.getPath().toLowerCase();
+ return path.endsWith(".mp3") || path.endsWith(".m4a") || path.endsWith(".aac")
+ || path.endsWith(".wav") || path.endsWith(".caf") || path.endsWith(".aiff");
+ }
+
@Override
public boolean needFileAccess() {
return false;
diff --git a/forge-gui-ios/src/forge/ios/OSLogPrintStream.java b/forge-gui-ios/src/forge/ios/OSLogPrintStream.java
new file mode 100644
index 000000000000..3553fc60168d
--- /dev/null
+++ b/forge-gui-ios/src/forge/ios/OSLogPrintStream.java
@@ -0,0 +1,209 @@
+package forge.ios;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+import org.robovm.apple.foundation.Foundation;
+import org.robovm.apple.uikit.UIDevice;
+
+/**
+ * A PrintStream that redirects output to os_log (iOS 26+) or NSLog (older iOS)
+ * so all logging is visible via idevicesyslog on any iOS version.
+ *
+ * iOS 26+ redacts NSLog output as {@code }, so os_log with
+ * {@code %{public}s} is required. Older iOS versions expose NSLog via
+ * idevicesyslog but may not surface os_log entries.
+ */
+public class OSLogPrintStream extends PrintStream {
+ private final String prefix;
+ private final boolean isError;
+ private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ // Lazy-initialized: UIDevice may not be available during early class loading
+ static volatile Boolean useOSLog;
+
+ static boolean shouldUseOSLog() {
+ if (useOSLog == null) {
+ try {
+ String ver = UIDevice.getCurrentDevice().getSystemVersion();
+ int major = Integer.parseInt(ver.split("\\.")[0]);
+ useOSLog = Boolean.valueOf(major >= 26);
+ } catch (Throwable t) {
+ // Default to NSLog if we can't detect version
+ useOSLog = Boolean.FALSE;
+ }
+ }
+ return useOSLog.booleanValue();
+ }
+
+ public OSLogPrintStream(String prefix, boolean isError) {
+ super(new OutputStream() {
+ @Override
+ public void write(int b) {
+ // Dummy - we override the print methods
+ }
+ });
+ this.prefix = prefix;
+ this.isError = isError;
+ }
+
+ @Override
+ public void println(String x) {
+ log(x);
+ }
+
+ @Override
+ public void println(Object x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println() {
+ log("");
+ }
+
+ @Override
+ public void println(boolean x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(char x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(int x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(long x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(float x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(double x) {
+ log(String.valueOf(x));
+ }
+
+ @Override
+ public void println(char[] x) {
+ log(new String(x));
+ }
+
+ @Override
+ public void print(String s) {
+ if (s == null) s = "null";
+ synchronized (buffer) {
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '\n') {
+ flushBuffer();
+ } else {
+ buffer.write(c);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void print(Object obj) {
+ print(String.valueOf(obj));
+ }
+
+ @Override
+ public void print(boolean b) {
+ print(String.valueOf(b));
+ }
+
+ @Override
+ public void print(char c) {
+ print(String.valueOf(c));
+ }
+
+ @Override
+ public void print(int i) {
+ print(String.valueOf(i));
+ }
+
+ @Override
+ public void print(long l) {
+ print(String.valueOf(l));
+ }
+
+ @Override
+ public void print(float f) {
+ print(String.valueOf(f));
+ }
+
+ @Override
+ public void print(double d) {
+ print(String.valueOf(d));
+ }
+
+ @Override
+ public void print(char[] s) {
+ print(new String(s));
+ }
+
+ @Override
+ public void write(int b) {
+ synchronized (buffer) {
+ if (b == '\n') {
+ flushBuffer();
+ } else {
+ buffer.write(b);
+ }
+ }
+ }
+
+ @Override
+ public void write(byte[] buf, int off, int len) {
+ synchronized (buffer) {
+ for (int i = off; i < off + len; i++) {
+ write(buf[i]);
+ }
+ }
+ }
+
+ @Override
+ public void flush() {
+ synchronized (buffer) {
+ if (buffer.size() > 0) {
+ flushBuffer();
+ }
+ }
+ }
+
+ private void flushBuffer() {
+ String line = buffer.toString();
+ buffer.reset();
+ log(line);
+ }
+
+ private void log(String message) {
+ if (message == null || message.isEmpty()) {
+ return;
+ }
+ String fullMessage = prefix + message;
+ try {
+ if (shouldUseOSLog()) {
+ if (isError) {
+ ForgeOSLog.logError(fullMessage);
+ } else {
+ ForgeOSLog.logPublic(fullMessage);
+ }
+ } else {
+ Foundation.log("%@", new org.robovm.apple.foundation.NSString(fullMessage));
+ }
+ } catch (Throwable t) {
+ // If logging fails, we can't do much - avoid infinite recursion
+ }
+ }
+}
diff --git a/forge-gui-ios/src/forge/ios/OpenALManager.java b/forge-gui-ios/src/forge/ios/OpenALManager.java
new file mode 100644
index 000000000000..0511a632a6c3
--- /dev/null
+++ b/forge-gui-ios/src/forge/ios/OpenALManager.java
@@ -0,0 +1,45 @@
+package forge.ios;
+
+import org.robovm.apple.foundation.NSObject;
+import org.robovm.objc.ObjCRuntime;
+import org.robovm.objc.annotation.Method;
+import org.robovm.objc.annotation.NativeClass;
+import org.robovm.objc.annotation.Property;
+import org.robovm.rt.bro.annotation.Library;
+
+/**
+ * Minimal RoboVM binding to ObjectAL's OpenALManager (compiled into libObjectAL.a).
+ *
+ * Used to permanently suspend the OpenAL effects engine. Sound effects now play
+ * through libGDX Music (AVAudioPlayer) on iOS (see forge.sound.AudioClip), so OpenAL
+ * is unused - but libGDX auto-initializes it, and an ACTIVE OpenAL 3D-mixer render
+ * cycle beats against the AVAudioPlayer music/effects (mediaserverd) clock, producing
+ * a slow periodic music crackle. OALSuspendManager.manuallySuspended alcSuspendContext's
+ * the effects context, halting that render cycle so everything is one clock domain.
+ * OpenAL-only: AVAudioPlayer music/effects are a separate subsystem and keep playing.
+ *
+ * The libGDX OALSimpleAudio binding exposes no pause/suspend method, so this binds the
+ * underlying ObjC class directly. NOT force-linked in robovm.xml: it is referenced
+ * directly from Main.didBecomeActive, so RoboVM reachability keeps it - and adding it
+ * to forceLinkClasses would change the config hash, invalidating the whole AOT cache.
+ * Verified selectors in libObjectAL.a:
+ * +[OpenALManager sharedInstance]
+ * -[OpenALManager manuallySuspended] / -[OpenALManager setManuallySuspended:]
+ */
+@Library(Library.INTERNAL)
+@NativeClass
+public final class OpenALManager extends NSObject {
+
+ static {
+ ObjCRuntime.bind(OpenALManager.class);
+ }
+
+ @Method
+ public native static OpenALManager sharedInstance();
+
+ @Property(selector = "manuallySuspended")
+ public native boolean isManuallySuspended();
+
+ @Property(selector = "setManuallySuspended:")
+ public native void setManuallySuspended(boolean suspended);
+}
diff --git a/forge-gui-mobile/src/forge/CachedCardImage.java b/forge-gui-mobile/src/forge/CachedCardImage.java
index 33b266963411..d27359adec70 100644
--- a/forge-gui-mobile/src/forge/CachedCardImage.java
+++ b/forge-gui-mobile/src/forge/CachedCardImage.java
@@ -29,8 +29,13 @@ public CachedCardImage(String key) {
}
public void fetch() {
- if (!ImageCache.getInstance().imageKeyFileExists(key)) {
- fetcher.fetchImage(key, this);
+ try {
+ if (!ImageCache.getInstance().imageKeyFileExists(key)) {
+ fetcher.fetchImage(key, this);
+ }
+ } catch (Throwable t) {
+ // never let a fetch failure kill the render thread; surface it
+ System.err.println("Image fetch failed for " + key + ": " + t);
}
}
diff --git a/forge-gui-mobile/src/forge/Forge.java b/forge-gui-mobile/src/forge/Forge.java
index 30b5305d155f..8f354f0db6fd 100644
--- a/forge-gui-mobile/src/forge/Forge.java
+++ b/forge-gui-mobile/src/forge/Forge.java
@@ -653,6 +653,21 @@ public static void setBackScreen(final FScreen screen0, boolean replace) {
Dscreens.addFirst(front);
}
+ // iOS apps must not programmatically terminate (App Store guideline / HIG),
+ // so the iOS device adapter's exit()/restart() are no-ops. That left the
+ // normal exit flow stuck on the ClosingScreen, forcing the user to swipe the
+ // app away and relaunch. On iOS, return to the main menu instead so the app
+ // stays usable: adventure -> classic home (switchToClassic), classic -> home.
+ private static boolean iOSReturnToMainMenu() {
+ if (Gdx.app == null || Gdx.app.getType() != Application.ApplicationType.iOS)
+ return false;
+ if (isMobileAdventureMode)
+ switchToClassic();
+ else
+ openHomeDefault();
+ return true;
+ }
+
public static void restart(boolean silent) {
if (exited) {
return;
@@ -660,6 +675,8 @@ public static void restart(boolean silent) {
Consumer callback = result -> {
if (result) {
+ if (iOSReturnToMainMenu())
+ return;
exited = true;
exitAnimation(true);
}
@@ -686,6 +703,8 @@ public static void exit(boolean silent) {
Consumer callback = result -> {
if (result == 0) {
+ if (iOSReturnToMainMenu())
+ return;
exited = true;
exitAnimation(false);
}
@@ -1271,7 +1290,18 @@ public boolean keyUp(int keyCode) {
@Override
public boolean keyTyped(char ch) {
if (keyInputAdapter != null) {
- if (ch >= ' ' && ch <= '~') { //only process this event if character is printable
+ if (Gdx.app != null && Gdx.app.getType() == Application.ApplicationType.iOS) {
+ // The iOS software keyboard delivers one keyTyped per tap and
+ // routes backspace (0x08) / delete (0x7F) through keyTyped
+ // rather than keyDown. The upstream de-dup below blocked
+ // repeated characters (e.g. "aa" in "Kaalia") because iOS
+ // gives no keyUp to reset it, and the printable-only filter
+ // dropped delete entirely. Pass these straight through; the
+ // text field handles backspace/delete.
+ if ((ch >= ' ' && ch <= '~') || ch == '\b' || ch == '\u007F') {
+ return keyInputAdapter.keyTyped(ch);
+ }
+ } else if (ch >= ' ' && ch <= '~') { //only process this event if character is printable
//prevent firing this event more than once for the same character on the same key down, otherwise it fires too often
if (lastKeyTyped != ch || !keyTyped) {
keyTyped = true;
diff --git a/forge-gui-mobile/src/forge/adventure/scene/Scene.java b/forge-gui-mobile/src/forge/adventure/scene/Scene.java
index 2e84eaf364d0..3a6f0b8d737c 100644
--- a/forge-gui-mobile/src/forge/adventure/scene/Scene.java
+++ b/forge-gui-mobile/src/forge/adventure/scene/Scene.java
@@ -13,30 +13,42 @@
public abstract class Scene implements Disposable {
static class SceneControllerListener implements ControllerListener {
+ // On iOS, IosControllerManager fires connected() during Controllers
+ // initialization for already-present controllers — before any scene
+ // exists, so Forge.getCurrentScene() can still be null here.
@Override
public void connected(Controller controller) {
- Forge.getCurrentScene().connected(controller);
+ Scene scene = Forge.getCurrentScene();
+ if (scene != null) {
+ scene.connected(controller);
+ }
}
@Override
public void disconnected(Controller controller) {
- Forge.getCurrentScene().disconnected(controller);
+ Scene scene = Forge.getCurrentScene();
+ if (scene != null) {
+ scene.disconnected(controller);
+ }
}
@Override
public boolean buttonDown(Controller controller, int i) {
- return Forge.getCurrentScene().buttonDown(controller, i);
+ Scene scene = Forge.getCurrentScene();
+ return scene != null && scene.buttonDown(controller, i);
}
@Override
public boolean buttonUp(Controller controller, int i) {
- return Forge.getCurrentScene().buttonUp(controller, i);
+ Scene scene = Forge.getCurrentScene();
+ return scene != null && scene.buttonUp(controller, i);
}
@Override
public boolean axisMoved(Controller controller, int i, float v) {
- return Forge.getCurrentScene().axisMoved(controller, i, v);
+ Scene scene = Forge.getCurrentScene();
+ return scene != null && scene.axisMoved(controller, i, v);
}
}
diff --git a/forge-gui-mobile/src/forge/adventure/util/Config.java b/forge-gui-mobile/src/forge/adventure/util/Config.java
index 1c209233ba32..ca20fb773abe 100644
--- a/forge-gui-mobile/src/forge/adventure/util/Config.java
+++ b/forge-gui-mobile/src/forge/adventure/util/Config.java
@@ -1,5 +1,7 @@
package forge.adventure.util;
+import com.badlogic.gdx.Application;
+import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
@@ -124,8 +126,15 @@ private Config() {
}
private String resPath() {
-
- return GuiBase.isAndroid() ? ForgeConstants.ASSETS_DIR : Files.exists(Paths.get("./res")) ? "./" : Files.exists(Paths.get("./forge-gui/")) ? "./forge-gui/" : "../forge-gui";
+ if (GuiBase.isAndroid()) {
+ return ForgeConstants.ASSETS_DIR;
+ }
+ // iOS: resources live inside the app bundle (ASSETS_DIR); the
+ // desktop-relative "./res" probes below never match there
+ if (Gdx.app != null && Gdx.app.getType() == Application.ApplicationType.iOS) {
+ return ForgeConstants.ASSETS_DIR;
+ }
+ return Files.exists(Paths.get("./res")) ? "./" : Files.exists(Paths.get("./forge-gui/")) ? "./forge-gui/" : "../forge-gui";
}
public String getPlanePath(String plane) {
diff --git a/forge-gui-mobile/src/forge/adventure/util/MapDialog.java b/forge-gui-mobile/src/forge/adventure/util/MapDialog.java
index 59eadffa0687..43b7e1f70905 100644
--- a/forge-gui-mobile/src/forge/adventure/util/MapDialog.java
+++ b/forge-gui-mobile/src/forge/adventure/util/MapDialog.java
@@ -173,7 +173,10 @@ private boolean loadDialog(DialogData dialog) { //Displays a dialog with dialogu
if (dialog.voiceFile != null) {
FileHandle file = Gdx.files.absolute(Config.instance().getFilePath(dialog.voiceFile));
if (file.exists()) {
- audio = Pair.of(file, Forge.getAssets().getMusic(file));
+ Music voice = Forge.getAssets().getMusic(file);
+ if (voice != null) {
+ audio = Pair.of(file, voice);
+ }
}
if (audio != null) {
int vol = FModel.getPreferences().getPrefInt(ForgePreferences.FPref.UI_VOL_MUSIC);
diff --git a/forge-gui-mobile/src/forge/assets/Assets.java b/forge-gui-mobile/src/forge/assets/Assets.java
index b2ce0f18f1ce..29801031d486 100644
--- a/forge-gui-mobile/src/forge/assets/Assets.java
+++ b/forge-gui-mobile/src/forge/assets/Assets.java
@@ -1,5 +1,6 @@
package forge.assets;
+import com.badlogic.gdx.Application.ApplicationType;
import com.badlogic.gdx.Files.FileType;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.assets.AssetLoaderParameters;
@@ -8,6 +9,7 @@
import com.badlogic.gdx.assets.loaders.ParticleEffectLoader;
import com.badlogic.gdx.assets.loaders.TextureLoader.TextureParameter;
import com.badlogic.gdx.assets.loaders.resolvers.AbsoluteFileHandleResolver;
+import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.files.FileHandle;
@@ -33,9 +35,56 @@
import static forge.assets.FSkin.getDefaultSkinFile;
public class Assets implements Disposable {
+ /**
+ * Custom FileHandleResolver for iOS/Android that handles both:
+ * - Absolute paths (for writable cache/Documents files)
+ * - Relative paths (for read-only bundle resources)
+ */
+ private static class HybridFileHandleResolver implements FileHandleResolver {
+ private final AbsoluteFileHandleResolver absoluteResolver = new AbsoluteFileHandleResolver();
+ private final InternalFileHandleResolver internalResolver = new InternalFileHandleResolver();
+ private final boolean isIosOrAndroid;
+
+ public HybridFileHandleResolver() {
+ this.isIosOrAndroid = Gdx.app != null &&
+ (Gdx.app.getType() == ApplicationType.iOS || Gdx.app.getType() == ApplicationType.Android);
+ }
+
+ @Override
+ public FileHandle resolve(String fileName) {
+ if (isIosOrAndroid) {
+ // On iOS/Android: absolute paths use absoluteResolver, relative paths use internalResolver
+ if (fileName.startsWith("/")) {
+ return absoluteResolver.resolve(fileName);
+ } else {
+ return internalResolver.resolve(fileName);
+ }
+ } else {
+ // On Desktop: always use absolute paths
+ return absoluteResolver.resolve(fileName);
+ }
+ }
+ }
+
+ /**
+ * Helper method to get FileHandle that works on both iOS and Android.
+ * On iOS/Android, bundled resources must use internal() with relative paths.
+ * On Desktop, we can use absolute() with full paths.
+ */
+ private static FileHandle getFileHandle(String path) {
+ if (Gdx.app != null && (Gdx.app.getType() == ApplicationType.iOS || Gdx.app.getType() == ApplicationType.Android)) {
+ // On iOS/Android, strip the assets directory prefix and use internal()
+ String relativePath = path.replace(ForgeConstants.ASSETS_DIR, "");
+ return Gdx.files.internal(relativePath);
+ } else {
+ // On Desktop, use absolute paths
+ return Gdx.files.absolute(path);
+ }
+ }
+
private MemoryTrackingAssetManager manager;
private HashMap fonts;
- private HashMap cardArtCache;
+ private java.util.LinkedHashMap cardArtCache;
private HashMap avatarImages;
private HashMap manaImages;
private HashMap symbolLookup;
@@ -51,6 +100,7 @@ public class Assets implements Disposable {
private ObjectMap tmxMap;
private Texture defaultImage, dummy;
private TextureParameter textureParameter;
+ private TextureParameter cardTextureParameter;
private ObjectMap textrafonts;
private int cFB = 0, cFBVal = 0, cTM = 0, cTMVal = 0, cSF = 0, cSFVal = 0, cCF = 0, cCFVal = 0;
private Texture holofoil;
@@ -143,8 +193,12 @@ public void dispose() {
}
public MemoryTrackingAssetManager manager() {
- if (manager == null)
- manager = new MemoryTrackingAssetManager(new AbsoluteFileHandleResolver());
+ if (manager == null) {
+ // Use HybridFileHandleResolver that intelligently handles both:
+ // - Absolute paths (for writable cache/Documents files)
+ // - Relative paths (for read-only bundle resources)
+ manager = new MemoryTrackingAssetManager(new HybridFileHandleResolver());
+ }
return manager;
}
@@ -154,9 +208,16 @@ public HashMap fonts() {
return fonts;
}
- public HashMap cardArtCache() {
+ public java.util.LinkedHashMap cardArtCache() {
+ // LRU-capped: card art entries otherwise accumulate unboundedly over a
+ // long session (memory hygiene on RAM-constrained devices)
if (cardArtCache == null)
- cardArtCache = new HashMap<>();
+ cardArtCache = new java.util.LinkedHashMap(100, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(java.util.Map.Entry eldest) {
+ return size() > 100;
+ }
+ };
return cardArtCache;
}
@@ -261,6 +322,25 @@ public TextureParameter getTextureFilter() {
return textureParameter;
}
+ // Card images are opaque, so load the bundled (AssetManager) card path as RGB565 (half the RGBA8888
+ // footprint) with no mipmaps (cards are drawn ~1:1). Separate from getTextureFilter() so UI textures that
+ // need alpha/mipmaps are unaffected. Mirrors the RGB565 downscale already applied to the downloaded path.
+ public TextureParameter getCardTextureFilter() {
+ if (cardTextureParameter == null) {
+ cardTextureParameter = new TextureParameter();
+ cardTextureParameter.format = Pixmap.Format.RGB565;
+ cardTextureParameter.genMipMaps = false;
+ }
+ if (Forge.isTextureFilteringEnabled()) {
+ cardTextureParameter.minFilter = Texture.TextureFilter.Linear;
+ cardTextureParameter.magFilter = Texture.TextureFilter.Linear;
+ } else {
+ cardTextureParameter.minFilter = Texture.TextureFilter.Nearest;
+ cardTextureParameter.magFilter = Texture.TextureFilter.Nearest;
+ }
+ return cardTextureParameter;
+ }
+
public Texture getTexture(FileHandle file) {
return getTexture(file, true);
}
@@ -276,6 +356,7 @@ public Texture getTexture(FileHandle file, boolean is2D, boolean required) {
System.err.println("Failed to load: " + file + "!. Creating dummy texture.");
return getDummy();
}
+
//internal path can be inside apk or jar..
if (!FileType.Absolute.equals(file.type()) || file.path().contains("fallback_skin")) {
Texture f = fallback_skins().get(file.path());
@@ -313,7 +394,9 @@ public ParticleEffect getEffect(FileHandle file) {
public Texture getDefaultImage() {
if (defaultImage == null) {
- FileHandle blankImage = Gdx.files.absolute(ForgeConstants.NO_CARD_FILE);
+ // iOS/Android: bundled resources need internal() relative paths;
+ // the hybrid resolver then routes them correctly in the manager
+ FileHandle blankImage = getFileHandle(ForgeConstants.NO_CARD_FILE);
if (blankImage.exists()) {
defaultImage = manager().get(blankImage.path(), Texture.class, false);
if (defaultImage != null)
@@ -337,6 +420,7 @@ public void loadTexture(FileHandle file, TextureParameter parameter) {
try {
if (file == null || !file.exists())
return;
+
if (!FileType.Absolute.equals(file.type()))
return;
manager().load(file.path(), Texture.class, parameter);
@@ -420,9 +504,18 @@ public Music getMusic(FileHandle file) {
}
Music music = manager().get(file.path(), Music.class, false);
if (music == null) {
- manager().load(file.path(), Music.class);
- manager().finishLoadingAsset(file.path());
- music = manager().get(file.path(), Music.class);
+ // A load failure (unsupported/corrupt file, or audio unavailable) must
+ // not propagate: getMusic is called synchronously from the render loop
+ // (adventure dialog voice), where an uncaught exception kills the app.
+ // Callers already treat null as "no audio".
+ try {
+ manager().load(file.path(), Music.class);
+ manager().finishLoadingAsset(file.path());
+ music = manager().get(file.path(), Music.class);
+ } catch (Exception e) {
+ System.err.println("Failed to load music " + file.path() + ": " + e.getMessage());
+ return null;
+ }
}
return music;
}
diff --git a/forge-gui-mobile/src/forge/assets/FSkin.java b/forge-gui-mobile/src/forge/assets/FSkin.java
index d6f2668331b9..7fcc721811e5 100644
--- a/forge-gui-mobile/src/forge/assets/FSkin.java
+++ b/forge-gui-mobile/src/forge/assets/FSkin.java
@@ -1,5 +1,6 @@
package forge.assets;
+import com.badlogic.gdx.Application.ApplicationType;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.assets.loaders.TextureLoader;
import com.badlogic.gdx.files.FileHandle;
@@ -31,6 +32,23 @@ public class FSkin {
private static String preferredName;
private static boolean loaded = false;
+ /**
+ * Helper method to get FileHandle that works on both iOS and Android.
+ * On iOS/Android, bundled resources must use internal() with relative paths
+ * (device bundle paths via absolute() fail: /var symlink + sandbox realpath).
+ * On Desktop, we can use absolute() with full paths.
+ */
+ private static FileHandle getFileHandle(String path) {
+ if (Gdx.app != null && (Gdx.app.getType() == ApplicationType.iOS || Gdx.app.getType() == ApplicationType.Android)) {
+ // On iOS/Android, strip the assets directory prefix and use internal()
+ String relativePath = path.replace(ForgeConstants.ASSETS_DIR, "");
+ return Gdx.files.internal(relativePath);
+ } else {
+ // On Desktop, use absolute paths
+ return Gdx.files.absolute(path);
+ }
+ }
+
public static Texture getLogo() {
if (Forge.isMobileAdventureMode)
return Forge.getAssets().getTexture(getDefaultSkinFile("adv_logo.png"), true, false);
@@ -96,7 +114,7 @@ private static void checkThemeDir(FileHandle themeDir, String themeName) {
} else {
if (!isThemeValid(themeDir, themeName, false)) {
System.err.println(themeName + " theme is missing some files to work properly.");
- final FileHandle def = Gdx.files.absolute(ForgeConstants.DEFAULT_SKINS_DIR);
+ final FileHandle def = getFileHandle(ForgeConstants.DEFAULT_SKINS_DIR);
if (def.exists() && def.isDirectory() && isThemeValid(def, "", true)) {
FSkinFont.deleteCachedFiles();
//use default skin if valid
@@ -109,7 +127,9 @@ private static void checkThemeDir(FileHandle themeDir, String themeName) {
}
}
private static void useFallbackDir() {
- preferredDir = GuiBase.isAndroid() ? Gdx.files.internal("fallback_skin") : Gdx.files.classpath("fallback_skin");
+ // iOS and Android both need to use internal() for bundled resources
+ boolean isMobile = Gdx.app != null && (Gdx.app.getType() == ApplicationType.iOS || Gdx.app.getType() == ApplicationType.Android);
+ preferredDir = isMobile ? Gdx.files.internal("fallback_skin") : Gdx.files.classpath("fallback_skin");
}
public static void loadLight(String skinName, final SplashScreen splashScreen,FileHandle prefDir) {
preferredDir = prefDir;
@@ -130,19 +150,38 @@ public static void loadLight(String skinName, final SplashScreen splashScreen) {
Forge.hdbuttons = false;
Forge.hdstart = false;
// TODO: the "v2" string should be a property of the default skin.
- FileHandle v2File = Gdx.files.absolute(ForgeConstants.FONTS_DIR + "v2");
+ // iOS compatibility: Use writable local storage for marker file on iOS
+ FileHandle v2File = null;
+ if (Gdx.app != null && Gdx.app.getType() == ApplicationType.iOS) {
+ // On iOS, use local (writable) storage for the marker file
+ v2File = Gdx.files.local("fonts/v2");
+ } else {
+ // On other platforms, use the standard location
+ v2File = getFileHandle(ForgeConstants.FONTS_DIR + "v2");
+ }
+
if (v2File == null || !v2File.exists()) {
//delete cached fonts
FSkinFont.deleteCachedFiles();
try {
- v2File.file().createNewFile();
+ if (v2File != null) {
+ // Ensure parent directory exists
+ FileHandle parent = v2File.parent();
+ if (parent != null && !parent.exists()) {
+ parent.mkdirs();
+ }
+ // Create the marker file using libGDX API
+ v2File.writeString("", false);
+ }
} catch (Exception e) {
- e.printStackTrace();
+ // iOS compatibility: Silently ignore if we can't create the marker file
+ // The font cache will be deleted each time, which is safe but less efficient
+ System.err.println("Warning: Could not create font version marker file: " + e.getMessage());
}
}
//ensure skins directory exists
- final FileHandle dir = Gdx.files.absolute(ForgeConstants.CACHE_SKINS_DIR);
+ final FileHandle dir = getFileHandle(ForgeConstants.CACHE_SKINS_DIR);
if(preferredDir == null)
{
if (!dir.exists() || !dir.isDirectory()) {
@@ -162,7 +201,7 @@ public static void loadLight(String skinName, final SplashScreen splashScreen) {
}
// Non-default (preferred) skin name and dir.
- preferredDir = Gdx.files.absolute(preferredName.equalsIgnoreCase("default") ? ForgeConstants.BASE_SKINS_DIR + preferredName : ForgeConstants.CACHE_SKINS_DIR + preferredName);
+ preferredDir = getFileHandle(preferredName.equalsIgnoreCase("default") ? ForgeConstants.BASE_SKINS_DIR + preferredName : ForgeConstants.CACHE_SKINS_DIR + preferredName);
if (!preferredDir.exists() || !preferredDir.isDirectory()) {
preferredDir.mkdirs();
}
@@ -572,14 +611,14 @@ public static FileHandle getSkinFile(String filename) {
* Gets a FileHandle for a file within the directory where the default skin files should be stored
*/
public static FileHandle getDefaultSkinFile(String filename) {
- return Gdx.files.absolute(ForgeConstants.DEFAULT_SKINS_DIR + filename);
+ return getFileHandle(ForgeConstants.DEFAULT_SKINS_DIR + filename);
}
/**
* Gets a FileHandle for a file within the planechase cache directory
*/
public static FileHandle getCachePlanechaseFile(String filename) {
- return Gdx.files.absolute(ForgeConstants.CACHE_PLANECHASE_PICS_DIR + filename);
+ return getFileHandle(ForgeConstants.CACHE_PLANECHASE_PICS_DIR + filename);
}
public static FileHandle getSkinDir() {
@@ -594,7 +633,7 @@ public static FileHandle getSkinDir() {
public static Array getSkinDirectoryNames() {
final Array mySkins = new Array<>();
- final FileHandle dir = Gdx.files.absolute(ForgeConstants.CACHE_SKINS_DIR);
+ final FileHandle dir = getFileHandle(ForgeConstants.CACHE_SKINS_DIR);
for (FileHandle skinFile : dir.list()) {
String skinName = skinFile.name();
if (skinName.equalsIgnoreCase(".svn")) { continue; }
diff --git a/forge-gui-mobile/src/forge/assets/FSkinFont.java b/forge-gui-mobile/src/forge/assets/FSkinFont.java
index 7be19e8e3698..06495cbe2981 100644
--- a/forge-gui-mobile/src/forge/assets/FSkinFont.java
+++ b/forge-gui-mobile/src/forge/assets/FSkinFont.java
@@ -2,8 +2,6 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
import java.util.HashMap;
import com.badlogic.gdx.Gdx;
@@ -369,7 +367,15 @@ public String getCharacterSet(String langCode) {
String[] translationFilePaths = { ForgeConstants.LANG_DIR + "cardnames-" + langCode + ".txt",
ForgeConstants.LANG_DIR + langCode + ".properties" };
for (String translationFilePath : translationFilePaths) {
- try (LineReader translationFile = new LineReader(Files.newInputStream(Paths.get(translationFilePath)),
+ // On iOS, use internal() for bundled resources instead of absolute() to avoid sandbox violations
+ // Strip the assets directory prefix to get a relative path
+ String relativePath = translationFilePath.replace(ForgeConstants.ASSETS_DIR, "");
+ FileHandle translationFileHandle = Gdx.files.internal(relativePath);
+ // Skip if file doesn't exist (e.g., cardnames-en-US.txt doesn't exist because English is the base language)
+ if (!translationFileHandle.exists()) {
+ continue;
+ }
+ try (LineReader translationFile = new LineReader(translationFileHandle.read(),
StandardCharsets.UTF_8)) {
for (String fileLine : translationFile.readLines()) {
final int stringLength = fileLine.length();
@@ -407,7 +413,10 @@ private void updateFont() {
if (useCjkFont && !Forge.forcedEnglishonCJKMissing) {
fontName += Forge.locale;
}
- FileHandle fontFile = Gdx.files.absolute(ForgeConstants.FONTS_DIR + fontName + ".fnt");
+ // On iOS, use internal() for bundled resources instead of absolute() to avoid sandbox violations
+ String fontPath = ForgeConstants.FONTS_DIR + fontName + ".fnt";
+ String relativeFontPath = fontPath.replace(ForgeConstants.ASSETS_DIR, "");
+ FileHandle fontFile = Gdx.files.internal(relativeFontPath);
final boolean[] found = {false};
if (fontFile != null && fontFile.exists()) {
FThreads.invokeInEdtNowOrLater(() -> { //font must be initialized on UI thread
@@ -480,7 +489,8 @@ public void dispose() {
getTextureData().consumePixmap().dispose();
}
};
- texture.setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);
+ // Use Linear filtering for smoother text on high-DPI/Retina displays
+ texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
textureRegions.addAll(new TextureRegion(texture));
}
diff --git a/forge-gui-mobile/src/forge/assets/ImageCache.java b/forge-gui-mobile/src/forge/assets/ImageCache.java
index 4047d93e64e1..0c9a1f45737f 100644
--- a/forge-gui-mobile/src/forge/assets/ImageCache.java
+++ b/forge-gui-mobile/src/forge/assets/ImageCache.java
@@ -30,7 +30,6 @@
import com.google.common.collect.Queues;
import com.google.common.collect.Sets;
import forge.deck.DeckProxy;
-import forge.gui.GuiBase;
import forge.item.PaperToken;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
@@ -71,6 +70,39 @@
public class ImageCache {
private static ImageCache imageCache;
private Supplier> missingIconKeys = Suppliers.memoize(HashSet::new);
+
+ // Max resident downloaded card textures (LRU-evicted). Each is a decoded card image kept in native memory,
+ // so on a RAM-constrained device this is the single biggest native ceiling and the per-turn ratchet over a
+ // long game. Lowered from 120 on <=4GB devices in initCache() (from Forge.totalDeviceRAM).
+ private static int downloadedTextureCacheMax = 120;
+ // Longest-side cap (px) applied to downloaded card images before GPU upload; battlefield draws thumbnails and
+ // this keeps zoom acceptable while cutting the decoded RGBA/565 footprint. See downscaleCardPixmap().
+ private static final int MAX_CARD_TEX_DIM = 512;
+
+ // iOS fix: Keep Pixmaps alive - iOS Texture requires Pixmap to stay in memory
+ // Don't dispose Pixmaps - let GC handle them when memory is low
+ private static final java.util.HashMap pixmapCache = new java.util.HashMap();
+
+ // iOS fix: Reverse mapping from Texture to its file path for ImageRecord lookups
+ // Needed because texture.toString() isn't unique (returns dimensions, not path)
+ private static final java.util.IdentityHashMap textureToPath = new java.util.IdentityHashMap();
+
+ // iOS fix: Cache for downloaded image textures (bypassing AssetManager)
+ private static final java.util.LinkedHashMap downloadedTextureCache =
+ new java.util.LinkedHashMap(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(java.util.Map.Entry eldest) {
+ if (size() > downloadedTextureCacheMax) {
+ Texture t = eldest.getValue();
+ pixmapCache.remove(t);
+ textureToPath.remove(t);
+ try { t.dispose(); } catch (Exception ignored) {}
+ return true;
+ }
+ return false;
+ }
+ };
+
public int counter = 0;
private int maxCardCapacity = 300; //default card capacity
private EvictingQueue q;
@@ -90,8 +122,12 @@ private void initCache(int capacity) {
q = EvictingQueue.create(capacity);
//init syncQ for threadsafe use
syncQ = Queues.synchronizedQueue(q);
- //cap
- int cl = GuiBase.isAndroid() ? maxCardCapacity + (capacity / 3) : 400;
+ // Bound the resident downloaded-texture cache tighter on RAM-constrained devices (<=~4GB). 120 full-res
+ // card textures is hundreds of MB of native memory and is the main per-turn ratchet over a long game.
+ boolean lowRam = Forge.totalDeviceRAM > 0 && Forge.totalDeviceRAM <= 4500;
+ downloadedTextureCacheMax = lowRam ? 48 : 120;
+ //cap: scale the bundled-texture set with the (already RAM-adjusted) cacheSize instead of a flat 400
+ int cl = maxCardCapacity + (capacity / 3);
cardsLoaded = new HashSet<>(cl);
}
@@ -115,6 +151,34 @@ private Queue getSyncQ() {
return syncQ;
}
+ /**
+ * iOS fix: Convert absolute path to relative path for libGDX compatibility.
+ * libGDX's AssetManager on iOS requires relative paths from the local directory.
+ * On iOS, files are in Documents/ but libGDX local base is Library/local/,
+ * so we need to construct a relative path like ../../Documents/...
+ */
+ private String toRelativePath(String absolutePath) {
+ // Extract the portion after the app container UUID
+ // Path format: .../Application/UUID/Documents/cache/pics/...
+ // We want: ../../Documents/cache/pics/...
+
+ int documentsIndex = absolutePath.indexOf("/Documents/");
+ if (documentsIndex != -1) {
+ // Found Documents directory - construct relative path from Library/local
+ String relativePath = "../../Documents" + absolutePath.substring(documentsIndex + "/Documents".length());
+ return relativePath;
+ }
+
+ int libraryIndex = absolutePath.indexOf("/Library/");
+ if (libraryIndex != -1) {
+ // File is in Library somewhere - might already be accessible
+ String relativePath = absolutePath.substring(absolutePath.indexOf("/Library/") + "/Library/".length());
+ return relativePath;
+ }
+
+ return absolutePath;
+ }
+
public Texture getDefaultImage() {
return Forge.getAssets().getDefaultImage();
}
@@ -143,6 +207,16 @@ public void disposeTextures() {
}
} catch (Exception ignored) {}
getCardsLoaded().clear();
+ // Clear iOS-specific texture caches to prevent memory accumulation
+ // across games. Textures will be reloaded on demand when needed.
+ for (Texture t : downloadedTextureCache.values()) {
+ try { t.dispose(); } catch (Exception ignored) {}
+ }
+ downloadedTextureCache.clear();
+ pixmapCache.clear();
+ textureToPath.clear();
+ imageRecord.get().clear();
+ counter = 0;
((Forge) Gdx.app.getApplicationListener()).needsUpdate = true;
}
@@ -323,6 +397,10 @@ public Texture getImage(String imageKey, boolean useDefaultIfNotFound, boolean o
try {
image = loadAsset(imageKey, imageFile, others);
} catch (final Exception ex) {
+ // surface silent texture-load failures (iOS diagnosis)
+ System.err.println("LOAD-DEBUG loadAsset failed for " + imageKey
+ + " (file=" + (imageFile != null ? imageFile.getPath() : "null") + "): " + ex);
+ ex.printStackTrace();
image = null;
}
@@ -344,7 +422,70 @@ public Texture getImage(String imageKey, boolean useDefaultIfNotFound, boolean o
private Texture getAsset(File file) {
if (file == null)
return null;
- return Forge.getAssets().manager().get(file.getPath(), Texture.class, false);
+
+ String absolutePath = file.getPath();
+ String path = toRelativePath(absolutePath);
+
+ // iOS fix: Check downloaded texture cache first (for images in Documents)
+ if (absolutePath.contains("/Documents/cache")) {
+ Texture cached = downloadedTextureCache.get(absolutePath);
+ if (cached != null) {
+ return cached;
+ }
+ }
+
+ return Forge.getAssets().manager().get(path, Texture.class, false);
+ }
+
+ // Downscale a decoded card image to at most MAX_CARD_TEX_DIM on its longest side and convert to RGB565
+ // (card art is opaque). Cuts the resident Pixmap+Texture footprint ~4-8x vs full-res RGBA8888 while keeping
+ // zoom acceptable. Returns src unchanged if already small enough and RGB565; otherwise a new Pixmap (and
+ // disposes src). Any failure falls back to the original so the card still renders.
+ private static Pixmap downscaleCardPixmap(Pixmap src) {
+ try {
+ int w = src.getWidth();
+ int h = src.getHeight();
+ int maxSide = Math.max(w, h);
+ boolean needScale = maxSide > MAX_CARD_TEX_DIM;
+ boolean needFormat = src.getFormat() != Pixmap.Format.RGB565;
+ if (!needScale && !needFormat) {
+ return src;
+ }
+ int nw = w;
+ int nh = h;
+ if (needScale) {
+ float s = (float) MAX_CARD_TEX_DIM / (float) maxSide;
+ nw = Math.max(1, Math.round(w * s));
+ nh = Math.max(1, Math.round(h * s));
+ }
+ Pixmap dst = new Pixmap(nw, nh, Pixmap.Format.RGB565);
+ dst.setFilter(Pixmap.Filter.BiLinear);
+ dst.drawPixmap(src, 0, 0, w, h, 0, 0, nw, nh);
+ src.dispose();
+ return dst;
+ } catch (Exception e) {
+ return src;
+ }
+ }
+
+ // --- memory measurement accessors ---
+ public int getDownloadedTextureCount() {
+ return downloadedTextureCache.size();
+ }
+ public long getDownloadedPixmapBytes() {
+ long sum = 0;
+ for (Pixmap p : pixmapCache.values()) {
+ if (p != null) {
+ sum += (long) p.getWidth() * (long) p.getHeight() * (p.getFormat() == Pixmap.Format.RGB565 ? 2L : 4L);
+ }
+ }
+ return sum;
+ }
+ public int getCardsLoadedCount() {
+ return getCardsLoaded().size();
+ }
+ public static int getDownloadedTextureCacheMax() {
+ return downloadedTextureCacheMax;
}
private Texture loadAsset(String imageKey, File file, boolean others) {
@@ -353,29 +494,89 @@ private Texture loadAsset(String imageKey, File file, boolean others) {
Texture check = getAsset(file);
if (check != null)
return check;
+
+ // iOS fix: Convert absolute path to relative path for AssetManager
+ String absolutePath = file.getPath();
+ String fileName = toRelativePath(absolutePath);
+
+ // iOS fix: For downloaded images (in Documents/cache), bypass AssetManager
+ // Read file as bytes and create Pixmap directly (libGDX iOS can't load from FileHandle in Documents)
+ boolean isDownloadedImage = absolutePath.contains("/Documents/cache");
+ Texture directTexture = null;
+
+ if (isDownloadedImage) {
+ try {
+ java.io.File imageFile = new java.io.File(absolutePath);
+ if (imageFile.exists()) {
+ // Read file as byte array
+ byte[] imageBytes = new byte[(int) imageFile.length()];
+ java.io.FileInputStream fis = new java.io.FileInputStream(imageFile);
+ fis.read(imageBytes);
+ fis.close();
+
+ // Decode the downloaded JPEG, then downscale + convert to RGB565 (card art is opaque) to
+ // bound native memory: battlefield cards render as thumbnails, so keeping a full-res
+ // RGBA8888 Pixmap+Texture pair (~5MB/card) is the main per-turn memory ratchet on iOS.
+ Pixmap raw = new Pixmap(imageBytes, 0, imageBytes.length);
+ Pixmap pixmap = downscaleCardPixmap(raw);
+
+ // Create Texture from Pixmap (no mipmaps for faster upload)
+ directTexture = new Texture(pixmap, false);
+ // Apply Linear filtering for smoother card images on high-DPI/Retina displays
+ directTexture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
+
+ // iOS fix: Store Pixmap - iOS Texture needs Pixmap to stay alive
+ // Rely on GC to clean up when memory is low
+ pixmapCache.put(directTexture, pixmap);
+
+ // iOS fix: Cache the texture so we don't reload the same image!
+ downloadedTextureCache.put(absolutePath, directTexture);
+
+ // a texture just became available for a card that may already be
+ // drawn in text mode: invalidate cached card art so renderers pick it up
+ CardRenderer.clearcardArtCache();
+ ((Forge) Gdx.app.getApplicationListener()).needsUpdate = true;
+ }
+ } catch (Exception e) {
+ System.err.println("Failed to load downloaded image: " + absolutePath + " - " + e.getMessage());
+ }
+ }
+
if (!others) {
//update first before clearing
- getSyncQ().add(file.getPath());
- getCardsLoaded().add(file.getPath());
+ getSyncQ().add(fileName);
+ getCardsLoaded().add(fileName);
unloadCardTextures(false);
}
- String fileName = file.getPath();
- //load to assetmanager
- try {
- if (Forge.getAssets().manager().get(fileName, Texture.class, false) == null) {
- Forge.getAssets().manager().load(fileName, Texture.class, Forge.getAssets().getTextureFilter());
- Forge.getAssets().manager().finishLoadingAsset(fileName);
- counter += 1;
+
+ // Only use AssetManager for bundled assets (not downloaded images)
+ if (!isDownloadedImage) {
+ //load to assetmanager - card images are opaque, so use the RGB565 card parameter (half the RGBA8888
+ //footprint) to bound the bundled-texture memory that grows over a long game (cardsLoaded climb)
+ try {
+ if (Forge.getAssets().manager().get(fileName, Texture.class, false) == null) {
+ Forge.getAssets().manager().load(fileName, Texture.class, Forge.getAssets().getCardTextureFilter());
+ Forge.getAssets().manager().finishLoadingAsset(fileName);
+ counter += 1;
+ // a texture just became available for a card that may already be
+ // drawn in text mode: invalidate cached card art so renderers
+ // pick it up (onImageFetched does this for downloads, but
+ // nothing did it for images loaded from disk)
+ CardRenderer.clearcardArtCache();
+ ((Forge) Gdx.app.getApplicationListener()).needsUpdate = true;
+ }
+ } catch (Exception e) {
+ System.err.println("Failed to load image: " + fileName);
+ System.err.println("Error details: " + e.getMessage());
+ e.printStackTrace(System.err);
}
- } catch (Exception e) {
- System.err.println("Failed to load image: " + fileName);
}
//return loaded assets
if (others) {
- return Forge.getAssets().manager().get(fileName, Texture.class, false);
+ return directTexture != null ? directTexture : Forge.getAssets().manager().get(fileName, Texture.class, false);
} else {
- Texture cardTexture = Forge.getAssets().manager().get(fileName, Texture.class, false);
+ Texture cardTexture = directTexture != null ? directTexture : Forge.getAssets().manager().get(fileName, Texture.class, false);
//if full bordermasking is enabled, update the border color
if (cardTexture != null) {
String setCode = imageKey.split("/")[0].trim().toUpperCase();
@@ -386,7 +587,13 @@ else if (setCode.equals("MED") || setCode.equals("ME2") || setCode.equals("ME3")
radius = 25;
else
radius = 22;
- updateImageRecord(cardTexture.toString(), isCloserToWhite(getpixelColor(cardTexture)), radius, cardTexture.toString().contains(".fullborder.") || cardTexture.toString().contains("tokens"));
+ // Downloaded images from Scryfall (in Documents/cache) are always fullborder
+ // Also check file path for .fullborder. or tokens
+ boolean isFullBorder = isDownloadedImage || absolutePath.contains(".fullborder.") || absolutePath.contains("tokens");
+ // Store texture-to-path mapping for later lookups
+ textureToPath.put(cardTexture, absolutePath);
+ // Use absolutePath as key instead of texture.toString() since toString() isn't unique per image
+ updateImageRecord(absolutePath, isCloserToWhite(getpixelColor(cardTexture)), radius, isFullBorder);
}
return cardTexture;
}
@@ -427,20 +634,32 @@ public void unloadCardTextures(boolean removeAll) {
} catch (Exception ignored) {}
}
- public void preloadCache(Iterable keys) {
+ public void preloadCache(final Iterable keys) {
if (FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_DISABLE_CARD_IMAGES))
return;
+ // GL textures must be created on the render thread: preload is called
+ // from the background match-start thread, and textures uploaded there
+ // are blank on iOS (desktop drivers happen to tolerate it)
+ if (Gdx.app != null && !forge.gui.FThreads.isGuiThread()) {
+ Gdx.app.postRunnable(() -> preloadCache(keys));
+ return;
+ }
for (String imageKey : keys) {
if (getImage(imageKey, false) == null)
System.err.println("could not load card image:" + imageKey);
}
}
- public void preloadCache(Deck deck) {
+ public void preloadCache(final Deck deck) {
if (FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_DISABLE_CARD_IMAGES))
return;
if (deck == null)
return;
+ // see preloadCache(Iterable): GL work must happen on the render thread
+ if (Gdx.app != null && !forge.gui.FThreads.isGuiThread()) {
+ Gdx.app.postRunnable(() -> preloadCache(deck));
+ return;
+ }
if (deck.getAllCardsInASinglePool().toFlatList().size() <= 100) {
for (PaperCard p : deck.getAllCardsInASinglePool().toFlatList()) {
if (getImage(p.getImageKey(false), false) == null)
@@ -449,8 +668,16 @@ public void preloadCache(Deck deck) {
}
}
+ // Helper to get the path key for a texture (uses textureToPath mapping or falls back to toString)
+ private String getTextureKey(Texture t) {
+ if (t == null) return null;
+ String path = textureToPath.get(t);
+ return path != null ? path : t.toString();
+ }
+
public TextureRegion croppedBorderImage(Texture image) {
- if (!image.toString().contains(".fullborder.") && !image.toString().contains("tokens"))
+ String key = getTextureKey(image);
+ if (key == null || (!key.contains(".fullborder.") && !key.contains("tokens")))
return new TextureRegion(image);
float rscale = 0.96f;
int rw = Math.round(image.getWidth() * rscale);
@@ -464,7 +691,7 @@ public Color borderColor(Texture t) {
if (t == null)
return Color.valueOf("#171717");
try {
- return Color.valueOf(imageRecord.get().get(t.toString()).colorValue);
+ return Color.valueOf(imageRecord.get().get(getTextureKey(t)).colorValue);
} catch (Exception e) {
return Color.valueOf("#171717");
}
@@ -488,7 +715,7 @@ public void updateImageRecord(String textureString, Pair data,
public int getRadius(Texture t) {
if (t == null)
return 20;
- ImageRecord record = imageRecord.get().get(t.toString());
+ ImageRecord record = imageRecord.get().get(getTextureKey(t));
if (record == null)
return 20;
Integer i = record.cardRadius;
@@ -500,7 +727,7 @@ public int getRadius(Texture t) {
public boolean isFullBorder(Texture image) {
if (image == null)
return false;
- ImageRecord record = imageRecord.get().get(image.toString());
+ ImageRecord record = imageRecord.get().get(getTextureKey(image));
if (record == null)
return false;
return record.isFullBorder;
diff --git a/forge-gui-mobile/src/forge/assets/TextRenderer.java b/forge-gui-mobile/src/forge/assets/TextRenderer.java
index 3649b1a8da5c..e66b3c00b301 100644
--- a/forge-gui-mobile/src/forge/assets/TextRenderer.java
+++ b/forge-gui-mobile/src/forge/assets/TextRenderer.java
@@ -33,7 +33,17 @@ public static String endColor() {
private boolean wrap, needClip;
private List pieces = new ArrayList<>();
private List lineWidths = new ArrayList<>();
- private BreakIterator boundary = BreakIterator.getLineInstance(new Locale(Forge.locale));
+ // iOS-compatible: BreakIterator may not be available (missing ICU resources in RoboVM)
+ private BreakIterator boundary;
+
+ {
+ try {
+ boundary = BreakIterator.getLineInstance(new Locale(Forge.locale));
+ } catch (Exception e) {
+ // RoboVM on iOS doesn't have ICU resources - use simple space-based breaking instead
+ boundary = null;
+ }
+ }
public TextRenderer() {
this(false);
@@ -42,6 +52,36 @@ public TextRenderer(boolean parseReminderText0) {
parseReminderText = parseReminderText0;
}
+ /**
+ * iOS-compatible: Enhanced word breaking fallback for when BreakIterator is unavailable.
+ * Breaks at spaces, punctuation, and MTG-specific characters for proper card text wrapping.
+ */
+ private int findNextSpace(String text, int startIndex) {
+ if (startIndex >= text.length()) {
+ return BreakIterator.DONE;
+ }
+ for (int i = startIndex; i < text.length(); i++) {
+ char c = text.charAt(i);
+ // Break at whitespace
+ if (c == ' ' || c == '\n' || c == '\t') {
+ return i + 1; // Return position after the whitespace
+ }
+ // Break after punctuation (for card text like "Draw a card, then...")
+ if (c == '.' || c == ',' || c == ';' || c == ':' || c == '!' || c == '?') {
+ return i + 1; // Break after punctuation
+ }
+ // Break after closing symbols (for mana costs like "{2}{U}{U}")
+ if (c == '}' || c == ')' || c == ']') {
+ return i + 1; // Break after closing brace/paren
+ }
+ // Break after hyphens and slashes (for "creature/token" or compound words)
+ if (c == '-' || c == '/') {
+ return i + 1; // Break after hyphen or slash
+ }
+ }
+ return BreakIterator.DONE;
+ }
+
//break text in pieces
private void updatePieces(FSkinFont font0) {
pieces.clear();
@@ -60,7 +100,10 @@ private void updatePieces(FSkinFont font0) {
needClip = true;
}
- boundary.setText(fullText);
+ // iOS-compatible: Use simple space-based breaking if BreakIterator unavailable
+ if (boundary != null) {
+ boundary.setText(fullText);
+ }
ForgePreferences prefs = FModel.getPreferences();
boolean hideReminderText = prefs != null && prefs.getPrefBoolean(FPref.UI_HIDE_REMINDER_TEXT);
@@ -69,7 +112,7 @@ private void updatePieces(FSkinFont font0) {
float y = 0;
float pieceWidth = 0;
float lineHeight = font.getLineHeight();
- int nextSpaceIdx = boundary.first();
+ int nextSpaceIdx = boundary != null ? boundary.first() : findNextSpace(fullText, 0);
int lastSpaceIdx = -1;
int lineNum = 0;
StringBuilder text = new StringBuilder();
@@ -84,7 +127,7 @@ private void updatePieces(FSkinFont font0) {
atReminderTextEnd = false;
if (i == nextSpaceIdx) {
lastSpaceIdx = text.length();
- nextSpaceIdx = boundary.next();
+ nextSpaceIdx = boundary != null ? boundary.next() : findNextSpace(fullText, i + 1);
}
try {
ch = fullText.charAt(i);
diff --git a/forge-gui-mobile/src/forge/sound/AudioClip.java b/forge-gui-mobile/src/forge/sound/AudioClip.java
index 8bff621f0b04..1b7ffbebf5e5 100644
--- a/forge-gui-mobile/src/forge/sound/AudioClip.java
+++ b/forge-gui-mobile/src/forge/sound/AudioClip.java
@@ -6,26 +6,42 @@
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
- *
+ *
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
- *
+ *
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package forge.sound;
+import com.badlogic.gdx.Application;
import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.files.FileHandle;
import java.io.File;
public class AudioClip implements IAudioClip {
- private Sound clip;
+ // On iOS, sound effects play through libGDX Music (AVAudioPlayer/AudioQueue) instead
+ // of Sound (OpenAL). The OpenAL effects engine and the AVAudioPlayer music are two
+ // independent CoreAudio clients whose render cycles beat against each other, producing
+ // a slow periodic crackle in the co-summed music (~14-32s, un-fixable by rate-matching
+ // alone). Playing effects in the SAME AVAudioPlayer/mediaserverd domain as the music
+ // collapses everything to one clock and eliminates the beat; the now-unused OpenAL
+ // engine is suspended in Main (forge-gui-ios). Trade-off: a same-type effect can't
+ // overlap itself (one player per type) - re-triggers while still playing are dropped
+ // rather than truncating the sound. Evaluated per-instance (not a class-load-time
+ // constant) so it can never latch the wrong value and strand effects on the suspended
+ // OpenAL engine.
+ private final boolean ios;
+
+ private Sound clip; // non-iOS: OpenAL sound
+ private Music musicClip; // iOS: AVAudioPlayer-backed effect
public static AudioClip createClip(String filename) {
FileHandle fileHandle = Gdx.files.absolute(filename);
@@ -41,9 +57,15 @@ public static AudioClip createClip(File file) {
}
private AudioClip(final FileHandle fileHandle) {
+ ios = Gdx.app != null && Gdx.app.getType() == Application.ApplicationType.iOS;
try {
- //investigate why sound is called outside edt -> Forge.getAssets().getSound(fileHandle), seems the audioclip is cached in SoundSystem instead of using it directly from assetManager
- clip = Gdx.audio.newSound(fileHandle);
+ if (ios) {
+ //route effects through the AVAudioPlayer domain (see class comment)
+ musicClip = Gdx.audio.newMusic(fileHandle);
+ } else {
+ //investigate why sound is called outside edt -> Forge.getAssets().getSound(fileHandle), seems the audioclip is cached in SoundSystem instead of using it directly from assetManager
+ clip = Gdx.audio.newSound(fileHandle);
+ }
}
catch (Exception ex) {
System.err.println("Unable to load sound file: " + fileHandle.toString());
@@ -51,6 +73,25 @@ private AudioClip(final FileHandle fileHandle) {
}
public final void play(float value) {
+ if (ios) {
+ //Drop a re-trigger while the effect is still playing: the single per-type
+ //AVAudioPlayer can't overlap itself, so re-triggering would truncate the
+ //sound into machine-gun fragments. One clean sound per duration-window. Also
+ //avoids the pointless 30ms game-thread stall on a dropped event.
+ if (musicClip == null || musicClip.isPlaying()) {
+ return;
+ }
+ try {
+ musicClip.stop(); //reset to the start
+ musicClip.setLooping(false);
+ musicClip.setVolume(value);
+ musicClip.play();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return;
+ }
+ //non-iOS (OpenAL): keep the historical 30ms voice-reuse delay
if (clip == null) {
return;
}
@@ -68,6 +109,18 @@ public final void play(float value) {
}
public final void loop() {
+ if (ios) {
+ if (musicClip == null || musicClip.isPlaying()) {
+ return;
+ }
+ try {
+ musicClip.setLooping(true);
+ musicClip.play();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return;
+ }
if (clip == null) {
return;
}
@@ -86,28 +139,43 @@ public final void loop() {
@Override
public void dispose() {
- if (clip != null) {
- try {
+ try {
+ if (musicClip != null) {
+ musicClip.dispose();
+ musicClip = null;
+ }
+ if (clip != null) {
clip.dispose();
- } catch (Exception e) {
- e.printStackTrace();
+ clip = null;
}
- clip = null;
+ } catch (Exception e) {
+ e.printStackTrace();
}
}
public final void stop() {
- if (clip == null) {
- return;
- }
try {
- clip.stop();
+ if (ios) {
+ if (musicClip != null) {
+ musicClip.stop();
+ }
+ } else {
+ if (clip != null) {
+ clip.stop();
+ }
+ }
} catch (Exception e) {
e.printStackTrace();
}
}
public final boolean isDone() {
+ if (ios) {
+ //a Music-backed effect is "done" when it isn't currently playing, so
+ //synchronized effects re-trigger only after the previous one finishes
+ //(matches the desktop one-at-a-time contract for synced effects)
+ return musicClip == null || !musicClip.isPlaying();
+ }
return clip != null;//TODO: Make this smarter if Sound supports marking as done
}
}
diff --git a/forge-gui-mobile/src/forge/sound/AudioMusic.java b/forge-gui-mobile/src/forge/sound/AudioMusic.java
index 7afcd8b1a498..207dd86226bb 100644
--- a/forge-gui-mobile/src/forge/sound/AudioMusic.java
+++ b/forge-gui-mobile/src/forge/sound/AudioMusic.java
@@ -71,6 +71,6 @@ public void setVolume(float value) {
@Override
public boolean isPlaying() {
- return music.isPlaying();
+ return music != null && music.isPlaying();
}
}
diff --git a/forge-gui-mobile/src/forge/toolbox/FTextField.java b/forge-gui-mobile/src/forge/toolbox/FTextField.java
index 37654681f636..5b5a2c3af79c 100644
--- a/forge-gui-mobile/src/forge/toolbox/FTextField.java
+++ b/forge-gui-mobile/src/forge/toolbox/FTextField.java
@@ -254,7 +254,33 @@ public boolean allowTouchInput() {
@Override
public boolean keyTyped(char ch) {
+ // iOS fix: the software keyboard delivers backspace/delete through
+ // keyTyped as \b (0x08) or 0x7F, not as a keyDown with
+ // Keys.BACKSPACE. Without this, pressing delete inserts a control
+ // character instead of deleting (the delete key does nothing in the
+ // Settings search field). Mirror the keyDown BACKSPACE handling.
+ if (ch == '\b' || ch == '\u007F') {
+ if (text.length() > 0) {
+ if (selLength == 0) { //delete previous character if selection empty
+ if (selStart > 0) {
+ selStart--;
+ }
+ selLength = 1;
+ }
+ insertText("");
+ if (changedHandler != null) { //live-filter search fields as characters are removed
+ changedHandler.handleEvent(new FEvent(FTextField.this, FEventType.CHANGE, textBeforeKeyInput));
+ }
+ }
+ return true;
+ }
+ if (ch < ' ') { //ignore other control characters
+ return false;
+ }
insertText(String.valueOf(ch));
+ if (changedHandler != null) { //live-filter search fields as characters are typed
+ changedHandler.handleEvent(new FEvent(FTextField.this, FEventType.CHANGE, textBeforeKeyInput));
+ }
return true;
}
diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
index 54a993b2fbe9..577f801d8177 100644
--- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
+++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
@@ -9,8 +9,10 @@
import forge.localinstance.properties.ForgeConstants;
import io.sentry.Sentry;
+import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
+import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
@@ -50,6 +52,114 @@ private static class LibGDXDownloadTask implements Runnable {
this.notifyObservers = notifyObservers;
}
+ /**
+ * Resolve a Scryfall API image URL to a direct CDN URL.
+ * RoboVM's OkHttp cannot follow Scryfall's 302 redirects properly,
+ * so we query the JSON API to get the direct image URL instead.
+ *
+ * Input: https://api.scryfall.com/cards/m3c/160?format=image&version=normal
+ * Output: https://cards.scryfall.io/normal/front/.../uuid.jpg
+ */
+ private String resolveScryfallUrl(String scryfallUrl) {
+ // Extract the JSON API URL by removing ?format=image... parameters
+ int queryIndex = scryfallUrl.indexOf('?');
+ if (queryIndex == -1) return scryfallUrl;
+
+ String jsonUrl = scryfallUrl.substring(0, queryIndex);
+ boolean useArtCrop = scryfallUrl.contains("version=art_crop");
+ String face = "";
+ if (scryfallUrl.contains("face=back")) face = "back";
+
+ try {
+ URL url = new URL(jsonUrl);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent());
+ conn.setRequestProperty("Accept", "application/json");
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(15000);
+
+ int code = conn.getResponseCode();
+ if (code != 200) {
+ System.err.println(" Scryfall JSON API returned " + code + " for " + jsonUrl);
+ conn.disconnect();
+ return null;
+ }
+
+ // Read JSON response
+ StringBuilder sb = new StringBuilder();
+ InputStream is = conn.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"));
+ char[] buf = new char[4096];
+ int n;
+ while ((n = reader.read(buf)) != -1) {
+ sb.append(buf, 0, n);
+ }
+ reader.close();
+ conn.disconnect();
+
+ String json = sb.toString();
+
+ // For back face, look in card_faces array
+ if ("back".equals(face)) {
+ String cdnUrl = extractImageUrl(json, useArtCrop, 1);
+ if (cdnUrl != null) return cdnUrl;
+ }
+
+ // Look in top-level image_uris
+ return extractImageUrl(json, useArtCrop, 0);
+ } catch (Exception e) {
+ System.err.println(" Scryfall JSON resolve failed: " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Extract image URL from Scryfall JSON response.
+ * @param faceIndex 0 = front/top-level, 1 = back face
+ */
+ private String extractImageUrl(String json, boolean useArtCrop, int faceIndex) {
+ String searchBlock = json;
+
+ if (faceIndex > 0) {
+ // Find the card_faces array and skip to the right face
+ int facesStart = json.indexOf("\"card_faces\"");
+ if (facesStart == -1) return null;
+ int currentFace = 0;
+ int pos = facesStart;
+ while (currentFace < faceIndex) {
+ pos = json.indexOf("\"image_uris\"", pos + 1);
+ if (pos == -1) return null;
+ currentFace++;
+ }
+ searchBlock = json.substring(pos);
+ }
+
+ String key = useArtCrop ? "\"art_crop\":\"" : "\"normal\":\"";
+ int start = searchBlock.indexOf(key);
+ if (start == -1) {
+ // Fallback to normal if art_crop not found
+ if (useArtCrop) {
+ key = "\"normal\":\"";
+ start = searchBlock.indexOf(key);
+ }
+ if (start == -1) return null;
+ }
+ start += key.length();
+ int end = searchBlock.indexOf("\"", start);
+ if (end == -1) return null;
+
+ return searchBlock.substring(start, end);
+ }
+
+ private HttpURLConnection openConnection(String urlString) throws IOException {
+ URL url = new URL(urlString);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent());
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(30000);
+ return conn;
+ }
+
private boolean doFetch(String urlToDownload) throws IOException {
if (disableHostedDownload && urlToDownload.startsWith(ForgeConstants.URL_CARDFORGE)) {
// Don't try to download card images from cardforge servers
@@ -72,11 +182,22 @@ private boolean doFetch(String urlToDownload) throws IOException {
if (!newdespath.contains(".full") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) &&
!destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR) && !destPath.startsWith(ForgeConstants.CACHE_PLANECHASE_PICS_DIR))
newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options
- URL url = new URL(urlToDownload);
- System.out.println("Attempting to fetch: " + url);
- HttpURLConnection c = (HttpURLConnection) url.openConnection();
+ // For Scryfall API URLs, resolve to direct CDN URL first
+ // (RoboVM's OkHttp can't follow Scryfall's 302 redirects)
+ String downloadUrl = urlToDownload;
+ if (urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD)) {
+ String cdnUrl = resolveScryfallUrl(urlToDownload);
+ if (cdnUrl == null) {
+ System.err.println(" Could not resolve Scryfall CDN URL");
+ return false;
+ }
+ System.err.println(" Resolved -> " + cdnUrl);
+ downloadUrl = cdnUrl;
+ }
+
+ System.out.println("Attempting to fetch: " + downloadUrl);
+ HttpURLConnection c = openConnection(downloadUrl);
c.setRequestProperty("Accept", "*/*");
- c.setRequestProperty("User-Agent", BuildInfo.getUserAgent());
int responseCode = c.getResponseCode();
String responseMessage = c.getResponseMessage();
@@ -111,6 +232,14 @@ private boolean doFetch(String urlToDownload) throws IOException {
is.close();
}
+ if (destFile.length() == 0) {
+ // never leave a poisoned 0-byte cache file ("downloaded" but
+ // undisplayable, and never retried because the file exists)
+ System.err.println(" Downloaded 0 bytes, skipping");
+ destFile.delete();
+ c.disconnect();
+ return false;
+ }
destFile.moveTo(new FileHandle(newdespath));
c.disconnect();
@@ -146,7 +275,7 @@ public void run() {
if (success) {
break;
}
- } catch (IOException e) {
+ } catch (Exception e) {
if (isPlanechaseBG) {
System.err.println("Failed to download planechase background [" + destPath + "] image: " + e.getMessage());
} else {
@@ -167,7 +296,7 @@ public void run() {
if (success) {
break;
}
- } catch (IOException t) {
+ } catch (Exception t) {
System.out.println("Failed to download setless token [" + destPath + "]: " + e.getMessage());
}
}
@@ -180,6 +309,9 @@ public void run() {
}
}
}
+ if (!success) {
+ System.err.println("All " + downloadUrls.length + " URLs failed for: " + destPath);
+ }
}
}
diff --git a/forge-gui/res/adventure/Shandalar/audio/WizPAR1.mp3 b/forge-gui/res/adventure/Shandalar/audio/WizPAR1.mp3
index c071a4a117fd..49cc39719c31 100644
Binary files a/forge-gui/res/adventure/Shandalar/audio/WizPAR1.mp3 and b/forge-gui/res/adventure/Shandalar/audio/WizPAR1.mp3 differ
diff --git a/forge-gui/res/adventure/Shandalar/audio/WizPAR2.mp3 b/forge-gui/res/adventure/Shandalar/audio/WizPAR2.mp3
index d4814978e43c..46582e43d7c7 100644
Binary files a/forge-gui/res/adventure/Shandalar/audio/WizPAR2.mp3 and b/forge-gui/res/adventure/Shandalar/audio/WizPAR2.mp3 differ
diff --git a/forge-gui/res/adventure/Shandalar/audio/WizPAR3.mp3 b/forge-gui/res/adventure/Shandalar/audio/WizPAR3.mp3
index 2e7ece75d422..7946f894f1ab 100644
Binary files a/forge-gui/res/adventure/Shandalar/audio/WizPAR3.mp3 and b/forge-gui/res/adventure/Shandalar/audio/WizPAR3.mp3 differ
diff --git a/forge-gui/res/adventure/Shandalar/audio/WizPAR4.mp3 b/forge-gui/res/adventure/Shandalar/audio/WizPAR4.mp3
index c157997b9910..29b2e4cbcc79 100644
Binary files a/forge-gui/res/adventure/Shandalar/audio/WizPAR4.mp3 and b/forge-gui/res/adventure/Shandalar/audio/WizPAR4.mp3 differ
diff --git a/forge-gui/res/sound/button_press.mp3 b/forge-gui/res/sound/button_press.mp3
index 1f56bbde7bae..838dd20c8a63 100644
Binary files a/forge-gui/res/sound/button_press.mp3 and b/forge-gui/res/sound/button_press.mp3 differ
diff --git a/forge-gui/res/sound/daytime.mp3 b/forge-gui/res/sound/daytime.mp3
index ae9afd10927d..411f7c38e22f 100644
Binary files a/forge-gui/res/sound/daytime.mp3 and b/forge-gui/res/sound/daytime.mp3 differ
diff --git a/forge-gui/res/sound/sprocket.mp3 b/forge-gui/res/sound/sprocket.mp3
index c9b2abbc99ea..2e5975fbbabc 100644
Binary files a/forge-gui/res/sound/sprocket.mp3 and b/forge-gui/res/sound/sprocket.mp3 differ
diff --git a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java
index 10e0c8f085aa..c509bbe390c7 100644
--- a/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java
+++ b/forge-gui/src/main/java/forge/gamemodes/match/HostedMatch.java
@@ -392,6 +392,15 @@ public void endCurrentGame() {
humanController.getGui().updateDayTime(null);
}
humanControllers.clear();
+
+ // Force GC here rather than in Match.startGame so it runs ONLY for
+ // GUI-hosted matches — headless AI sims (DeckBattler, SimulateMatch,
+ // quest-draft bracket) build Match directly and bypass HostedMatch.
+ // Runs after game=null + afterGameEnd above, so the finished game's
+ // whole object graph is collectible (more than a gc while it's still
+ // referenced). Keeps the iPad under its jetsam ceiling across the
+ // games of a multi-game Commander match.
+ System.gc();
}
public void pause() {
diff --git a/forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java b/forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java
index 69ada7db93ad..ef57479c7b08 100644
--- a/forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java
+++ b/forge-gui/src/main/java/forge/gamemodes/net/NetworkLogWriter.java
@@ -113,7 +113,14 @@ private void writeSystemInfoHeader(BufferedWriter writer, String key) {
sb.append("Network Debug Log Started\n");
sb.append("Log file key: ").append(key).append("\n");
if (!GuiBase.isAndroid()) {
- sb.append("PID: ").append(ProcessHandle.current().pid()).append("\n");
+ try {
+ sb.append("PID: ").append(ProcessHandle.current().pid()).append("\n");
+ } catch (Throwable t) {
+ // ProcessHandle (Java 9) is unavailable on iOS/MobiVM: the jvmdg
+ // downgrade stub needs MethodHandles trusted-lookup, which MobiVM
+ // lacks (NoSuchMethodError). Diagnostic PID only, so skip it.
+ sb.append("PID: (unavailable)\n");
+ }
}
try {
String hwInfo = GuiBase.getHWInfo()
@@ -125,8 +132,9 @@ private void writeSystemInfoHeader(BufferedWriter writer, String key) {
sb.append("=".repeat(80)).append("\n");
writer.write(sb.toString());
writer.flush();
- } catch (IOException e) {
- // Non-critical
+ } catch (Throwable e) {
+ // Non-critical: the network log header is diagnostic; never let it
+ // break hosting (e.g. a MobiVM-unsupported API in a downgrade stub).
}
}
}
diff --git a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java
index 044299cb930b..1182ed78b9ce 100644
--- a/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java
+++ b/forge-gui/src/main/java/forge/gamemodes/net/server/FServerManager.java
@@ -675,14 +675,14 @@ public static String getExternalAddress() {
}
private void mapNatPort() {
- final String localAddress = getLocalAddress();
- final PortMapping portMapping = new PortMapping(port, localAddress, PortMapping.Protocol.TCP, "Forge");
- // Shutdown existing UPnP service if already running
- if (upnpService != null) {
- upnpService.shutdown();
- }
-
try {
+ final String localAddress = getLocalAddress();
+ final PortMapping portMapping = new PortMapping(port, localAddress, PortMapping.Protocol.TCP, "Forge");
+ // Shutdown existing UPnP service if already running
+ if (upnpService != null) {
+ upnpService.shutdown();
+ }
+
// Create a new UPnP service instance
upnpService = new UpnpServiceImpl(GuiBase.getInterface().getUpnpPlatformService());
upnpService.startup();
@@ -702,8 +702,13 @@ public void run() {
}
}
}, 5000);
- } catch (Exception e) {
- netLog.error(e, "UPnP mapping error");
+ } catch (Throwable e) {
+ // UPnP port mapping is optional (it makes the host reachable from the
+ // internet; LAN/direct hosting works without it). jupnp is unavailable
+ // on iOS/MobiVM (provided scope, no platform UPnP service), so the
+ // PortMapping/UpnpService classes fail to load - catch Throwable so
+ // NoClassDefFoundError degrades gracefully instead of killing hosting.
+ netLog.error(e, "UPnP mapping unavailable");
}
}
diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgeProfileProperties.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeProfileProperties.java
index 6416190bf615..1d8a1076883a 100644
--- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeProfileProperties.java
+++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeProfileProperties.java
@@ -159,6 +159,14 @@ private static String getDir(final Properties props, final String propertyKey, f
// returns a pair
private static Pair getDefaultDirs() {
if (!GuiBase.getInterface().isRunningOnDesktop()) { //special case for mobile devices
+ // iOS: Main.java points these at the writable Documents sandbox; the
+ // assets dir is the read-only app bundle, so deriving data/cache from
+ // it would make every write (image cache, prefs) fail on device
+ String iosUserDir = System.getProperty("forge.ios.userDir");
+ String iosCacheDir = System.getProperty("forge.ios.cacheDir");
+ if (iosUserDir != null && iosCacheDir != null) {
+ return Pair.of(iosUserDir, iosCacheDir);
+ }
final String assetsDir = ForgeConstants.ASSETS_DIR;
return Pair.of(assetsDir + "data" + File.separator, assetsDir + "cache" + File.separator);
}