diff --git a/.gitignore b/.gitignore index 92fd033f2bf1..f8ad33badae0 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,4 @@ __pycache__ .claude # EDHREC commander matrix tool intermediates forge-gui/tools/build +forge-gui-ios/robovm.properties diff --git a/forge-gui-ios/Info.plist.xml b/forge-gui-ios/Info.plist.xml index 38e4c2121e98..e89071298bf6 100644 --- a/forge-gui-ios/Info.plist.xml +++ b/forge-gui-ios/Info.plist.xml @@ -31,7 +31,7 @@ UIRequiredDeviceCapabilities - armv7 + arm64 UISupportedInterfaceOrientations @@ -57,5 +57,51 @@ + UILaunchStoryboardName + + UIRequiresFullScreen + + UIViewControllerBasedStatusBarAppearance + + UIStatusBarHidden + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UIUserInterfaceStyle + Light + NSLocalNetworkUsageDescription + Forge uses the local network to discover and connect to multiplayer game servers. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + NSExceptionDomains + + scryfall.com + + NSIncludesSubdomains + + NSTemporaryExceptionAllowsInsecureHTTPLoads + + NSTemporaryExceptionMinimumTLSVersion + TLSv1.2 + + api.scryfall.com + + NSIncludesSubdomains + + NSTemporaryExceptionAllowsInsecureHTTPLoads + + NSTemporaryExceptionMinimumTLSVersion + TLSv1.2 + + + diff --git a/forge-gui-ios/fallback_skin/adv_bg_splash.png b/forge-gui-ios/fallback_skin/adv_bg_splash.png new file mode 100644 index 000000000000..d6da1088913f Binary files /dev/null and b/forge-gui-ios/fallback_skin/adv_bg_splash.png differ diff --git a/forge-gui-ios/fallback_skin/adv_bg_texture.jpg b/forge-gui-ios/fallback_skin/adv_bg_texture.jpg new file mode 100644 index 000000000000..5ea4ad54c163 Binary files /dev/null and b/forge-gui-ios/fallback_skin/adv_bg_texture.jpg differ diff --git a/forge-gui-ios/fallback_skin/bg_splash.png b/forge-gui-ios/fallback_skin/bg_splash.png new file mode 100644 index 000000000000..0bcba091a571 Binary files /dev/null and b/forge-gui-ios/fallback_skin/bg_splash.png differ diff --git a/forge-gui-ios/fallback_skin/bg_texture.jpg b/forge-gui-ios/fallback_skin/bg_texture.jpg new file mode 100644 index 000000000000..50f37309ff0b Binary files /dev/null and b/forge-gui-ios/fallback_skin/bg_texture.jpg differ diff --git a/forge-gui-ios/fallback_skin/font1.ttf b/forge-gui-ios/fallback_skin/font1.ttf new file mode 100644 index 000000000000..28cf43d7e7ff Binary files /dev/null and b/forge-gui-ios/fallback_skin/font1.ttf differ diff --git a/forge-gui-ios/fallback_skin/title_bg_lq.png b/forge-gui-ios/fallback_skin/title_bg_lq.png new file mode 100644 index 000000000000..0f1f79a622be Binary files /dev/null and b/forge-gui-ios/fallback_skin/title_bg_lq.png differ diff --git a/forge-gui-ios/fallback_skin/title_bg_lq_portrait.png b/forge-gui-ios/fallback_skin/title_bg_lq_portrait.png new file mode 100644 index 000000000000..734050646cea Binary files /dev/null and b/forge-gui-ios/fallback_skin/title_bg_lq_portrait.png differ diff --git a/forge-gui-ios/fallback_skin/transition.png b/forge-gui-ios/fallback_skin/transition.png new file mode 100644 index 000000000000..2ba659b5ce24 Binary files /dev/null and b/forge-gui-ios/fallback_skin/transition.png differ diff --git a/forge-gui-ios/libs/libForgeOSLog.a b/forge-gui-ios/libs/libForgeOSLog.a new file mode 100644 index 000000000000..91ec86527426 Binary files /dev/null and b/forge-gui-ios/libs/libForgeOSLog.a differ diff --git a/forge-gui-ios/oslog_wrapper/ForgeOSLog.h b/forge-gui-ios/oslog_wrapper/ForgeOSLog.h new file mode 100644 index 000000000000..98690879f592 --- /dev/null +++ b/forge-gui-ios/oslog_wrapper/ForgeOSLog.h @@ -0,0 +1,11 @@ +// ForgeOSLog.h - Header for os_log wrapper + +#ifndef ForgeOSLog_h +#define ForgeOSLog_h + +void forge_os_log_public(const char *message); +void forge_os_log_info(const char *message); +void forge_os_log_debug(const char *message); +void forge_os_log_error(const char *message); + +#endif /* ForgeOSLog_h */ diff --git a/forge-gui-ios/oslog_wrapper/ForgeOSLog.m b/forge-gui-ios/oslog_wrapper/ForgeOSLog.m new file mode 100644 index 000000000000..64afe6010f92 --- /dev/null +++ b/forge-gui-ios/oslog_wrapper/ForgeOSLog.m @@ -0,0 +1,23 @@ +// ForgeOSLog.m - Simple os_log wrapper for RoboVM +// This wraps os_log since it's a C macro that can't be called directly from Java + +#import +#import + +// Simple function to log a public message using os_log +void forge_os_log_public(const char *message) { + os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEFAULT, "%{public}s", message); +} + +// Log with different levels +void forge_os_log_info(const char *message) { + os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "%{public}s", message); +} + +void forge_os_log_debug(const char *message) { + os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEBUG, "%{public}s", message); +} + +void forge_os_log_error(const char *message) { + os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_ERROR, "%{public}s", message); +} diff --git a/forge-gui-ios/oslog_wrapper/libForgeOSLog.a b/forge-gui-ios/oslog_wrapper/libForgeOSLog.a new file mode 100644 index 000000000000..fae241625a9d Binary files /dev/null and b/forge-gui-ios/oslog_wrapper/libForgeOSLog.a differ diff --git a/forge-gui-ios/pipeline/README.md b/forge-gui-ios/pipeline/README.md new file mode 100644 index 000000000000..e088632e525d --- /dev/null +++ b/forge-gui-ios/pipeline/README.md @@ -0,0 +1,84 @@ +# iOS modern-Java pipeline + +MobiVM/RoboVM ships a **Java-7-era runtime library** (no `java.util.function`, +`java.util.stream`, `Optional`, `java.time`, `java.nio.file`, and none of the +Java 8 default/static members on `Map`, `List`, `Comparator`, `String`, ...), +while upstream Forge freely uses Java 17. Instead of hand-backporting source +on every merge, this pipeline transforms the **built jars**: + +| Stage | Tool | What it does | +|---|---|---| +| 1 | [JvmDowngrader](https://github.com/unimined/JvmDowngrader) `-c 52` | Java 9–17 → Java 8 bytecode: records de-sugared, `List.of`, `String.repeat`, switch expressions, string-concat indy. Lambdas stay `invokedynamic` (MobiVM handles those natively). | +| 2 | `MobiVmBridge` (`src/`) | The Java 8 library surface missing from MobiVM → [streamsupport](https://github.com/stefan-zobel/streamsupport) backports (`java8.*`), `forge.compat` rewrite targets, and app-supplied `java.*` classes. Rules in `bridge.cfg`. `java.time` is the ThreeTen backport relocated in-place (`relocate-time.cfg`); `java.nio.file` is the minimal supply in `java-supply/`. | +| 3 | `MobiVmLinkAudit` (`src/`) | Resolves **every** member reference in the final jars against the real runtime + supplies. Its report *is* the porting workload after an upstream merge — usually empty, occasionally one new `bridge.cfg` line. | + +## Usage + +```bash +# after every upstream merge / module rebuild: +mvn clean install -pl forge-core,forge-game,forge-gui,forge-gui-mobile,forge-ai -DskipTests +pipeline/ios-pipeline.sh classpath # transform -> tmp/ios-m2, prints audit +pipeline/ios-pipeline.sh sim # build + install + launch simulator +pipeline/ios-pipeline.sh device # build + sign + install to iPad +``` + +First run bootstraps third-party jars (JvmDowngrader, streamsupport, +ThreeTen) into `tmp/jvmdg/` automatically. Device IDs / signing identity are +env-overridable — see the header of `ios-pipeline.sh`. + +## Key facts (hard-won — see git history of feature/ios-jvmdg-pipeline) + +- JvmDowngrader `-c 51` (true Java 7 target) is a dead end: its 8→7 stub + bodies throw `MissingStubError`. `-c 52` is the mature path; the bridge + covers the 8→MobiVM gap with *real* implementations (streamsupport). +- streamsupport interfaces are bare SAMs: statics/defaults live in companion + classes (`RefStreams`, `Predicates`, `Comparators`, `Maps`, ...) — that's + what most `bridge.cfg` member rules encode, receiver-prepended. +- The bridge renames jar **entry paths** when whole-type remapping (RoboVM + locates classes by entry path) and rewrites method-reference `Handle`s + inside invokedynamic bootstrap args. +- App-class owners matter: forge collections inherit Java 8 defaults + (`CardCollection.forEach`), so the bridge resolves *every* indexed owner + through its hierarchy, not just `java/*`. +- `forge-gui-ios` sources compile against **untransformed** `~/.m2` jars, + then its `target/classes` go through the same downgrade+bridge — types line + up at the bytecode level. +- libGDX/RoboVM jars are excluded from transformation (MobiVM-clean by + construction); they participate in the resolution index only. +- The RoboVM AOT cache is content-hashed — `SKIP_CACHE_CLEAR=1` is safe and + saves ~15 min per build. +- RoboVM's AOT lambda plugin silently drops `altMetafactory`'s + `FLAG_SERIALIZABLE` (it only implements `FLAG_MARKERS`/`FLAG_BRIDGES`), so a + serializable lambda — `(Supplier & Serializable) X::new`, ECJ-compiled + jgrapht is full of them — comes out NOT implementing `Serializable` and the + compiler-emitted `checkcast java/io/Serializable` throws at runtime. The + bridge converts the ignored bit into an explicit `FLAG_MARKERS` + + `java/io/Serializable` marker, which RoboVM honors; the audit fails on any + site that reaches the final jars without the marker. CAVEAT: this restores + type-compatibility only — RoboVM generates no `writeReplace`, so actually + Java-serializing such a lambda still throws `NotSerializableException` + (nothing does that today). +- `META-INF/services/` entries are class names in disguise: the bridge remaps + both the entry name and the impl lines through the type rules (miss this + and e.g. the relocated ThreeTen zone-rules provider never registers — + `java.time` works until the first `ZoneId.systemDefault()`, then throws + "No time-zone data files registered"). The audit flags any services entry + naming a class that exists nowhere on the final classpath. + +## Bundle identifier (bring your own) + +`forge-gui-ios/robovm.properties` is **untracked** because the bundle +identifier is developer-specific. Register your own App ID, put +`APP_ID=` in the untracked `.env` at the repo root, and the +pipeline generates `robovm.properties` from `robovm.properties.template` on +first run (or copy the template manually and fill in `@APP_ID@`). Signing +settings (`SIGN_ID`, `PROFILE`, `TEAM_ID`) and device UDIDs also live in +`.env` — nothing machine- or identity-specific is tracked. + +## Fresh-clone bootstrap + +`ios-pipeline.sh` also builds and installs every `forge-gui-ios/pom.xml` +supply dependency into `~/.m2` on first run (streamsupport, the jvmdg api +jar, `java-nio-supply`, `java-function-stubs`, the sentry no-op stubs via +`sentry-stub/build.sh`, and a `java-time-supply` compile placeholder) — a +fresh clone needs only a JDK 17, Maven, Xcode, and an `.env`. diff --git a/forge-gui-ios/pipeline/bridge.cfg b/forge-gui-ios/pipeline/bridge.cfg new file mode 100644 index 000000000000..9ee5003573a9 --- /dev/null +++ b/forge-gui-ios/pipeline/bridge.cfg @@ -0,0 +1,168 @@ +# MobiVM bridge rules: map Java 8 library surface missing from MobiVM's +# Java-7-era runtime onto streamsupport / forge.compat backports. +# Applied AFTER JvmDowngrader -c 52 (which handles all Java 9+ surface). +# +# type — whole-type remap +# member [PREPEND ] — unresolved-member redirect + +# ---------- whole-type remaps: classes absent from robovm-rt ---------- +type java/util/function/ java8/util/function/ +type java/util/stream/ java8/util/stream/ +type java/util/Optional java8/util/Optional +type java/util/Spliterator java8/util/Spliterator +type java/util/PrimitiveIterator java8/util/PrimitiveIterator +type java/util/StringJoiner java8/util/StringJoiner +type java/util/DoubleSummaryStatistics java8/util/DoubleSummaryStatistics +type java/util/IntSummaryStatistics java8/util/IntSummaryStatistics +type java/util/LongSummaryStatistics java8/util/LongSummaryStatistics +type java/util/concurrent/CompletableFuture java8/util/concurrent/CompletableFuture +type java/util/concurrent/CompletionStage java8/util/concurrent/CompletionStage +type java/util/concurrent/CompletionException java8/util/concurrent/CompletionException +# java.lang.management is absent on MobiVM; remap the whole package onto the +# Runtime-backed compat implementations (apfloat/tinylog probe it at boot) +type java/lang/management/ forge/compat/mgmt/ + +# ---------- Map + ConcurrentMap defaults ---------- +member java/util/Map getOrDefault java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map putIfAbsent java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map computeIfAbsent java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map computeIfPresent java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map compute java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map merge java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map forEach java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map replaceAll java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map replace java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map remove java8/util/Maps PREPEND Ljava/util/Map; +member java/util/Map$Entry comparingByKey java8/util/Maps$Entry +member java/util/Map$Entry comparingByValue java8/util/Maps$Entry + +# ---------- List / Collection / Iterable / Iterator defaults ---------- +member java/util/List sort java8/util/Lists PREPEND Ljava/util/List; +member java/util/List replaceAll java8/util/Lists PREPEND Ljava/util/List; +member java/util/Collection stream java8/util/stream/StreamSupport PREPEND Ljava/util/Collection; +member java/util/Collection parallelStream java8/util/stream/StreamSupport PREPEND Ljava/util/Collection; +member java/util/Collection removeIf java8/lang/Iterables PREPEND Ljava/lang/Iterable; +member java/util/Collection spliterator java8/lang/Iterables PREPEND Ljava/lang/Iterable; +member java/lang/Iterable forEach java8/lang/Iterables PREPEND Ljava/lang/Iterable; +member java/lang/Iterable spliterator java8/lang/Iterables PREPEND Ljava/lang/Iterable; +member java/util/Iterator forEachRemaining java8/util/Iterators PREPEND Ljava/util/Iterator; + +# ---------- Comparator statics + defaults ---------- +member java/util/Comparator comparing java8/util/Comparators +member java/util/Comparator comparingInt java8/util/Comparators +member java/util/Comparator comparingLong java8/util/Comparators +member java/util/Comparator comparingDouble java8/util/Comparators +member java/util/Comparator naturalOrder java8/util/Comparators +member java/util/Comparator reverseOrder java8/util/Comparators +member java/util/Comparator nullsFirst java8/util/Comparators +member java/util/Comparator nullsLast java8/util/Comparators +member java/util/Comparator reversed java8/util/Comparators PREPEND Ljava/util/Comparator; +member java/util/Comparator thenComparing java8/util/Comparators PREPEND Ljava/util/Comparator; +member java/util/Comparator thenComparingInt java8/util/Comparators PREPEND Ljava/util/Comparator; +member java/util/Comparator thenComparingLong java8/util/Comparators PREPEND Ljava/util/Comparator; +member java/util/Comparator thenComparingDouble java8/util/Comparators PREPEND Ljava/util/Comparator; + +# ---------- static interface methods (streamsupport keeps these in +# companion classes; applied as exact-owner rules before the type remap) ---------- +member java/util/stream/Stream of java8/util/stream/RefStreams +member java/util/stream/Stream ofNullable java8/util/stream/RefStreams +member java/util/stream/Stream empty java8/util/stream/RefStreams +member java/util/stream/Stream concat java8/util/stream/RefStreams +member java/util/stream/Stream iterate java8/util/stream/RefStreams +member java/util/stream/Stream generate java8/util/stream/RefStreams +member java/util/stream/IntStream of java8/util/stream/IntStreams +member java/util/stream/IntStream range java8/util/stream/IntStreams +member java/util/stream/IntStream rangeClosed java8/util/stream/IntStreams +member java/util/stream/IntStream empty java8/util/stream/IntStreams +member java/util/stream/IntStream concat java8/util/stream/IntStreams +member java/util/stream/IntStream iterate java8/util/stream/IntStreams +member java/util/stream/IntStream generate java8/util/stream/IntStreams +member java/util/stream/LongStream of java8/util/stream/LongStreams +member java/util/stream/LongStream range java8/util/stream/LongStreams +member java/util/stream/LongStream rangeClosed java8/util/stream/LongStreams +member java/util/stream/LongStream empty java8/util/stream/LongStreams +member java/util/stream/LongStream concat java8/util/stream/LongStreams +member java/util/stream/DoubleStream of java8/util/stream/DoubleStreams +member java/util/stream/DoubleStream empty java8/util/stream/DoubleStreams +member java/util/stream/DoubleStream concat java8/util/stream/DoubleStreams +member java/util/function/Function identity java8/util/function/Functions +member java/util/function/Predicate isEqual forge/compat/JFunction8 + +# ---------- SAM-interface default methods (streamsupport interfaces are bare +# SAMs; defaults live in companion classes, receiver prepended) ---------- +member java/util/function/Predicate and java8/util/function/Predicates PREPEND Ljava/util/function/Predicate; +member java/util/function/Predicate negate java8/util/function/Predicates PREPEND Ljava/util/function/Predicate; +member java/util/function/Predicate or java8/util/function/Predicates PREPEND Ljava/util/function/Predicate; +member java/util/function/Function andThen java8/util/function/Functions PREPEND Ljava/util/function/Function; +member java/util/function/Function compose java8/util/function/Functions PREPEND Ljava/util/function/Function; +member java/util/function/Consumer andThen java8/util/function/Consumers PREPEND Ljava/util/function/Consumer; +member java/util/function/BinaryOperator minBy java8/util/function/BinaryOperators +member java/util/function/BinaryOperator maxBy java8/util/function/BinaryOperators +member java/util/stream/Collector of java8/util/stream/Collectors + +# ---------- Objects / Arrays ---------- +member java/util/Objects isNull java8/util/Objects +member java/util/Objects nonNull java8/util/Objects +member java/util/Objects requireNonNull java8/util/Objects +member java/util/Arrays stream java8/util/J8Arrays +member java/util/Arrays setAll java8/util/J8Arrays +member java/util/Arrays parallelSort java8/util/J8Arrays +member java/util/Arrays spliterator java8/util/J8Arrays + +# ---------- String / CharSequence ---------- +member java/lang/String join forge/compat/JString8 +member java/lang/String chars forge/compat/JString8 PREPEND Ljava/lang/CharSequence; +member java/lang/String codePoints forge/compat/JString8 PREPEND Ljava/lang/CharSequence; +member java/lang/CharSequence chars forge/compat/JString8 PREPEND Ljava/lang/CharSequence; +member java/lang/CharSequence codePoints forge/compat/JString8 PREPEND Ljava/lang/CharSequence; + +# ---------- Math / boxed primitives (Java 8 statics) ---------- +member java/lang/Math floorDiv forge/compat/JMath8 +member java/lang/Math floorMod forge/compat/JMath8 +member java/lang/Math toIntExact forge/compat/JMath8 +member java/lang/Math addExact forge/compat/JMath8 +member java/lang/Math subtractExact forge/compat/JMath8 +member java/lang/Math multiplyExact forge/compat/JMath8 +member java/lang/Integer sum forge/compat/JBoxed8 +member java/lang/Integer max forge/compat/JBoxed8 +member java/lang/Integer min forge/compat/JBoxed8 +member java/lang/Integer hashCode forge/compat/JBoxed8 +member java/lang/Long sum forge/compat/JBoxed8 +member java/lang/Long max forge/compat/JBoxed8 +member java/lang/Long min forge/compat/JBoxed8 +member java/lang/Long hashCode forge/compat/JBoxed8 +member java/lang/Double sum forge/compat/JBoxed8 +member java/lang/Double max forge/compat/JBoxed8 +member java/lang/Double min forge/compat/JBoxed8 +member java/lang/Double hashCode forge/compat/JBoxed8 +member java/lang/Float hashCode forge/compat/JBoxed8 +member java/lang/Boolean hashCode forge/compat/JBoxed8 +member java/lang/Boolean logicalAnd forge/compat/JBoxed8 +member java/lang/Boolean logicalOr forge/compat/JBoxed8 +member java/lang/ThreadLocal withInitial forge/compat/JThreadLocal8 +member java/lang/Float sum forge/compat/JBoxed8 +member java/lang/Float max forge/compat/JBoxed8 +member java/lang/Float min forge/compat/JBoxed8 +member java/lang/Float isFinite forge/compat/JBoxed8 +member java/lang/Double isFinite forge/compat/JBoxed8 +member java/lang/Integer compareUnsigned forge/compat/JBoxed8 +member java/lang/Integer toUnsignedString forge/compat/JBoxed8 +member java/lang/Integer toUnsignedLong forge/compat/JBoxed8 +member java/lang/Long compareUnsigned forge/compat/JBoxed8 +member java/lang/Long toUnsignedString forge/compat/JBoxed8 +member java/lang/Byte toUnsignedInt forge/compat/JBoxed8 +member java/lang/Short toUnsignedInt forge/compat/JBoxed8 + +# ---------- IO / reflection / nio bridge ---------- +member java/io/BufferedReader lines forge/compat/JIo8 PREPEND Ljava/io/BufferedReader; +member java/io/File toPath forge/compat/JMisc8 PREPEND Ljava/io/File; +member java/lang/StringBuilder append forge/compat/JMisc8 PREPEND Ljava/lang/StringBuilder; +member java/lang/reflect/Type getTypeName forge/compat/JMisc8 PREPEND Ljava/lang/reflect/Type; +member java/lang/Class getTypeName forge/compat/JMisc8 PREPEND Ljava/lang/reflect/Type; +member java/nio/file/Files walk forge/compat/JMisc8 +member java/nio/channels/FileChannel open forge/compat/JMisc8 +member java/util/Date from forge/compat/JMisc8 +member java/util/ResourceBundle getBaseBundleName forge/compat/JMisc8 PREPEND Ljava/util/ResourceBundle; +member java/util/regex/Matcher group forge/compat/JRegex8 PREPEND Ljava/util/regex/Matcher; +member java/util/regex/Matcher start forge/compat/JRegex8 PREPEND Ljava/util/regex/Matcher; +member java/util/regex/Matcher end forge/compat/JRegex8 PREPEND Ljava/util/regex/Matcher; diff --git a/forge-gui-ios/pipeline/ios-pipeline.sh b/forge-gui-ios/pipeline/ios-pipeline.sh new file mode 100755 index 000000000000..fd7df8c46eb2 --- /dev/null +++ b/forge-gui-ios/pipeline/ios-pipeline.sh @@ -0,0 +1,397 @@ +#!/bin/bash +# ios-pipeline.sh — build unmodified upstream Forge for iOS (MobiVM). +# +# MobiVM's runtime library is Java-7-era. Instead of hand-backporting modern +# Java, this pipeline transforms the built jars: +# 1. JvmDowngrader -c 52 : Java 9-17 surface -> Java 8 bytecode +# (records, List.of, String.repeat, switch expressions, concat-indy) +# 2. MobiVmBridge (src/) : Java 8 library surface missing from MobiVM -> +# streamsupport backports + forge.compat + app-supplied java.* classes +# (rules in bridge.cfg; java.time = relocated ThreeTen; java.nio.file = +# java-supply/) +# 3. MobiVmLinkAudit : verifies every java/javax/java8/compat member +# reference in the final jars against the real runtime — the report is +# the complete porting workload after an upstream merge (usually empty) +# +# Usage: +# pipeline/ios-pipeline.sh classpath # transform jars -> tmp/ios-m2 (run +# # after every module rebuild/merge) +# pipeline/ios-pipeline.sh sim # build + install + launch simulator +# pipeline/ios-pipeline.sh device # build + sign + install to iPad +# +# Typical merge workflow: +# git pull && mvn clean install -pl forge-core,forge-game,forge-gui,forge-gui-mobile,forge-ai -DskipTests +# pipeline/ios-pipeline.sh classpath # check the audit report it prints +# pipeline/ios-pipeline.sh sim # or: device +# +# App identity: robovm.properties is untracked (developer-specific bundle id) +# and is generated on first run from robovm.properties.template using APP_ID +# from your .env. Register your own App ID -- do not ship under someone +# else's bundle identifier. +# +# Machine/developer-specific settings (device UDIDs, signing identity) are +# NOT stored in this script. Put them in the untracked .env at the repo root +# (or export them). Example .env entries: +# +# SIM_UDID= +# IPAD_UDID= +# SIGN_ID='Apple Development: Your Name (CERTID1234)' +# PROFILE=/path/to/embedded.mobileprovision +# TEAM_ID=YOURTEAMID +# +# Environment overrides (all may also live in .env): +# APP_ID bundle identifier (default: app.id from robovm.properties) +# APP_EXEC executable name (default: app.executable from robovm.properties) +# SIM_UDID simulator device (required for: sim) +# IPAD_UDID devicectl install target (required for: device) +# SIGN_ID codesign identity (required for: device) +# PROFILE provisioning profile (required for: device) +# TEAM_ID Apple team id (required for: device) +# SKIP_CACHE_CLEAR=1 reuse the RoboVM AOT cache (safe: content-hashed) +# +# Working artifacts (downloaded jars, transformed output, cloned repo) live +# under tmp/ (untracked): tmp/jvmdg/ and tmp/ios-m2/. First run bootstraps +# the third-party jars from GitHub/Maven Central automatically. +set -e +# (no pipefail: `cmd | head` legitimately SIGPIPEs the left side here) + +MODE="${1:-classpath}" + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PIPE="$ROOT/forge-gui-ios/pipeline" +JV="$ROOT/tmp/jvmdg" +WORK="$JV/work" +CLONE="$ROOT/tmp/ios-m2" +M2="$HOME/.m2/repository" +SETTINGS="$ROOT/.mvn/local-settings.xml" +CP_FILE="$JV/ios-classpath.txt" + +# machine/developer-specific settings live in the untracked repo-root .env; +# explicitly exported environment variables take precedence over .env values +# (e.g. SIM_UDID= pipeline/ios-pipeline.sh sim) +_pre_APP_ID="$APP_ID"; _pre_APP_EXEC="$APP_EXEC"; _pre_SIM_UDID="$SIM_UDID" +_pre_IPAD_UDID="$IPAD_UDID"; _pre_SIGN_ID="$SIGN_ID"; _pre_PROFILE="$PROFILE"; _pre_TEAM_ID="$TEAM_ID" +if [ -f "$ROOT/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$ROOT/.env" + set +a +fi +[ -n "$_pre_APP_ID" ] && APP_ID="$_pre_APP_ID" +[ -n "$_pre_APP_EXEC" ] && APP_EXEC="$_pre_APP_EXEC" +[ -n "$_pre_SIM_UDID" ] && SIM_UDID="$_pre_SIM_UDID" +[ -n "$_pre_IPAD_UDID" ] && IPAD_UDID="$_pre_IPAD_UDID" +[ -n "$_pre_SIGN_ID" ] && SIGN_ID="$_pre_SIGN_ID" +[ -n "$_pre_PROFILE" ] && PROFILE="$_pre_PROFILE" +[ -n "$_pre_TEAM_ID" ] && TEAM_ID="$_pre_TEAM_ID" + +# app identity: robovm.properties (untracked) is generated from the tracked +# template using YOUR bundle identifier (APP_ID from .env / environment) +PROPS="$ROOT/forge-gui-ios/robovm.properties" +if [ ! -f "$PROPS" ]; then + if [ -z "$APP_ID" ]; then + echo "ERROR: $PROPS does not exist and APP_ID is not set." >&2 + echo " Register your own bundle identifier and add APP_ID=" >&2 + echo " to $ROOT/.env, then re-run. See robovm.properties.template." >&2 + exit 1 + fi + sed "s/@APP_ID@/$APP_ID/" "$ROOT/forge-gui-ios/robovm.properties.template" > "$PROPS" + echo "generated $PROPS for $APP_ID" +fi +APP_ID="${APP_ID:-$(sed -n 's/^app\.id=//p' "$PROPS")}" +APP_EXEC="${APP_EXEC:-$(sed -n 's/^app\.executable=//p' "$PROPS")}" + +require_env() { + for v in "$@"; do + if [ -z "${!v}" ]; then + echo "ERROR: $v is not set. Export it or add it to $ROOT/.env" >&2 + echo " (see the header of this script for expected entries)" >&2 + exit 1 + fi + done +} + +JVMDG_VER=1.3.6 +SS_VER=1.7.4 +THREETEN_VER=1.7.1 +ASM_VER=9.9.1 + +JVMDG_JAR="$JV/jvmdowngrader-$JVMDG_VER-all.jar" +SS_JAR="$JV/streamsupport-$SS_VER.jar" +SSCF_JAR="$JV/streamsupport-cfuture-$SS_VER.jar" +THREETEN_JAR="$JV/threetenbp-$THREETEN_VER.jar" +API8_JAR="$JV/java-api-8.jar" +NIO_JAR="$JV/java-nio-supply.jar" +ASM="$M2/org/ow2/asm/asm/$ASM_VER/asm-$ASM_VER.jar" +ASMC="$M2/org/ow2/asm/asm-commons/$ASM_VER/asm-commons-$ASM_VER.jar" +TOOLS_CP="$ASM:$ASMC:$JV/tools" + +RT="$M2/com/mobidevelop/robovm/robovm-rt/2.3.23/robovm-rt-2.3.23.jar" +ROBOVM_OBJC="$M2/com/mobidevelop/robovm/robovm-objc/2.3.23/robovm-objc-2.3.23.jar" +ROBOVM_CT="$M2/com/mobidevelop/robovm/robovm-cocoatouch/2.3.23/robovm-cocoatouch-2.3.23.jar" +GDXB="$M2/com/badlogicgames/gdx/gdx-backend-robovm/1.13.5/gdx-backend-robovm-1.13.5.jar" + +# ---------------------------------------------------------------- bootstrap +bootstrap() { + mkdir -p "$JV/tools" + + [ -f "$JVMDG_JAR" ] || curl -sL -o "$JVMDG_JAR" \ + "https://github.com/unimined/JvmDowngrader/releases/download/$JVMDG_VER/jvmdowngrader-$JVMDG_VER-all.jar" + [ -f "$SS_JAR" ] || curl -sL -o "$SS_JAR" \ + "https://repo1.maven.org/maven2/net/sourceforge/streamsupport/streamsupport/$SS_VER/streamsupport-$SS_VER.jar" + [ -f "$SSCF_JAR" ] || curl -sL -o "$SSCF_JAR" \ + "https://repo1.maven.org/maven2/net/sourceforge/streamsupport/streamsupport-cfuture/$SS_VER/streamsupport-cfuture-$SS_VER.jar" + [ -f "$THREETEN_JAR" ] || curl -sL -o "$THREETEN_JAR" \ + "https://repo1.maven.org/maven2/org/threeten/threetenbp/$THREETEN_VER/threetenbp-$THREETEN_VER.jar" + if [ ! -f "$ASM" ]; then + mvn -q --settings "$SETTINGS" dependency:get -Dartifact=org.ow2.asm:asm:$ASM_VER + mvn -q --settings "$SETTINGS" dependency:get -Dartifact=org.ow2.asm:asm-commons:$ASM_VER + fi + + # jvmdg runtime stubs, pre-downgraded to Java 8 + [ -f "$API8_JAR" ] || java -jar "$JVMDG_JAR" -c 52 debug downgradeApi "$API8_JAR" + + # bridge/audit tools (compile when missing or sources newer) + if [ ! -f "$JV/tools/MobiVmBridge.class" ] \ + || [ "$PIPE/src/MobiVmBridge.java" -nt "$JV/tools/MobiVmBridge.class" ] \ + || [ "$PIPE/src/MobiVmLinkAudit.java" -nt "$JV/tools/MobiVmLinkAudit.class" ]; then + javac -cp "$ASM:$ASMC" -d "$JV/tools" "$PIPE/src/MobiVmBridge.java" "$PIPE/src/MobiVmLinkAudit.java" + fi + + # java.nio.file + UncheckedIOException supply (java.base patch-module) + if [ ! -f "$NIO_JAR" ] || [ -n "$(find "$PIPE/java-supply/src" -name '*.java' -newer "$NIO_JAR" 2>/dev/null | head -1)" ]; then + rm -rf "$JV/java-supply-classes" + mkdir -p "$JV/java-supply-classes" + (cd "$PIPE/java-supply/src" && javac --patch-module java.base=. --release 17 \ + -d "$JV/java-supply-classes" $(find . -name "*.java")) + (cd "$JV/java-supply-classes" && jar cf "$NIO_JAR" .) + fi + + # java.util.function stubs (satisfy MobiVM's own Comparator signatures) + FUNC_JAR="$JV/java-function-stubs.jar" + if [ ! -f "$FUNC_JAR" ] || [ -n "$(find "$PIPE/java-function-stubs/src" -name '*.java' -newer "$FUNC_JAR" 2>/dev/null | head -1)" ]; then + rm -rf "$JV/function-stub-classes" + mkdir -p "$JV/function-stub-classes" + (cd "$PIPE/java-function-stubs/src" && javac --patch-module java.base=. --release 17 \ + -d "$JV/function-stub-classes" $(find . -name "*.java")) + (cd "$JV/function-stub-classes" && jar cf "$FUNC_JAR" .) + fi + + # sentry no-op stubs + if [ ! -f "$JV/.sentry-stub-built" ] || [ -n "$(find "$ROOT/forge-gui-ios/sentry-stub/src" -name '*.java' -newer "$JV/.sentry-stub-built" 2>/dev/null | head -1)" ]; then + bash "$ROOT/forge-gui-ios/sentry-stub/build.sh" + touch "$JV/.sentry-stub-built" + fi + + # install every pom-declared supply into ~/.m2 so forge-gui-ios compiles + # resolve on a fresh clone (the classpath step installs the transformed + # variants into the build clone separately) + m2_install() { # groupId artifactId version file + if [ ! -f "$M2/$(tr . / <<< "$1")/$2/$3/$2-$3.jar" ]; then + mvn -q --settings "$SETTINGS" install:install-file -Dpackaging=jar \ + -DgroupId="$1" -DartifactId="$2" -Dversion="$3" -Dfile="$4" + fi + } + m2_install net.sourceforge.streamsupport streamsupport "$SS_VER" "$SS_JAR" + m2_install net.sourceforge.streamsupport streamsupport-cfuture "$SS_VER" "$SSCF_JAR" + m2_install xyz.wagyourtail.jvmdowngrader jvmdowngrader-java-api "$JVMDG_VER-mobivm" "$API8_JAR" + m2_install forge.stubs java-nio-supply 1.0 "$NIO_JAR" + m2_install forge.stubs java-function-stubs 1.0 "$FUNC_JAR" + if [ ! -f "$M2/forge/stubs/java-time-supply/1.0/java-time-supply-1.0.jar" ]; then + # compile-time placeholder; the classpath step installs the real + # (relocated) variant into the build clone + m2_install forge.stubs java-time-supply 1.0 "$THREETEN_JAR" + fi +} + +# ------------------------------------------------------------- transform all +classpath() { + echo "=== [1/8] resolve classpath + fix \${revision} poms ===" + VERSION="$(grep -A1 '' "$ROOT/pom.xml" | grep -o '[0-9.]*')-SNAPSHOT" + find "$M2/forge" -name "*.pom" -exec sed -i '' "s/\\\${revision}/$VERSION/g" {} \; + (cd "$ROOT/forge-gui-ios" && mvn -q dependency:build-classpath \ + -Dmdep.outputFile="$CP_FILE" --settings "$SETTINGS") + + echo "=== [2/8] reset work dirs + clone maven repo (APFS CoW) ===" + rm -rf "$WORK" "$CLONE" + mkdir -p "$WORK/dg" "$WORK/out" + cp -Rc "$M2" "$CLONE" + + echo "=== [3/8] partition classpath ===" + ALL_JARS=$(tr ':' '\n' < "$CP_FILE") + TRANSFORM=() + KEEP=() + while IFS= read -r j; do + case "$j" in + # robovm + ALL badlogicgames (libGDX) jars are MobiVM-clean by + # construction; transforming them is unnecessary risk + */robovm-*|*natives*|*com/badlogicgames/*) KEEP+=("$j") ;; + # compile-time placeholder only: step [5/8] builds the real + # (relocated) supply and step [7/8] installs it over this GAV in + # the clone — transforming/scanning the placeholder just feeds the + # audit stale org.threeten classes and a dead services entry + */forge/stubs/java-time-supply/*) ;; + *) TRANSFORM+=("$j") ;; + esac + done <<< "$ALL_JARS" + echo "transform: ${#TRANSFORM[@]} jars, keep: ${#KEEP[@]} jars" + FULL_CP=$(tr '\n' ':' <<< "$ALL_JARS" | sed 's/:$//') + + echo "=== [4/8] JvmDowngrader -c 52 on ${#TRANSFORM[@]} jars ===" + for j in "${TRANSFORM[@]}"; do + java -jar "$JVMDG_JAR" -q -c 52 downgrade -t "$j" "$WORK/dg/$(basename "$j")" \ + -cp "$FULL_CP" 2>&1 | grep -v "^\[" || true + done + + echo "=== [5/8] relocate ThreeTen -> java.time supply ===" + java -cp "$TOOLS_CP" MobiVmBridge --rules "$PIPE/relocate-time.cfg" --index "$RT" \ + --in "$THREETEN_JAR" --out "$WORK/java-time-supply.jar" | tail -1 + # duplicate TZDB.dat at the relocated path too + (cd "$WORK" && unzip -oq java-time-supply.jar org/threeten/bp/TZDB.dat \ + && mkdir -p java/time && cp org/threeten/bp/TZDB.dat java/time/TZDB.dat \ + && jar -uf java-time-supply.jar java/time/TZDB.dat && rm -rf org java) + + echo "=== [6/8] MobiVmBridge on all downgraded jars + jvmdg api jar ===" + DG_JARS=$(ls "$WORK"/dg/*.jar | tr '\n' ',' | sed 's/,$//') + KEEP_CS=$(IFS=,; echo "${KEEP[*]}") + INDEX="$RT,$ROBOVM_OBJC,$ROBOVM_CT,$SS_JAR,$SSCF_JAR,$API8_JAR,$WORK/java-time-supply.jar,$NIO_JAR,$KEEP_CS,$DG_JARS" + BRIDGE_ARGS=() + for j in "$WORK"/dg/*.jar; do + BRIDGE_ARGS+=(--in "$j" --out "$WORK/out/$(basename "$j")") + done + BRIDGE_ARGS+=(--in "$API8_JAR" --out "$WORK/out/jvmdg-java-api-mobivm.jar") + java -cp "$TOOLS_CP" MobiVmBridge --rules "$PIPE/bridge.cfg" --index "$INDEX" \ + "${BRIDGE_ARGS[@]}" > "$WORK/bridge-report.txt" 2>&1 || true + head -2 "$WORK/bridge-report.txt" + # The downgradeApi output has the runtime API stubs (jXX/stub/*) but NOT the + # jvmdg runtime-support classes those stubs call: exc/* (MissingStubError) and + # util/* (Utils, Function, Pair, IOFunction, ...). Missing util/* crashed + # online hosting (NetworkLogWriter -> a stub -> jvmdg/util/Utils + # NoClassDefFoundError). Pull both from the CLI jar (they're Java-7 bytecode, + # no java-8-gap APIs, so MobiVM-safe raw). NOT version/ or runtime/: those are + # the runtime-downgrader machinery, which drags in shade/asm (~850KB) and is + # never used in static-downgrade mode. + (cd "$WORK" && unzip -oq "$JVMDG_JAR" \ + 'xyz/wagyourtail/jvmdg/exc/*' \ + 'xyz/wagyourtail/jvmdg/util/*' \ + && jar -uf out/jvmdg-java-api-mobivm.jar xyz && rm -rf xyz) + + echo "=== [7/8] overwrite transformed jars in clone + install supplies ===" + for j in "${TRANSFORM[@]}"; do + cp "$WORK/out/$(basename "$j")" "$CLONE/${j#"$M2"/}" + done + MVN_I=(mvn -q --settings "$SETTINGS" install:install-file -Dmaven.repo.local="$CLONE" -Dpackaging=jar) + "${MVN_I[@]}" -Dfile="$SS_JAR" -DgroupId=net.sourceforge.streamsupport -DartifactId=streamsupport -Dversion=$SS_VER + "${MVN_I[@]}" -Dfile="$SSCF_JAR" -DgroupId=net.sourceforge.streamsupport -DartifactId=streamsupport-cfuture -Dversion=$SS_VER + "${MVN_I[@]}" -Dfile="$WORK/out/jvmdg-java-api-mobivm.jar" -DgroupId=xyz.wagyourtail.jvmdowngrader -DartifactId=jvmdowngrader-java-api -Dversion=$JVMDG_VER-mobivm + "${MVN_I[@]}" -Dfile="$WORK/java-time-supply.jar" -DgroupId=forge.stubs -DartifactId=java-time-supply -Dversion=1.0 + "${MVN_I[@]}" -Dfile="$NIO_JAR" -DgroupId=forge.stubs -DartifactId=java-nio-supply -Dversion=1.0 + + echo "=== [8/8] linkage audit (this report = your porting workload) ===" + SCAN=$(ls "$WORK"/out/*.jar | tr '\n' ',' | sed 's/,$//') + java -cp "$TOOLS_CP" MobiVmLinkAudit \ + --rt "$RT,$ROBOVM_OBJC,$ROBOVM_CT,$GDXB,$SS_JAR,$SSCF_JAR,$WORK/out/jvmdg-java-api-mobivm.jar,$WORK/java-time-supply.jar,$NIO_JAR,$KEEP_CS,$SCAN" \ + --scan "$SCAN" > "$WORK/audit-report.txt" 2>&1 || true + head -1 "$WORK/audit-report.txt" + echo "full reports: $WORK/bridge-report.txt $WORK/audit-report.txt" +} + +# --------------------------------------------------- compile + transform app +build_module() { + echo "=== compile forge-gui-ios (against UNtransformed ~/.m2 jars) ===" + # Source is written for java.util.* types; the bridge below rewrites the + # compiled classes to match the transformed runtime classpath. + (cd "$ROOT/forge-gui-ios" && mvn clean compile --settings "$SETTINGS" -DskipTests -q) + + echo "=== transform forge-gui-ios/target/classes (jvmdg + bridge) ===" + cd "$ROOT/forge-gui-ios/target" + jar cf gui-ios-raw.jar -C classes . + java -jar "$JVMDG_JAR" -q -c 52 downgrade -t gui-ios-raw.jar gui-ios-dg.jar \ + -cp "$(tr ':' '\n' < "$CP_FILE" | tr '\n' ':')$SS_JAR:$SSCF_JAR:$NIO_JAR" 2>&1 | grep -v '^\[' || true + DG_JARS=$(ls "$WORK"/out/*.jar | tr '\n' ',' | sed 's/,$//') + java -cp "$TOOLS_CP" MobiVmBridge --rules "$PIPE/bridge.cfg" \ + --index "$RT,$ROBOVM_OBJC,$ROBOVM_CT,$GDXB,$SS_JAR,$SSCF_JAR,$WORK/java-time-supply.jar,$NIO_JAR,$DG_JARS,gui-ios-dg.jar" \ + --in gui-ios-dg.jar --out gui-ios-bridged.jar > "$WORK/gui-ios-bridge-report.txt" 2>&1 || true + rm -rf classes && mkdir classes && (cd classes && jar xf ../gui-ios-bridged.jar && rm -rf META-INF) + + echo "=== audit forge-gui-ios classes ===" + java -cp "$TOOLS_CP" MobiVmLinkAudit \ + --rt "$RT,$ROBOVM_OBJC,$ROBOVM_CT,$GDXB,$SS_JAR,$SSCF_JAR,$WORK/out/jvmdg-java-api-mobivm.jar,$WORK/java-time-supply.jar,$NIO_JAR,$DG_JARS,gui-ios-bridged.jar" \ + --scan gui-ios-bridged.jar || true + cd "$ROOT" +} + +prep_build() { + if [ "${SKIP_CACHE_CLEAR:-0}" != "1" ]; then + rm -rf ~/.robovm/cache + fi + NEWEST_TXT=$(find "$ROOT/forge-gui/res/cardsfolder" -name '*.txt' -newer "$ROOT/forge-gui/res/cardsfolder/cardsfolder.zip" 2>/dev/null | head -1) + if [ ! -f "$ROOT/forge-gui/res/cardsfolder/cardsfolder.zip" ] || [ -n "$NEWEST_TXT" ]; then + (cd "$ROOT/forge-gui/res/cardsfolder" && bash mkzip.sh) + fi + build_module +} + +sim() { + require_env SIM_UDID + prep_build + echo "=== robovm ipad-sim build (ignore the wrong-simulator install error) ===" + (cd "$ROOT/forge-gui-ios" && mvn robovm:ipad-sim --settings "$SETTINGS" \ + -Dmaven.repo.local="$CLONE" -DskipTests 2>&1 | tail -8) || true + APP="$ROOT/forge-gui-ios/target/robovm.tmp/$APP_EXEC.app" + [ -f "$APP/$APP_EXEC" ] || { echo "APP BINARY MISSING - build failed"; exit 1; } + + echo "=== install + launch on simulator $SIM_UDID ===" + xcrun simctl bootstatus "$SIM_UDID" -b || xcrun simctl boot "$SIM_UDID" || true + xcrun simctl install "$SIM_UDID" "$APP" + xcrun simctl launch "$SIM_UDID" "$APP_ID" + echo "logs: xcrun simctl spawn $SIM_UDID log stream --predicate \"process == '$APP_EXEC'\"" +} + +device() { + require_env IPAD_UDID SIGN_ID PROFILE TEAM_ID + prep_build + echo "=== robovm ios-device build (deploy attempt may fail: >1 iPad) ===" + (cd "$ROOT/forge-gui-ios" && mvn robovm:ios-device --settings "$SETTINGS" \ + -Dmaven.repo.local="$CLONE" -DskipTests 2>&1 | tail -8) || true + APP="$ROOT/forge-gui-ios/target/robovm.tmp/$APP_EXEC.app" + [ -f "$APP/$APP_EXEC" ] || { echo "DEVICE BINARY MISSING - build failed"; exit 1; } + + echo "=== sign ===" + cp "$PROFILE" "$APP/embedded.mobileprovision" + cat > /tmp/forge-entitlements.plist < + + + + application-identifier + ${TEAM_ID}.${APP_ID} + com.apple.developer.team-identifier + ${TEAM_ID} + get-task-allow + + keychain-access-groups + + ${TEAM_ID}.* + + + +EOF + for framework in "$APP"/Frameworks/*.framework; do + codesign -f -s "$SIGN_ID" "$framework" + done + codesign -f -s "$SIGN_ID" --entitlements /tmp/forge-entitlements.plist \ + --generate-entitlement-der "$APP" + + echo "=== install to iPad $IPAD_UDID ===" + xcrun devicectl device install app --device "$IPAD_UDID" "$APP" + echo "INSTALLED" +} + +bootstrap +case "$MODE" in + classpath) classpath ;; + sim) sim ;; + device) device ;; + *) echo "usage: $0 [classpath|sim|device]"; exit 1 ;; +esac diff --git a/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/Function.java b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/Function.java new file mode 100644 index 000000000000..e5935f354121 --- /dev/null +++ b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/Function.java @@ -0,0 +1,13 @@ +package java.util.function; + +/** + * Empty stub: MobiVM's bundled java.util.Comparator references this type in + * method signatures but MobiVM ships no java.util.function package, causing + * NoClassDefFoundError when serialization reflects over Comparator (e.g. + * EnumMap.readObject during multiplayer networking). This satisfies the + * class loader; real functional types are remapped to java8.util.function. + */ +@FunctionalInterface +public interface Function { + R apply(T t); +} diff --git a/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToDoubleFunction.java b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToDoubleFunction.java new file mode 100644 index 000000000000..53b3a4dcb77b --- /dev/null +++ b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToDoubleFunction.java @@ -0,0 +1,13 @@ +package java.util.function; + +/** + * Empty stub: MobiVM's bundled java.util.Comparator references this type in + * method signatures but MobiVM ships no java.util.function package, causing + * NoClassDefFoundError when serialization reflects over Comparator (e.g. + * EnumMap.readObject during multiplayer networking). This satisfies the + * class loader; real functional types are remapped to java8.util.function. + */ +@FunctionalInterface +public interface ToDoubleFunction { + double applyAsDouble(T value); +} diff --git a/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToIntFunction.java b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToIntFunction.java new file mode 100644 index 000000000000..266da7b1f72c --- /dev/null +++ b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToIntFunction.java @@ -0,0 +1,13 @@ +package java.util.function; + +/** + * Empty stub: MobiVM's bundled java.util.Comparator references this type in + * method signatures but MobiVM ships no java.util.function package, causing + * NoClassDefFoundError when serialization reflects over Comparator (e.g. + * EnumMap.readObject during multiplayer networking). This satisfies the + * class loader; real functional types are remapped to java8.util.function. + */ +@FunctionalInterface +public interface ToIntFunction { + int applyAsInt(T value); +} diff --git a/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToLongFunction.java b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToLongFunction.java new file mode 100644 index 000000000000..e79a72bb056d --- /dev/null +++ b/forge-gui-ios/pipeline/java-function-stubs/src/java/util/function/ToLongFunction.java @@ -0,0 +1,13 @@ +package java.util.function; + +/** + * Empty stub: MobiVM's bundled java.util.Comparator references this type in + * method signatures but MobiVM ships no java.util.function package, causing + * NoClassDefFoundError when serialization reflects over Comparator (e.g. + * EnumMap.readObject during multiplayer networking). This satisfies the + * class loader; real functional types are remapped to java8.util.function. + */ +@FunctionalInterface +public interface ToLongFunction { + long applyAsLong(T value); +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/io/UncheckedIOException.java b/forge-gui-ios/pipeline/java-supply/src/java/io/UncheckedIOException.java new file mode 100644 index 000000000000..f3334131d439 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/io/UncheckedIOException.java @@ -0,0 +1,21 @@ +package java.io; + +/** + * Java 8 class missing from MobiVM's Java-7-era runtime; referenced by + * commons-lang3, jvmdg stubs, and stream-close paths. Faithful minimal + * implementation. + */ +public class UncheckedIOException extends RuntimeException { + public UncheckedIOException(String message, IOException cause) { + super(message, cause); + } + + public UncheckedIOException(IOException cause) { + super(cause); + } + + @Override + public IOException getCause() { + return (IOException) super.getCause(); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/CopyOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/CopyOption.java new file mode 100644 index 000000000000..6c8eb951e785 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/CopyOption.java @@ -0,0 +1,4 @@ +package java.nio.file; + +public interface CopyOption { +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileAlreadyExistsException.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileAlreadyExistsException.java new file mode 100644 index 000000000000..04a76b94226d --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileAlreadyExistsException.java @@ -0,0 +1,11 @@ +package java.nio.file; + +public class FileAlreadyExistsException extends FileSystemException { + public FileAlreadyExistsException(String file) { + super(file); + } + + public FileAlreadyExistsException(String file, String other, String reason) { + super(file, other, reason); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystem.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystem.java new file mode 100644 index 000000000000..69b08d7a69bb --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystem.java @@ -0,0 +1,27 @@ +package java.nio.file; + +import java.nio.file.attribute.UserPrincipalLookupService; +import java.util.Collections; +import java.util.Set; + +/** + * Minimal FileSystem: exists so probes like guava's TempFileCreator can ask + * what attribute views are supported. Reporting only "basic" steers such + * probes onto their java.io fallback paths. + */ +public abstract class FileSystem { + protected FileSystem() { + } + + public Set supportedFileAttributeViews() { + return Collections.singleton("basic"); + } + + public UserPrincipalLookupService getUserPrincipalLookupService() { + throw new UnsupportedOperationException("user principals not supported"); + } + + public Path getPath(String first, String... more) { + return Paths.get(first, more); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystemException.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystemException.java new file mode 100644 index 000000000000..d22c2513837a --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystemException.java @@ -0,0 +1,42 @@ +package java.nio.file; + +import java.io.IOException; + +public class FileSystemException extends IOException { + private final String file; + private final String other; + + public FileSystemException(String file) { + this.file = file; + this.other = null; + } + + public FileSystemException(String file, String other, String reason) { + super(reason); + this.file = file; + this.other = other; + } + + public String getFile() { + return file; + } + + public String getOtherFile() { + return other; + } + + @Override + public String getMessage() { + StringBuilder sb = new StringBuilder(); + if (file != null) { + sb.append(file); + } + if (other != null) { + sb.append(" -> ").append(other); + } + if (super.getMessage() != null) { + sb.append(": ").append(super.getMessage()); + } + return sb.toString(); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystems.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystems.java new file mode 100644 index 000000000000..f4448eedbc27 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileSystems.java @@ -0,0 +1,12 @@ +package java.nio.file; + +public final class FileSystems { + private static final FileSystem DEFAULT = new FileSystem() { }; + + private FileSystems() { + } + + public static FileSystem getDefault() { + return DEFAULT; + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitOption.java new file mode 100644 index 000000000000..6c0b2c8d83c1 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitOption.java @@ -0,0 +1,5 @@ +package java.nio.file; + +public enum FileVisitOption { + FOLLOW_LINKS +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitResult.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitResult.java new file mode 100644 index 000000000000..8d205f4aac3e --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitResult.java @@ -0,0 +1,5 @@ +package java.nio.file; + +public enum FileVisitResult { + CONTINUE, TERMINATE, SKIP_SUBTREE, SKIP_SIBLINGS +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitor.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitor.java new file mode 100644 index 000000000000..bc77b7c5e33a --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/FileVisitor.java @@ -0,0 +1,14 @@ +package java.nio.file; + +import java.io.IOException; +import java.nio.file.attribute.BasicFileAttributes; + +public interface FileVisitor { + FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException; + + FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException; + + FileVisitResult visitFileFailed(T file, IOException exc) throws IOException; + + FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException; +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Files.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Files.java new file mode 100644 index 000000000000..6a05446aa9a3 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Files.java @@ -0,0 +1,274 @@ +package java.nio.file; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.ForgeFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Minimal Files implementation over java.io.File for the MobiVM supply. + * Charset-taking JDK members default to UTF-8, matching real nio behavior. + * Stream-returning members (walk, lines, list) are NOT here — the bridge + * pass redirects those to forge.compat.JNioBridge (they must return + * java8.util.stream types, which java.base-patched code cannot reference). + */ +public final class Files { + private Files() { } + + public static boolean exists(Path path, LinkOption... options) { + return path.toFile().exists(); + } + + public static boolean notExists(Path path, LinkOption... options) { + return !path.toFile().exists(); + } + + public static boolean isDirectory(Path path, LinkOption... options) { + return path.toFile().isDirectory(); + } + + public static boolean isRegularFile(Path path, LinkOption... options) { + return path.toFile().isFile(); + } + + public static long size(Path path) throws IOException { + return path.toFile().length(); + } + + public static Path createDirectories(Path dir, FileAttribute... attrs) throws IOException { + File f = dir.toFile(); + if (!f.exists() && !f.mkdirs()) { + throw new IOException("could not create directories " + f); + } + return dir; + } + + public static Path createDirectory(Path dir, FileAttribute... attrs) throws IOException { + File f = dir.toFile(); + if (f.exists()) { + throw new FileAlreadyExistsException(f.getPath()); + } + if (!f.mkdir()) { + throw new IOException("could not create directory " + f); + } + return dir; + } + + public static Path createFile(Path path, FileAttribute... attrs) throws IOException { + File f = path.toFile(); + if (!f.createNewFile()) { + throw new FileAlreadyExistsException(f.getPath()); + } + return path; + } + + public static Path createTempDirectory(String prefix, FileAttribute... attrs) throws IOException { + File tmp = File.createTempFile(prefix == null ? "tmp" : prefix, null); + if (!tmp.delete() || !tmp.mkdir()) { + throw new IOException("could not create temp directory"); + } + return new ForgeFilePath(tmp); + } + + public static Path createTempDirectory(Path dir, String prefix, FileAttribute... attrs) throws IOException { + File tmp = File.createTempFile(prefix == null ? "tmp" : prefix, null, dir.toFile()); + if (!tmp.delete() || !tmp.mkdir()) { + throw new IOException("could not create temp directory"); + } + return new ForgeFilePath(tmp); + } + + public static Path createTempFile(Path dir, String prefix, String suffix, FileAttribute... attrs) throws IOException { + return new ForgeFilePath(File.createTempFile(prefix == null ? "tmp" : prefix, suffix, dir.toFile())); + } + + public static Path createTempFile(String prefix, String suffix, FileAttribute... attrs) throws IOException { + return new ForgeFilePath(File.createTempFile(prefix == null ? "tmp" : prefix, suffix)); + } + + public static void delete(Path path) throws IOException { + File f = path.toFile(); + if (!f.exists()) { + throw new NoSuchFileException(f.getPath()); + } + if (!f.delete()) { + throw new IOException("could not delete " + f); + } + } + + public static boolean deleteIfExists(Path path) throws IOException { + return path.toFile().delete(); + } + + public static Path createLink(Path link, Path existing) throws IOException { + // Hard links are unavailable through java.io; a copy preserves the + // observable contract tinylog's rolling writer relies on (a second + // name with the same content). + return copy(existing, link, StandardCopyOption.REPLACE_EXISTING); + } + + public static Path copy(Path source, Path target, CopyOption... options) throws IOException { + File dst = target.toFile(); + boolean replace = false; + for (CopyOption o : options) { + if (o == StandardCopyOption.REPLACE_EXISTING) { + replace = true; + } + } + if (dst.exists() && !replace) { + throw new FileAlreadyExistsException(dst.getPath()); + } + if (source.toFile().isDirectory()) { + if (!dst.exists() && !dst.mkdirs()) { + throw new IOException("could not create " + dst); + } + return target; + } + InputStream in = new BufferedInputStream(new FileInputStream(source.toFile())); + try { + OutputStream out = new BufferedOutputStream(new FileOutputStream(dst)); + try { + byte[] buf = new byte[65536]; + int r; + while ((r = in.read(buf)) > 0) { + out.write(buf, 0, r); + } + } finally { + out.close(); + } + } finally { + in.close(); + } + return target; + } + + public static Path move(Path source, Path target, CopyOption... options) throws IOException { + File src = source.toFile(); + File dst = target.toFile(); + for (CopyOption o : options) { + if (o == StandardCopyOption.REPLACE_EXISTING && dst.exists() && !dst.delete()) { + throw new IOException("could not replace " + dst); + } + } + if (!src.renameTo(dst)) { + copy(source, target, StandardCopyOption.REPLACE_EXISTING); + if (!src.delete()) { + throw new IOException("could not remove " + src); + } + } + return target; + } + + public static byte[] readAllBytes(Path path) throws IOException { + InputStream in = new FileInputStream(path.toFile()); + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[65536]; + int r; + while ((r = in.read(buf)) > 0) { + bos.write(buf, 0, r); + } + return bos.toByteArray(); + } finally { + in.close(); + } + } + + public static List readAllLines(Path path) throws IOException { + BufferedReader r = newBufferedReader(path); + try { + List lines = new ArrayList(); + String line; + while ((line = r.readLine()) != null) { + lines.add(line); + } + return lines; + } finally { + r.close(); + } + } + + public static Path write(Path path, byte[] bytes, OpenOption... options) throws IOException { + OutputStream out = newOutputStream(path, options); + try { + out.write(bytes); + } finally { + out.close(); + } + return path; + } + + public static InputStream newInputStream(Path path, OpenOption... options) throws IOException { + return new FileInputStream(path.toFile()); + } + + public static OutputStream newOutputStream(Path path, OpenOption... options) throws IOException { + boolean append = false; + for (OpenOption o : options) { + if (o == StandardOpenOption.APPEND) { + append = true; + } + } + return new FileOutputStream(path.toFile(), append); + } + + public static BufferedReader newBufferedReader(Path path) throws IOException { + return new BufferedReader(new InputStreamReader(new FileInputStream(path.toFile()), "UTF-8")); + } + + public static BufferedWriter newBufferedWriter(Path path, OpenOption... options) throws IOException { + boolean append = false; + for (OpenOption o : options) { + if (o == StandardOpenOption.APPEND) { + append = true; + } + } + return new BufferedWriter(new OutputStreamWriter(new FileOutputStream(path.toFile(), append), "UTF-8")); + } + + public static Path walkFileTree(Path start, FileVisitor visitor) throws IOException { + walkRecursive(start.toFile(), visitor); + return start; + } + + private static FileVisitResult walkRecursive(File f, FileVisitor visitor) throws IOException { + Path p = new ForgeFilePath(f); + BasicFileAttributes attrs = new ForgeFileAttributes(f); + if (f.isDirectory()) { + FileVisitResult pre = visitor.preVisitDirectory(p, attrs); + if (pre == FileVisitResult.SKIP_SUBTREE || pre == FileVisitResult.TERMINATE) { + return pre == FileVisitResult.TERMINATE ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } + File[] children = f.listFiles(); + if (children != null) { + Arrays.sort(children); + for (File c : children) { + FileVisitResult r = walkRecursive(c, visitor); + if (r == FileVisitResult.TERMINATE) { + return FileVisitResult.TERMINATE; + } + if (r == FileVisitResult.SKIP_SIBLINGS) { + break; + } + } + } + return visitor.postVisitDirectory(p, null); + } + return visitor.visitFile(p, attrs); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/ForgeFilePath.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/ForgeFilePath.java new file mode 100644 index 000000000000..ee4ba55b8f49 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/ForgeFilePath.java @@ -0,0 +1,126 @@ +package java.nio.file; + +import java.io.File; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** File-backed Path implementation for the MobiVM java.nio.file supply. */ +final class ForgeFilePath implements Path { + final File file; + + ForgeFilePath(File file) { + this.file = file; + } + + @Override + public File toFile() { + return file; + } + + @Override + public Path getParent() { + File p = file.getParentFile(); + return p == null ? null : new ForgeFilePath(p); + } + + @Override + public Path getFileName() { + return new ForgeFilePath(new File(file.getName())); + } + + @Override + public Path resolve(Path other) { + return resolve(other.toString()); + } + + @Override + public Path resolve(String other) { + if (other == null || other.isEmpty()) { + return this; + } + File o = new File(other); + if (o.isAbsolute()) { + return new ForgeFilePath(o); + } + return new ForgeFilePath(new File(file, other)); + } + + @Override + public Path relativize(Path other) { + String base = normalizedAbsolute(file); + String target = normalizedAbsolute(other.toFile()); + if (target.equals(base)) { + return new ForgeFilePath(new File("")); + } + String prefix = base.endsWith(File.separator) ? base : base + File.separator; + if (target.startsWith(prefix)) { + return new ForgeFilePath(new File(target.substring(prefix.length()))); + } + throw new IllegalArgumentException("cannot relativize " + target + " against " + base); + } + + private static String normalizedAbsolute(File f) { + try { + return f.getCanonicalPath(); + } catch (Exception e) { + return f.getAbsolutePath(); + } + } + + @Override + public Path normalize() { + return new ForgeFilePath(new File(normalizedAbsolute(file))); + } + + @Override + public Path toAbsolutePath() { + return new ForgeFilePath(file.getAbsoluteFile()); + } + + @Override + public boolean isAbsolute() { + return file.isAbsolute(); + } + + @Override + public boolean startsWith(Path other) { + return normalizedAbsolute(file).startsWith(normalizedAbsolute(other.toFile())); + } + + @Override + public boolean endsWith(Path other) { + return file.getPath().endsWith(other.toFile().getPath()); + } + + @Override + public int compareTo(Path other) { + return file.getPath().compareTo(other.toFile().getPath()); + } + + @Override + public Iterator iterator() { + List parts = new ArrayList(); + for (String part : file.getPath().split(File.separator.equals("\\") ? "\\\\" : File.separator)) { + if (!part.isEmpty()) { + parts.add(new ForgeFilePath(new File(part))); + } + } + return parts.iterator(); + } + + @Override + public boolean equals(Object o) { + return o instanceof ForgeFilePath && file.getPath().equals(((ForgeFilePath) o).file.getPath()); + } + + @Override + public int hashCode() { + return file.getPath().hashCode(); + } + + @Override + public String toString() { + return file.getPath(); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/InvalidPathException.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/InvalidPathException.java new file mode 100644 index 000000000000..82022b20f230 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/InvalidPathException.java @@ -0,0 +1,30 @@ +package java.nio.file; + +public class InvalidPathException extends IllegalArgumentException { + private final String input; + private final String reason; + private final int index; + + public InvalidPathException(String input, String reason) { + this(input, reason, -1); + } + + public InvalidPathException(String input, String reason, int index) { + super(reason + ": " + input); + this.input = input; + this.reason = reason; + this.index = index; + } + + public String getInput() { + return input; + } + + public String getReason() { + return reason; + } + + public int getIndex() { + return index; + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/LinkOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/LinkOption.java new file mode 100644 index 000000000000..edea03e84d83 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/LinkOption.java @@ -0,0 +1,5 @@ +package java.nio.file; + +public enum LinkOption implements OpenOption, CopyOption { + NOFOLLOW_LINKS +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/NoSuchFileException.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/NoSuchFileException.java new file mode 100644 index 000000000000..5923ba79ff55 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/NoSuchFileException.java @@ -0,0 +1,11 @@ +package java.nio.file; + +public class NoSuchFileException extends FileSystemException { + public NoSuchFileException(String file) { + super(file); + } + + public NoSuchFileException(String file, String other, String reason) { + super(file, other, reason); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/OpenOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/OpenOption.java new file mode 100644 index 000000000000..c4440456b81c --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/OpenOption.java @@ -0,0 +1,4 @@ +package java.nio.file; + +public interface OpenOption { +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Path.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Path.java new file mode 100644 index 000000000000..f33d8f9f9e77 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Path.java @@ -0,0 +1,23 @@ +package java.nio.file; + +import java.io.File; + +/** + * Minimal java.nio.file supply for MobiVM (whose Java-7-era runtime lacks the + * package entirely, despite java.nio.file being a Java 7 API). Backed by + * java.io.File. Only the surface demanded by the MobiVmLinkAudit is provided; + * extend when the audit reports new members after an upstream merge. + */ +public interface Path extends Comparable, Iterable { + File toFile(); + Path getParent(); + Path getFileName(); + Path resolve(Path other); + Path resolve(String other); + Path relativize(Path other); + Path normalize(); + Path toAbsolutePath(); + boolean isAbsolute(); + boolean startsWith(Path other); + boolean endsWith(Path other); +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Paths.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Paths.java new file mode 100644 index 000000000000..d7dd74614eaf --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/Paths.java @@ -0,0 +1,15 @@ +package java.nio.file; + +import java.io.File; + +public final class Paths { + private Paths() { } + + public static Path get(String first, String... more) { + File f = new File(first); + for (String m : more) { + f = new File(f, m); + } + return new ForgeFilePath(f); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SimpleFileVisitor.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SimpleFileVisitor.java new file mode 100644 index 000000000000..9752189f9628 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SimpleFileVisitor.java @@ -0,0 +1,35 @@ +package java.nio.file; + +import java.io.IOException; +import java.nio.file.attribute.BasicFileAttributes; + +public class SimpleFileVisitor implements FileVisitor { + protected SimpleFileVisitor() { + } + + @Override + public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(T file, IOException exc) throws IOException { + if (exc != null) { + throw exc; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException { + if (exc != null) { + throw exc; + } + return FileVisitResult.CONTINUE; + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardCopyOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardCopyOption.java new file mode 100644 index 000000000000..2f0638410b37 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardCopyOption.java @@ -0,0 +1,5 @@ +package java.nio.file; + +public enum StandardCopyOption implements CopyOption { + REPLACE_EXISTING, COPY_ATTRIBUTES, ATOMIC_MOVE +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardOpenOption.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardOpenOption.java new file mode 100644 index 000000000000..eed59c37b736 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/StandardOpenOption.java @@ -0,0 +1,5 @@ +package java.nio.file; + +public enum StandardOpenOption implements OpenOption { + READ, WRITE, APPEND, TRUNCATE_EXISTING, CREATE, CREATE_NEW, DELETE_ON_CLOSE, SPARSE, SYNC, DSYNC +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SupportTypes.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SupportTypes.java new file mode 100644 index 000000000000..9c3b2d33a05c --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/SupportTypes.java @@ -0,0 +1,7 @@ +package java.nio.file; + +// This file intentionally holds no type; the individual public types live in +// their own files as required by javac. Kept as documentation anchor. +final class SupportTypes { + private SupportTypes() { } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/BasicFileAttributes.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/BasicFileAttributes.java new file mode 100644 index 000000000000..5c805a20ac67 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/BasicFileAttributes.java @@ -0,0 +1,15 @@ +package java.nio.file.attribute; + +public interface BasicFileAttributes { + FileTime lastModifiedTime(); + + boolean isRegularFile(); + + boolean isDirectory(); + + boolean isSymbolicLink(); + + boolean isOther(); + + long size(); +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileAttribute.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileAttribute.java new file mode 100644 index 000000000000..e4b9e7bf6299 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileAttribute.java @@ -0,0 +1,7 @@ +package java.nio.file.attribute; + +public interface FileAttribute { + String name(); + + T value(); +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileTime.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileTime.java new file mode 100644 index 000000000000..0ccbe8217725 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/FileTime.java @@ -0,0 +1,37 @@ +package java.nio.file.attribute; + +public final class FileTime implements Comparable { + private final long millis; + + private FileTime(long millis) { + this.millis = millis; + } + + public static FileTime fromMillis(long value) { + return new FileTime(value); + } + + public long toMillis() { + return millis; + } + + @Override + public int compareTo(FileTime other) { + return millis < other.millis ? -1 : (millis == other.millis ? 0 : 1); + } + + @Override + public boolean equals(Object o) { + return o instanceof FileTime && millis == ((FileTime) o).millis; + } + + @Override + public int hashCode() { + return (int) (millis ^ (millis >>> 32)); + } + + @Override + public String toString() { + return new java.util.Date(millis).toString(); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/ForgeFileAttributes.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/ForgeFileAttributes.java new file mode 100644 index 000000000000..b530b888f3a5 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/ForgeFileAttributes.java @@ -0,0 +1,42 @@ +package java.nio.file.attribute; + +import java.io.File; + +/** File-backed BasicFileAttributes for the MobiVM java.nio.file supply. */ +public final class ForgeFileAttributes implements BasicFileAttributes { + private final File file; + + public ForgeFileAttributes(File file) { + this.file = file; + } + + @Override + public FileTime lastModifiedTime() { + return FileTime.fromMillis(file.lastModified()); + } + + @Override + public boolean isRegularFile() { + return file.isFile(); + } + + @Override + public boolean isDirectory() { + return file.isDirectory(); + } + + @Override + public boolean isSymbolicLink() { + return false; + } + + @Override + public boolean isOther() { + return false; + } + + @Override + public long size() { + return file.length(); + } +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipal.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipal.java new file mode 100644 index 000000000000..b8570a1cdb89 --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipal.java @@ -0,0 +1,6 @@ +package java.nio.file.attribute; + +import java.security.Principal; + +public interface UserPrincipal extends Principal { +} diff --git a/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipalLookupService.java b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipalLookupService.java new file mode 100644 index 000000000000..49bd251768fc --- /dev/null +++ b/forge-gui-ios/pipeline/java-supply/src/java/nio/file/attribute/UserPrincipalLookupService.java @@ -0,0 +1,12 @@ +package java.nio.file.attribute; + +import java.io.IOException; + +public abstract class UserPrincipalLookupService { + protected UserPrincipalLookupService() { + } + + public UserPrincipal lookupPrincipalByName(String name) throws IOException { + throw new UnsupportedOperationException("user principals not supported"); + } +} diff --git a/forge-gui-ios/pipeline/relocate-time.cfg b/forge-gui-ios/pipeline/relocate-time.cfg new file mode 100644 index 000000000000..76f927706e39 --- /dev/null +++ b/forge-gui-ios/pipeline/relocate-time.cfg @@ -0,0 +1,5 @@ +# Relocate the ThreeTen backport into the java.time namespace so upstream +# java.time references resolve against real app-supplied classes on MobiVM +# (which has no java.time at all). Resource TZDB.dat is duplicated to both +# paths by the pipeline script. +type org/threeten/bp/ java/time/ diff --git a/forge-gui-ios/pipeline/src/MobiVmBridge.java b/forge-gui-ios/pipeline/src/MobiVmBridge.java new file mode 100644 index 000000000000..146c74ecc5b5 --- /dev/null +++ b/forge-gui-ios/pipeline/src/MobiVmBridge.java @@ -0,0 +1,430 @@ +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.Handle; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.commons.ClassRemapper; +import org.objectweb.asm.commons.Remapper; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** + * MobiVM bridge pass: makes JvmDowngrader(-c 52) output link against MobiVM's + * Java-7-era runtime by (a) whole-type remapping of classes absent from the + * runtime to their streamsupport/backport mirrors (java.util.function.* -> + * java8.util.function.*, java.util.stream.* -> java8.util.stream.*, Optional, + * CompletableFuture, ...), and (b) hierarchy-aware member redirection of + * Java-8 default/static members on classes that DO exist (Map.getOrDefault -> + * java8.util.Maps.getOrDefault(map, ...), Comparator.comparing -> + * java8.util.Comparators.comparing, ...). Also rewrites method-reference + * Handles inside invokedynamic bootstrap args with the same member table. + * + * A member is only redirected if it does NOT resolve against the index + * (runtime + supplies + all app jars), so app classes that define their own + * getOrDefault etc. are left untouched. Unresolved references with no rule + * are collected and printed (the residual porting workload; final gate is + * MobiVmLinkAudit). + * + * Rules file format (# comments, blank lines ok): + * type + * member [PREPEND ] + * + * Usage: + * MobiVmBridge --rules bridge.cfg --index rt.jar,supply1.jar,... \ + * --in in.jar --out out.jar + * Multiple --in/--out pairs allowed (order-paired); index should include ALL + * app jars so cross-jar overrides resolve. + */ +public class MobiVmBridge { + + // ---- index (same resolution model as MobiVmLinkAudit) ---- + static class ClassInfo { + String superName; + String[] interfaces; + final Set methods = new HashSet<>(); + final Set fields = new HashSet<>(); + } + static final Map INDEX = new HashMap<>(); + + // ---- rules ---- + static final List TYPE_PREFIXES = new ArrayList<>(); // [from, to], longest-first + // declaringOwner -> name -> {targetOwner, prependDescOrNull} + static final Map> MEMBER_RULES = new HashMap<>(); + + static final Map> UNRESOLVED = new TreeMap<>(); + static int redirects = 0; + static int handleRedirects = 0; + static int serializableLambdaFixes = 0; + static int servicesRemapped = 0; + + public static void main(String[] args) throws Exception { + List ins = new ArrayList<>(); + List outs = new ArrayList<>(); + String rules = null; + List index = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--rules": rules = args[++i]; break; + case "--index": for (String s : args[++i].split(",")) index.add(s); break; + case "--in": ins.add(args[++i]); break; + case "--out": outs.add(args[++i]); break; + default: throw new IllegalArgumentException(args[i]); + } + } + if (rules != null) loadRules(rules); + for (String jar : index) indexJar(jar); + + for (int i = 0; i < ins.size(); i++) { + transform(Paths.get(ins.get(i)), Paths.get(outs.get(i))); + } + System.out.println("BRIDGE: " + redirects + " member redirects, " + handleRedirects + + " handle redirects, " + serializableLambdaFixes + " serializable-lambda markers, " + + servicesRemapped + " services entries remapped across " + ins.size() + " jar(s)"); + if (!UNRESOLVED.isEmpty()) { + System.out.println("BRIDGE-UNRESOLVED (" + UNRESOLVED.size() + ") — no rule, left as-is:"); + for (Map.Entry> e : UNRESOLVED.entrySet()) { + System.out.println(" " + e.getKey() + " <- " + e.getValue().size() + + " refs, e.g. " + e.getValue().get(0)); + } + } + } + + static void loadRules(String path) throws Exception { + for (String line : Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8)) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] p = line.split("\\s+"); + if ("type".equals(p[0])) { + TYPE_PREFIXES.add(new String[]{p[1], p[2]}); + } else if ("member".equals(p[0])) { + String prepend = null; + if (p.length >= 6 && "PREPEND".equals(p[4])) prepend = p[5]; + MEMBER_RULES.computeIfAbsent(p[1], k -> new HashMap<>()) + .put(p[2], new String[]{p[3], prepend}); + } else { + throw new IllegalArgumentException("bad rule: " + line); + } + } + // longest prefix first so java/util/OptionalInt beats java/util/Optional if both listed + TYPE_PREFIXES.sort((a, b) -> b[0].length() - a[0].length()); + } + + static void indexJar(String jarPath) throws Exception { + try (ZipFile zf = new ZipFile(jarPath)) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + if (!e.getName().endsWith(".class") || e.getName().startsWith("META-INF/versions/")) continue; + try (InputStream is = zf.getInputStream(e)) { + ClassReader cr = new ClassReader(is); + final ClassInfo ci = new ClassInfo(); + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int v, int a, String name, String sig, String sup, String[] ifs) { + ci.superName = sup; + ci.interfaces = ifs; + INDEX.put(name, ci); + } + @Override + public MethodVisitor visitMethod(int a, String name, String desc, String sig, String[] ex) { + ci.methods.add(name + desc); + return null; + } + @Override + public FieldVisitor visitField(int a, String name, String desc, String sig, Object val) { + ci.fields.add(name); + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + } + } + } + + static boolean isTracked(String owner) { + return owner.startsWith("java/") || owner.startsWith("javax/"); + } + + static boolean methodResolves(String owner, String nameDesc) { + if (owner.startsWith("[")) return true; + Set seen = new HashSet<>(); + List work = new ArrayList<>(); + work.add(owner); + while (!work.isEmpty()) { + String c = work.remove(work.size() - 1); + if (c == null || !seen.add(c)) continue; + ClassInfo ci = INDEX.get(c); + if (ci == null) continue; + if (ci.methods.contains(nameDesc)) return true; + if (ci.superName != null) work.add(ci.superName); + if (ci.interfaces != null) for (String i : ci.interfaces) work.add(i); + } + return false; + } + + /** Walk owner's hierarchy for a member rule matching this method name. */ + static String[] findRule(String owner, String name) { + Set seen = new HashSet<>(); + List work = new ArrayList<>(); + work.add(owner); + while (!work.isEmpty()) { + String c = work.remove(work.size() - 1); + if (c == null || !seen.add(c)) continue; + Map byName = MEMBER_RULES.get(c); + if (byName != null) { + String[] r = byName.get(name); + if (r != null) return r; + } + ClassInfo ci = INDEX.get(c); + if (ci != null) { + if (ci.superName != null) work.add(ci.superName); + if (ci.interfaces != null) for (String i : ci.interfaces) work.add(i); + } + } + return null; + } + + static void reportUnresolved(String key, String where) { + List l = UNRESOLVED.get(key); + if (l == null) { + l = new ArrayList<>(); + UNRESOLVED.put(key, l); + } + l.add(where); + } + + // ---- the type remapper ---- + static final Remapper TYPE_REMAPPER = new Remapper() { + @Override + public String map(String internalName) { + for (String[] p : TYPE_PREFIXES) { + if (internalName.startsWith(p[0])) { + return p[1] + internalName.substring(p[0].length()); + } + } + return internalName; + } + }; + + // ---- transform one jar ---- + static void transform(Path in, Path out) throws Exception { + try (ZipFile zin = new ZipFile(in.toFile()); + ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(out))) { + java.util.Enumeration en = zin.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + if (e.isDirectory()) continue; + String n = e.getName(); + // module descriptors are useless to RoboVM; stale jar signatures + // would be invalidated by the rewrite anyway + if (n.endsWith("module-info.class") || n.startsWith("META-INF/versions/") + || (n.startsWith("META-INF/") && (n.endsWith(".SF") || n.endsWith(".RSA") || n.endsWith(".DSA") || n.endsWith(".EC")))) { + continue; + } + try (InputStream is = zin.getInputStream(e)) { + if (e.getName().endsWith(".class") && !e.getName().startsWith("META-INF/versions/")) { + byte[] rewritten = rewriteClass(is); + // the entry path must track the (possibly type-remapped) + // class name — RoboVM locates classes by jar entry path + String cls = new ClassReader(rewritten).getClassName(); + zout.putNextEntry(new ZipEntry(cls + ".class")); + zout.write(rewritten); + } else if (n.startsWith("META-INF/services/") && n.length() > "META-INF/services/".length()) { + // ServiceLoader registrations: the entry NAME is the service + // interface and each content line is an impl class — both are + // dotted class names that must track the type-remap rules. + // (Miss this and e.g. the relocated ThreeTen ZoneRulesProvider + // never registers: "No time-zone data files registered".) + String svc = n.substring("META-INF/services/".length()); + String newSvc = TYPE_REMAPPER.map(svc.replace('.', '/')).replace('/', '.'); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[65536]; + int r; + while ((r = is.read(buf)) > 0) bos.write(buf, 0, r); + String content = new String(bos.toByteArray(), StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(); + for (String line : content.split("\n", -1)) { + String t = line.trim(); + if (!t.isEmpty() && !t.startsWith("#")) { + t = TYPE_REMAPPER.map(t.replace('.', '/')).replace('/', '.'); + } + sb.append(t).append('\n'); + } + String newContent = sb.toString(); + if (!newSvc.equals(svc) || !newContent.equals(content)) { + servicesRemapped++; + } + zout.putNextEntry(new ZipEntry("META-INF/services/" + newSvc)); + zout.write(newContent.getBytes(StandardCharsets.UTF_8)); + } else { + zout.putNextEntry(new ZipEntry(e.getName())); + byte[] buf = new byte[65536]; + int r; + while ((r = is.read(buf)) > 0) zout.write(buf, 0, r); + } + } + zout.closeEntry(); + } + } + } + + static byte[] rewriteClass(InputStream is) throws Exception { + ClassReader cr = new ClassReader(is); + ClassWriter cw = new ClassWriter(0); + // chain: reader -> member redirector -> type remapper -> writer + ClassVisitor remap = new ClassRemapper(cw, TYPE_REMAPPER); + ClassVisitor redirect = new MemberRedirector(remap, cr.getClassName()); + cr.accept(redirect, ClassReader.EXPAND_FRAMES); + return cw.toByteArray(); + } + + /** + * RoboVM's AOT lambda plugin (LambdaPlugin/LambdaClassGenerator) implements + * only FLAG_MARKERS(2) and FLAG_BRIDGES(4) of LambdaMetafactory.altMetafactory + * — FLAG_SERIALIZABLE(1) is silently discarded, so a serializable lambda + * comes out NOT implementing Serializable and the compiler-emitted + * `checkcast java/io/Serializable` throws at runtime (first hit: jgrapht + * SupplierUtil. via GameAction.findStaticAbilityToApply — killed + * every game). Convert the ignored bit into an explicit marker interface, + * which RoboVM demonstrably honors. Bit 1 is left set: RoboVM ignores it + * either way and flags=7 + explicit marker is the (spec-valid) historical + * ECJ encoding, so the transformed jars still run on real JVMs. + * + * Arg layout: [samMT, implMH, instMT, flags, + * (markerCount, markers...)?, (bridgeCount, bridges...)?] + * + * Note: this runs BEFORE the ClassRemapper in the visitor chain, so a + * marker matching a type rule would be remapped downstream — that would + * be correct behavior, not a bug. + */ + static Object[] fixSerializableLambda(Handle bsm, Object[] args) { + if (!"java/lang/invoke/LambdaMetafactory".equals(bsm.getOwner()) + || !"altMetafactory".equals(bsm.getName()) + || args.length < 4 || !(args[3] instanceof Integer)) { + return args; + } + int flags = (Integer) args[3]; + if ((flags & 1) == 0) { // FLAG_SERIALIZABLE not set + return args; + } + Type ser = Type.getObjectType("java/io/Serializable"); + List a = new ArrayList<>(Arrays.asList(args)); + if ((flags & 2) != 0) { // FLAG_MARKERS present (javac-style encoding) + int mc = (Integer) a.get(4); + if (a.subList(5, 5 + mc).contains(ser)) { + return args; // already explicit + } + a.set(4, mc + 1); + a.add(5 + mc, ser); + } else { // ECJ-style: bit 1 only — splice markers before the bridge payload + a.set(3, flags | 2); + a.add(4, 1); + a.add(5, ser); + } + serializableLambdaFixes++; + return a.toArray(); + } + + static class MemberRedirector extends ClassVisitor { + final String self; + + MemberRedirector(ClassVisitor cv, String self) { + super(Opcodes.ASM9, cv); + this.self = self; + } + + @Override + public MethodVisitor visitMethod(int acc, String mname, String mdesc, String sig, String[] ex) { + MethodVisitor mv = super.visitMethod(acc, mname, mdesc, sig, ex); + final String where = self + "." + mname; + return new MethodVisitor(Opcodes.ASM9, mv) { + @Override + public void visitMethodInsn(int op, String owner, String name, String desc, boolean itf) { + String[] rule = null; + if (INDEX.containsKey(owner)) { + // ANY indexed owner (incl. app classes): a call to a member + // that no longer resolves is usually a Java 8 default method + // inherited from a java.util interface (e.g. + // forge CardCollection.forEach) — the hierarchy walk finds + // the interface's rule + if (!methodResolves(owner, name + desc)) { + rule = findRule(owner, name); + if (rule == null) { + reportUnresolved("METHOD " + owner + "." + name + desc, where); + } + } + } else if (isTracked(owner)) { + // owner class absent from the index (whole-type-remapped, + // e.g. java.util.stream.Stream): interface statics live in + // companion classes (RefStreams etc.) — exact-owner rules + Map byName = MEMBER_RULES.get(owner); + if (byName != null) { + rule = byName.get(name); + } + } + if (rule != null) { + redirects++; + String newDesc = (op == Opcodes.INVOKESTATIC || rule[1] == null) + ? desc : "(" + rule[1] + desc.substring(1); + super.visitMethodInsn(Opcodes.INVOKESTATIC, rule[0], name, newDesc, false); + return; + } + super.visitMethodInsn(op, owner, name, desc, itf); + } + + @Override + public void visitInvokeDynamicInsn(String name, String desc, Handle bsm, Object... args) { + Object[] newArgs = new Object[args.length]; + for (int i = 0; i < args.length; i++) { + Object a = args[i]; + if (a instanceof Handle) { + Handle h = (Handle) a; + String hOwner = h.getOwner(); + boolean hKnown = INDEX.containsKey(hOwner); + if ((hKnown && !methodResolves(hOwner, h.getName() + h.getDesc())) + || (!hKnown && isTracked(hOwner))) { + String[] rule; + if (hKnown) { + rule = findRule(hOwner, h.getName()); + } else { + Map byName = MEMBER_RULES.get(hOwner); + rule = byName == null ? null : byName.get(h.getName()); + } + if (rule != null) { + handleRedirects++; + String hDesc = (h.getTag() == Opcodes.H_INVOKESTATIC || rule[1] == null) + ? h.getDesc() : "(" + rule[1] + h.getDesc().substring(1); + a = new Handle(Opcodes.H_INVOKESTATIC, rule[0], h.getName(), hDesc, false); + } else if (hKnown) { + reportUnresolved("HANDLE " + hOwner + "." + h.getName() + h.getDesc(), where); + } + } + } + newArgs[i] = a; + } + Object[] outArgs = fixSerializableLambda(bsm, newArgs); + super.visitInvokeDynamicInsn(name, desc, bsm, outArgs); + } + }; + } + } +} diff --git a/forge-gui-ios/pipeline/src/MobiVmLinkAudit.java b/forge-gui-ios/pipeline/src/MobiVmLinkAudit.java new file mode 100644 index 000000000000..4a035245863e --- /dev/null +++ b/forge-gui-ios/pipeline/src/MobiVmLinkAudit.java @@ -0,0 +1,305 @@ +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.Handle; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * MobiVM linkage audit: scans app jars (AFTER pre-pass + JvmDowngrader) for + * references to java/* / javax/* members that do NOT exist in MobiVM's + * runtime (robovm-rt.jar) or in the app-supplied java.* classes + * (java-function-stubs, java.time supply). Every hit is a guaranteed + * NoClassDefFoundError / NoSuchMethodError if that code path executes on iOS. + * + * This replaces the manual CLAUDE.md grep scan: run it after every upstream + * merge and the output IS the remaining porting workload (usually empty). + * + * Usage: MobiVmLinkAudit --rt rt1.jar[,rt2.jar,...] --scan app1.jar[,app2.jar,...] + * Output: one line per missing member: KIND owner.member(desc) <- count refs [sample referencer] + */ +public class MobiVmLinkAudit { + + static class ClassInfo { + String superName; + String[] interfaces; + final Set methods = new HashSet<>(); // name + desc + final Set fields = new HashSet<>(); // name + } + + static final Map INDEX = new HashMap<>(); + static final Map> MISSING = new TreeMap<>(); + static final Set SCANNED_CLASSES = new HashSet<>(); + // jar!entry -> class names referenced (service interface + impl lines) + static final Map> SERVICES = new TreeMap<>(); + + public static void main(String[] args) throws Exception { + List rtJars = new ArrayList<>(); + List scanJars = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + if ("--rt".equals(args[i])) { + for (String s : args[++i].split(",")) rtJars.add(s); + } else if ("--scan".equals(args[i])) { + for (String s : args[++i].split(",")) scanJars.add(s); + } + } + for (String jar : rtJars) index(jar); + for (String jar : scanJars) scan(jar); + checkServices(); + + if (MISSING.isEmpty()) { + System.out.println("AUDIT CLEAN: no unresolved java/* references"); + return; + } + System.out.println("AUDIT: " + MISSING.size() + " unresolved java/javax references:"); + for (Map.Entry> e : MISSING.entrySet()) { + List refs = e.getValue(); + System.out.println(e.getKey() + " <- " + refs.size() + " refs, e.g. " + refs.get(0)); + } + System.exit(2); + } + + // ---- indexing the runtime ---- + + static void index(String jarPath) throws Exception { + try (ZipFile zf = new ZipFile(jarPath)) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + if (!e.getName().endsWith(".class") || e.getName().startsWith("META-INF/versions/")) continue; + try (InputStream is = zf.getInputStream(e)) { + ClassReader cr = new ClassReader(is); + final ClassInfo ci = new ClassInfo(); + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int v, int acc, String name, String sig, String sup, String[] ifs) { + ci.superName = sup; + ci.interfaces = ifs; + INDEX.put(name, ci); + } + @Override + public MethodVisitor visitMethod(int acc, String name, String desc, String sig, String[] ex) { + ci.methods.add(name + desc); + return null; + } + @Override + public FieldVisitor visitField(int acc, String name, String desc, String sig, Object val) { + ci.fields.add(name); + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + } + } + } + + // ---- resolution ---- + + static boolean isTracked(String owner) { + // java8/* (streamsupport), forge/compat/* and jvmdg stubs are bridge + // targets: verifying them catches redirects to nonexistent members + // (e.g. interface statics absent from streamsupport interfaces) + return owner.startsWith("java/") || owner.startsWith("javax/") + || owner.startsWith("java8/") || owner.startsWith("forge/compat/") + || owner.startsWith("xyz/wagyourtail/jvmdg/") + || owner.startsWith("io/sentry/"); + } + + static boolean classExists(String name) { + return INDEX.containsKey(name); + } + + static boolean methodExists(String owner, String nameDesc) { + if (owner.startsWith("[")) return true; // arrays: clone() etc. + Set seen = new HashSet<>(); + List work = new ArrayList<>(); + work.add(owner); + while (!work.isEmpty()) { + String c = work.remove(work.size() - 1); + if (c == null || !seen.add(c)) continue; + ClassInfo ci = INDEX.get(c); + if (ci == null) continue; + if (ci.methods.contains(nameDesc)) return true; + if (ci.superName != null) work.add(ci.superName); + if (ci.interfaces != null) for (String i : ci.interfaces) work.add(i); + } + return false; + } + + static boolean fieldExists(String owner, String name) { + Set seen = new HashSet<>(); + List work = new ArrayList<>(); + work.add(owner); + while (!work.isEmpty()) { + String c = work.remove(work.size() - 1); + if (c == null || !seen.add(c)) continue; + ClassInfo ci = INDEX.get(c); + if (ci == null) continue; + if (ci.fields.contains(name)) return true; + if (ci.superName != null) work.add(ci.superName); + if (ci.interfaces != null) for (String i : ci.interfaces) work.add(i); + } + return false; + } + + static void report(String key, String referencer) { + List l = MISSING.get(key); + if (l == null) { + l = new ArrayList<>(); + MISSING.put(key, l); + } + l.add(referencer); + } + + /** + * A META-INF/services registration naming a class that exists nowhere on + * the final classpath is a silently-broken service: ServiceLoader finds + * zero providers (wrong entry NAME, e.g. unremapped after a relocation) or + * throws ServiceConfigurationError (wrong impl line). First real instance: + * the relocated ThreeTen zone-rules provider — java.time worked until the + * first ZoneId.systemDefault(), which threw "No time-zone data files + * registered" because the services entry still used org.threeten.bp names. + */ + static void checkServices() { + for (Map.Entry> e : SERVICES.entrySet()) { + for (String dotted : e.getValue()) { + String internal = dotted.replace('.', '/'); + if (!INDEX.containsKey(internal) && !SCANNED_CLASSES.contains(internal)) { + report("SERVICE-UNRESOLVED " + dotted, e.getKey()); + } + } + } + } + + // ---- scanning app jars ---- + + static void scan(String jarPath) throws Exception { + try (ZipFile zf = new ZipFile(jarPath)) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + String entryName = e.getName(); + if (entryName.startsWith("META-INF/services/") + && entryName.length() > "META-INF/services/".length() && !e.isDirectory()) { + // collect ServiceLoader registrations; verified after the full + // scan (a registration naming a class that exists nowhere on the + // final classpath = silently-broken service, e.g. an unremapped + // provider name after a relocation) + List names = new ArrayList<>(); + names.add(entryName.substring("META-INF/services/".length())); + try (InputStream is = zf.getInputStream(e)) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int r; + while ((r = is.read(buf)) > 0) bos.write(buf, 0, r); + for (String line : new String(bos.toByteArray(), StandardCharsets.UTF_8).split("\n")) { + String t = line.trim(); + if (!t.isEmpty() && !t.startsWith("#")) names.add(t); + } + } + SERVICES.put(jarPath + "!" + entryName, names); + continue; + } + if (!entryName.endsWith(".class") || entryName.startsWith("META-INF/versions/")) continue; + try (InputStream is = zf.getInputStream(e)) { + ClassReader cr = new ClassReader(is); + final String self = cr.getClassName(); + SCANNED_CLASSES.add(self); + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int acc, String mname, String mdesc, String sig, String[] ex) { + final String where = self + "." + mname; + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int op, String owner, String name, String desc, boolean itf) { + if (classExists(owner)) { + // ANY indexed owner: unresolved member = will + // NoSuchMethodError if executed (e.g. Java 8 + // default inherited through an app class) + if (!methodExists(owner, name + desc)) { + report("METHOD " + owner + "." + name + desc, where); + } + } else if (isTracked(owner)) { + report("CLASS " + owner, where); + // member-level demand: sizes the supply needed + report("MISSING-MEMBER " + owner + "." + name + desc, where); + } + } + @Override + public void visitFieldInsn(int op, String owner, String name, String desc) { + if (isTracked(owner)) { + if (!classExists(owner)) { + report("CLASS " + owner, where); + } else if (!fieldExists(owner, name)) { + report("FIELD " + owner + "." + name, where); + } + } + } + @Override + public void visitTypeInsn(int op, String type) { + if (isTracked(type) && !type.startsWith("[") && !classExists(type)) { + report("CLASS " + type, where); + } + } + @Override + public void visitLdcInsn(Object v) { + if (v instanceof Type) { + Type t = (Type) v; + if (t.getSort() == Type.OBJECT && isTracked(t.getInternalName()) + && !classExists(t.getInternalName())) { + report("CLASS " + t.getInternalName(), where); + } + } + } + @Override + public void visitInvokeDynamicInsn(String iname, String idesc, Handle bsm, Object... bsmArgs) { + // RoboVM's lambda plugin ignores altMetafactory + // FLAG_SERIALIZABLE(1); MobiVmBridge compensates by + // adding an explicit java/io/Serializable marker under + // FLAG_MARKERS(2). Any site reaching the final jars + // with bit 1 set but no marker WILL throw CCE on the + // compiler-emitted `checkcast java/io/Serializable`. + if ("java/lang/invoke/LambdaMetafactory".equals(bsm.getOwner()) + && "altMetafactory".equals(bsm.getName()) + && bsmArgs.length >= 4 && bsmArgs[3] instanceof Integer) { + int flags = (Integer) bsmArgs[3]; + if ((flags & 1) != 0) { + boolean hasMarker = false; + if ((flags & 2) != 0 && bsmArgs.length >= 5 && bsmArgs[4] instanceof Integer) { + int mc = (Integer) bsmArgs[4]; + for (int m = 0; m < mc && 5 + m < bsmArgs.length; m++) { + if (bsmArgs[5 + m] instanceof Type + && "java/io/Serializable".equals(((Type) bsmArgs[5 + m]).getInternalName())) { + hasMarker = true; + } + } + } + if (!hasMarker) { + report("SERIALIZABLE-LAMBDA (FLAG_SERIALIZABLE without explicit " + + "Serializable marker; RoboVM drops it -> runtime CCE)", where); + } + } + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + } + } + } +} diff --git a/forge-gui-ios/pom.xml b/forge-gui-ios/pom.xml index 6b62913cf8aa..e309be96f7b9 100644 --- a/forge-gui-ios/pom.xml +++ b/forge-gui-ios/pom.xml @@ -28,12 +28,31 @@ 17 + + com.mobidevelop.robovm + robovm-maven-plugin + 2.3.24 + + robovm.properties + robovm.xml + Info.plist.xml + true + + + filters true + + . + + fallback_skin/** + + forge-ios-${revision} @@ -42,32 +61,178 @@ forge forge-core - ${project.version} + ${revision} + + + io.sentry + * + + forge forge-game - ${project.version} + ${revision} + + + io.sentry + * + + forge forge-ai - ${project.version} + ${revision} + + + io.sentry + * + + forge forge-gui - ${project.version} + ${revision} + + + org.jupnp + * + + + org.eclipse.jetty + * + + + io.sentry + * + + forge forge-gui-mobile - ${project.version} + ${revision} + + + org.jupnp + * + + + org.eclipse.jetty + * + + + io.sentry + * + + com.badlogicgames.gdx gdx-backend-robovm 1.13.5 + + + com.badlogicgames.gdx-controllers + gdx-controllers-ios + 2.2.4 + + + com.badlogicgames.gdx + gdx-platform + 1.13.5 + natives-ios + + + + com.badlogicgames.gdx + gdx-box2d-platform + 1.13.5 + natives-ios + + + com.badlogicgames.gdx + gdx-freetype + 1.13.5 + + + com.badlogicgames.gdx + gdx-freetype-platform + 1.13.5 + natives-ios + + + + + forge.stubs + java-function-stubs + 1.0 + + + + forge.stubs + sentry-stub + 1.0 + + + + + net.sourceforge.streamsupport + streamsupport + 1.7.4 + + + net.sourceforge.streamsupport + streamsupport-cfuture + 1.7.4 + + + + xyz.wagyourtail.jvmdowngrader + jvmdowngrader-java-api + 1.3.6-mobivm + + + + forge.stubs + java-time-supply + 1.0 + + + + forge.stubs + java-nio-supply + 1.0 + + + + org.jupnp + org.jupnp + 3.0.3 + provided + diff --git a/forge-gui-ios/robovm.properties b/forge-gui-ios/robovm.properties deleted file mode 100644 index e8c3068950c6..000000000000 --- a/forge-gui-ios/robovm.properties +++ /dev/null @@ -1,8 +0,0 @@ -# -#Wed Dec 03 21:31:22 CET 2014 -app.version=1.0 -app.id=forge.ios -app.mainclass=forge.ios.Main -app.executable=forge.ios.Main -app.build=1 -app.name=Forge diff --git a/forge-gui-ios/robovm.properties.template b/forge-gui-ios/robovm.properties.template new file mode 100644 index 000000000000..1f4d96a644e1 --- /dev/null +++ b/forge-gui-ios/robovm.properties.template @@ -0,0 +1,19 @@ +# +# Template for robovm.properties (which is untracked / gitignored). +# +# The bundle identifier is developer-specific: register your own App ID and +# set APP_ID in the untracked .env at the repo root (or export it), then run +# any pipeline/ios-pipeline.sh command — it generates robovm.properties from +# this template on first run. Or copy this file to robovm.properties yourself +# and replace @APP_ID@ with your bundle identifier. +# +app.version=1.0 +app.id=@APP_ID@ +app.mainclass=forge.ios.Main +app.executable=forge.ios.Main +app.build=1 +app.name=Forge + +# Set timezone before JVM initialization to avoid iOS sandbox violations. +# Placeholder only: Main.java replaces it with the device timezone at startup. +robovm.jvmArgs=-Duser.timezone=UTC diff --git a/forge-gui-ios/robovm.xml b/forge-gui-ios/robovm.xml index 859425926283..0aac40f84286 100644 --- a/forge-gui-ios/robovm.xml +++ b/forge-gui-ios/robovm.xml @@ -2,16 +2,42 @@ ${app.executable} ${app.mainclass} ios - thumbv7 + arm64 + ../forge-gui res/** + + + res/cardsfolder/*/*.txt + + true res + true + + + + . + + Icon*.png + + + + fallback_skin + fallback_skin + true ios @@ -30,15 +56,66 @@ org.apache.harmony.security.provider.crypto.CryptoProvider org.apache.xalan.processor.TransformerFactoryImpl java.util.logging.** - com.esotericsoftware.minlog.Log com.badlogic.gdx.** + + com.ray3k.tenpatch.** + + com.thoughtworks.xstream.** + java.io.ObjectInputStream + java.io.ObjectOutputStream + java.io.ObjectStreamClass + java.io.ObjectStreamField + + + io.sentry.** + + net.jpountz.lz4.** + net.jpountz.xxhash.** + + io.netty.** + + forge.gamemodes.net.** + forge.gamemodes.match.LobbySlot + forge.gamemodes.match.LobbySlotType + + forge.ios.ForgeOSLog + forge.ios.OSLogPrintStream + + org.robovm.rt.GC + + java8.** + xyz.wagyourtail.jvmdg.** + java.time.** + java.nio.file.** + java.io.UncheckedIOException + forge.compat.** + + forge.assets.Assets$* + forge.assets.ImageCache$* + + libs + - libs/libgdx.a - libs/libObjectAL.a - libs/libgdx-freetype.a + libs/libForgeOSLog.a + + forge_os_log_public + forge_os_log_info + forge_os_log_debug + forge_os_log_error + + gdx + ObjectAL UIKit OpenGLES QuartzCore @@ -46,5 +123,7 @@ OpenAL AudioToolbox AVFoundation + + GameController \ No newline at end of file diff --git a/forge-gui-ios/sentry-stub/.gitignore b/forge-gui-ios/sentry-stub/.gitignore new file mode 100644 index 000000000000..05b98f2c8629 --- /dev/null +++ b/forge-gui-ios/sentry-stub/.gitignore @@ -0,0 +1,2 @@ +classes/ +*.jar diff --git a/forge-gui-ios/sentry-stub/build.sh b/forge-gui-ios/sentry-stub/build.sh new file mode 100755 index 000000000000..0abb427ebd39 --- /dev/null +++ b/forge-gui-ios/sentry-stub/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Builds and installs forge.stubs:sentry-stub:1.0 — no-op io.sentry.* classes for the +# RoboVM/iOS build. The real io.sentry SDK is excluded in forge-gui-ios/pom.xml (it doesn't +# link on RoboVM), but shared Forge code calls Sentry telemetry on hot paths (e.g. +# PlayerControllerHuman.chooseEntitiesForEffect when resolving a library search). Without these +# stubs those calls throw NoClassDefFoundError: io/sentry/Sentry at runtime and silently kill the +# game thread (Cultivate / Kodama's Reach / Genesis Hydra "freeze"). +# +# Signatures MUST match the real Sentry SDK (the shared .class files are precompiled against it). +# Verify against the real jar: javap -cp ~/.m2/repository/io/sentry/sentry/8.21.1/sentry-8.21.1.jar io.sentry.Sentry +# +# Run from anywhere; re-run after editing src/ or if ~/.m2 is cleared. +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$DIR/../.." && pwd)" +rm -rf "$DIR/classes" "$DIR/sentry-stub.jar" +mkdir -p "$DIR/classes" +javac --release 8 -d "$DIR/classes" $(find "$DIR/src" -name "*.java") +(cd "$DIR/classes" && jar cf "$DIR/sentry-stub.jar" .) +# run mvn from the repo root: the tracked .mvn/maven.config uses a cwd-relative --settings path +(cd "$ROOT" && mvn install:install-file -q \ + -Dfile="$DIR/sentry-stub.jar" \ + -DgroupId=forge.stubs -DartifactId=sentry-stub -Dversion=1.0 -Dpackaging=jar) +echo "Installed forge.stubs:sentry-stub:1.0" diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/Breadcrumb.java b/forge-gui-ios/sentry-stub/src/io/sentry/Breadcrumb.java new file mode 100644 index 000000000000..4f214f25c8f4 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/Breadcrumb.java @@ -0,0 +1,14 @@ +package io.sentry; + +/** No-op stub of io.sentry.Breadcrumb (see Sentry.java for rationale). */ +public final class Breadcrumb { + public Breadcrumb() { } + + public Breadcrumb(String message) { } + + public void setMessage(String message) { } + + public void setCategory(String category) { } + + public void setData(String key, Object value) { } +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/Contexts.java b/forge-gui-ios/sentry-stub/src/io/sentry/Contexts.java new file mode 100644 index 000000000000..ef7b9cd733cf --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/Contexts.java @@ -0,0 +1,8 @@ +package io.sentry; +import io.sentry.protocol.Device; +import io.sentry.protocol.OperatingSystem; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public class Contexts { + public void setDevice(Device device) { } + public void setOperatingSystem(OperatingSystem operatingSystem) { } +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/Hint.java b/forge-gui-ios/sentry-stub/src/io/sentry/Hint.java new file mode 100644 index 000000000000..8905c410d113 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/Hint.java @@ -0,0 +1,6 @@ +package io.sentry; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public final class Hint { + public Hint() { } + public void set(String name, Object value) { } +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/IScope.java b/forge-gui-ios/sentry-stub/src/io/sentry/IScope.java new file mode 100644 index 000000000000..8e2d9b3db3fc --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/IScope.java @@ -0,0 +1,5 @@ +package io.sentry; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public interface IScope { + Contexts getContexts(); +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/ScopeCallback.java b/forge-gui-ios/sentry-stub/src/io/sentry/ScopeCallback.java new file mode 100644 index 000000000000..409e0b5d6bf0 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/ScopeCallback.java @@ -0,0 +1,5 @@ +package io.sentry; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public interface ScopeCallback { + void run(IScope scope); +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/ScopeType.java b/forge-gui-ios/sentry-stub/src/io/sentry/ScopeType.java new file mode 100644 index 000000000000..32e443d894d3 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/ScopeType.java @@ -0,0 +1,5 @@ +package io.sentry; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public enum ScopeType { + CURRENT, ISOLATION, GLOBAL, COMBINED +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/Sentry.java b/forge-gui-ios/sentry-stub/src/io/sentry/Sentry.java new file mode 100644 index 000000000000..cd00d4923176 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/Sentry.java @@ -0,0 +1,28 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; + +/** + * No-op stub of the Sentry facade for the RoboVM/iOS build. + * + * The real io.sentry:sentry SDK is excluded from the iOS module (it pulls in + * java.util.function, an HTTP transport and background threads that do not link + * on RoboVM), so every telemetry call in shared Forge code (BugReporter, + * PlayerControllerHuman, Forge, SaveFileData, VCardDisplayArea) would otherwise + * throw NoClassDefFoundError: io/sentry/Sentry at runtime and kill the game + * thread. These no-ops carry the exact signatures of the methods actually + * referenced so the precompiled bytecode links. Sentry is never initialised on + * iOS; desktop builds use the real SDK and are unaffected. + */ +public final class Sentry { + private Sentry() { } + + public static SentryId captureMessage(String message) { return new SentryId(); } + public static SentryId captureException(Throwable throwable) { return new SentryId(); } + public static SentryId captureException(Throwable throwable, Hint hint) { return new SentryId(); } + public static void addBreadcrumb(String message) { } + public static void addBreadcrumb(Breadcrumb breadcrumb) { } + public static void setExtra(String key, String value) { } + public static void removeExtra(String key) { } + public static void configureScope(ScopeType scopeType, ScopeCallback callback) { } +} diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/protocol/Device.java b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/Device.java new file mode 100644 index 000000000000..d6787855fe33 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/Device.java @@ -0,0 +1,3 @@ +package io.sentry.protocol; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public final class Device { } diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/protocol/OperatingSystem.java b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/OperatingSystem.java new file mode 100644 index 000000000000..bbd31d5e0b93 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/OperatingSystem.java @@ -0,0 +1,3 @@ +package io.sentry.protocol; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public final class OperatingSystem { } diff --git a/forge-gui-ios/sentry-stub/src/io/sentry/protocol/SentryId.java b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/SentryId.java new file mode 100644 index 000000000000..76816f9fc067 --- /dev/null +++ b/forge-gui-ios/sentry-stub/src/io/sentry/protocol/SentryId.java @@ -0,0 +1,3 @@ +package io.sentry.protocol; +/** No-op stub for the RoboVM/iOS build (real io.sentry SDK is excluded). */ +public final class SentryId { } diff --git a/forge-gui-ios/src/forge/compat/JBoxed8.java b/forge-gui-ios/src/forge/compat/JBoxed8.java new file mode 100644 index 000000000000..0c42e96ecd4c --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JBoxed8.java @@ -0,0 +1,72 @@ +package forge.compat; + +/** + * Bytecode-rewrite targets for boxed-primitive Java 8 statics missing from + * MobiVM's Java-7-era runtime (Integer.sum, Long.hashCode(long), ...). + * Bodies self-implemented — must not call the APIs they replace. + */ +public final class JBoxed8 { + private JBoxed8() { } + + public static int sum(int a, int b) { return a + b; } + public static int max(int a, int b) { return Math.max(a, b); } + public static int min(int a, int b) { return Math.min(a, b); } + public static int hashCode(int v) { return v; } + + public static long sum(long a, long b) { return a + b; } + public static long max(long a, long b) { return Math.max(a, b); } + public static long min(long a, long b) { return Math.min(a, b); } + public static int hashCode(long v) { return (int) (v ^ (v >>> 32)); } + + public static double sum(double a, double b) { return a + b; } + public static double max(double a, double b) { return Math.max(a, b); } + public static double min(double a, double b) { return Math.min(a, b); } + public static int hashCode(double v) { + long bits = Double.doubleToLongBits(v); + return (int) (bits ^ (bits >>> 32)); + } + + public static int hashCode(float v) { return Float.floatToIntBits(v); } + + public static int hashCode(boolean v) { return v ? 1231 : 1237; } + public static boolean logicalAnd(boolean a, boolean b) { return a && b; } + public static boolean logicalOr(boolean a, boolean b) { return a || b; } + + public static float sum(float a, float b) { return a + b; } + public static float max(float a, float b) { return Math.max(a, b); } + public static float min(float a, float b) { return Math.min(a, b); } + public static boolean isFinite(float v) { return Math.abs(v) <= Float.MAX_VALUE; } + public static boolean isFinite(double v) { return Math.abs(v) <= Double.MAX_VALUE; } + + public static int compareUnsigned(int a, int b) { + return compare(a + Integer.MIN_VALUE, b + Integer.MIN_VALUE); + } + + public static int compareUnsigned(long a, long b) { + long x = a + Long.MIN_VALUE; + long y = b + Long.MIN_VALUE; + return x < y ? -1 : (x == y ? 0 : 1); + } + + private static int compare(int x, int y) { return x < y ? -1 : (x == y ? 0 : 1); } + + public static long toUnsignedLong(int v) { return v & 0xffffffffL; } + + public static String toUnsignedString(int v) { return Long.toString(v & 0xffffffffL); } + + public static String toUnsignedString(int v, int radix) { return Long.toString(v & 0xffffffffL, radix); } + + public static String toUnsignedString(long v) { + if (v >= 0) { + return Long.toString(v); + } + // divide-and-conquer for negative (high-bit-set) values + long q = (v >>> 1) / 5; + long r = v - q * 10; + return Long.toString(q) + r; + } + + public static int toUnsignedInt(byte v) { return v & 0xff; } + + public static int toUnsignedInt(short v) { return v & 0xffff; } +} diff --git a/forge-gui-ios/src/forge/compat/JFunction8.java b/forge-gui-ios/src/forge/compat/JFunction8.java new file mode 100644 index 000000000000..5f57359909c5 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JFunction8.java @@ -0,0 +1,19 @@ +package forge.compat; + +/** + * Bytecode-rewrite targets for java.util.function interface statics that + * streamsupport's companion classes don't provide (Predicates has + * and/or/negate but no isEqual). + */ +public final class JFunction8 { + private JFunction8() { } + + public static java8.util.function.Predicate isEqual(final Object target) { + return new java8.util.function.Predicate() { + @Override + public boolean test(T t) { + return target == null ? t == null : target.equals(t); + } + }; + } +} diff --git a/forge-gui-ios/src/forge/compat/JIo8.java b/forge-gui-ios/src/forge/compat/JIo8.java new file mode 100644 index 000000000000..5f59eb98c052 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JIo8.java @@ -0,0 +1,30 @@ +package forge.compat; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Bytecode-rewrite target for BufferedReader.lines() (Java 8), missing from + * MobiVM's Java-7-era runtime. Returns a streamsupport stream — call-site + * descriptors match after the bridge pass remaps java.util.stream. + */ +public final class JIo8 { + private JIo8() { } + + public static java8.util.stream.Stream lines(BufferedReader reader) { + // Faithful enough for Forge's uses (parse whole files); reads eagerly. + List lines = new ArrayList<>(); + try { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return java8.util.stream.StreamSupport.stream(lines); + } +} diff --git a/forge-gui-ios/src/forge/compat/JMath8.java b/forge-gui-ios/src/forge/compat/JMath8.java new file mode 100644 index 000000000000..c51c55f0c9a5 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JMath8.java @@ -0,0 +1,93 @@ +package forge.compat; + +/** + * Bytecode-rewrite targets for java.lang.Math Java 8 statics missing from + * MobiVM's Java-7-era runtime. Bodies self-implemented (JDK semantics) — + * must not call the APIs they replace. + */ +public final class JMath8 { + private JMath8() { } + + public static int floorDiv(int x, int y) { + int r = x / y; + if ((x ^ y) < 0 && (r * y != x)) { + r--; + } + return r; + } + + public static long floorDiv(long x, long y) { + long r = x / y; + if ((x ^ y) < 0 && (r * y != x)) { + r--; + } + return r; + } + + public static int floorMod(int x, int y) { + return x - floorDiv(x, y) * y; + } + + public static long floorMod(long x, long y) { + return x - floorDiv(x, y) * y; + } + + public static int toIntExact(long value) { + if ((int) value != value) { + throw new ArithmeticException("integer overflow"); + } + return (int) value; + } + + public static int addExact(int x, int y) { + int r = x + y; + if (((x ^ r) & (y ^ r)) < 0) { + throw new ArithmeticException("integer overflow"); + } + return r; + } + + public static long addExact(long x, long y) { + long r = x + y; + if (((x ^ r) & (y ^ r)) < 0) { + throw new ArithmeticException("long overflow"); + } + return r; + } + + public static int subtractExact(int x, int y) { + int r = x - y; + if (((x ^ y) & (x ^ r)) < 0) { + throw new ArithmeticException("integer overflow"); + } + return r; + } + + public static long subtractExact(long x, long y) { + long r = x - y; + if (((x ^ y) & (x ^ r)) < 0) { + throw new ArithmeticException("long overflow"); + } + return r; + } + + public static int multiplyExact(int x, int y) { + long r = (long) x * (long) y; + if ((int) r != r) { + throw new ArithmeticException("integer overflow"); + } + return (int) r; + } + + public static long multiplyExact(long x, long y) { + long r = x * y; + long ax = Math.abs(x); + long ay = Math.abs(y); + if (((ax | ay) >>> 31 != 0)) { + if (((y != 0) && (r / y != x)) || (x == Long.MIN_VALUE && y == -1)) { + throw new ArithmeticException("long overflow"); + } + } + return r; + } +} diff --git a/forge-gui-ios/src/forge/compat/JMisc8.java b/forge-gui-ios/src/forge/compat/JMisc8.java new file mode 100644 index 000000000000..9703456d1a33 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JMisc8.java @@ -0,0 +1,110 @@ +package forge.compat; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.reflect.Type; +import java.nio.channels.FileChannel; +import java.nio.file.FileVisitOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Bytecode-rewrite targets for assorted members missing from MobiVM's + * runtime that don't fit the other compat holders: + * - StringBuilder.append(byte): JvmDowngrader's generated record toString + * emits this signature, which exists in NO Java version — widen to int. + * - Type.getTypeName / Class.getTypeName (Java 8). + * - Files.walk (returns a stream, so it can't live in the java.base-patched + * supply, which cannot reference java8.util types). + * - FileChannel.open(Path, OpenOption...) (Java 8 static). + */ +public final class JMisc8 { + private JMisc8() { } + + public static StringBuilder append(StringBuilder sb, byte b) { + return sb.append((int) b); + } + + public static java.util.Date from(java.time.Instant instant) { + return new java.util.Date(instant.toEpochMilli()); + } + + public static String getBaseBundleName(java.util.ResourceBundle bundle) { + // Java 8 API used by Forge only in a success log line; a best-effort + // name keeps the message useful without runtime support. + try { + return String.valueOf(java.util.ResourceBundle.class + .getMethod("getBaseBundleName").invoke(bundle)); + } catch (Throwable t) { + return bundle.getLocale() != null ? bundle.getLocale().toString() : "unknown"; + } + } + + public static String getTypeName(Type type) { + if (type instanceof Class) { + Class c = (Class) type; + if (c.isArray()) { + StringBuilder sb = new StringBuilder(); + Class el = c; + while (el.isArray()) { + sb.append("[]"); + el = el.getComponentType(); + } + return el.getName() + sb; + } + return c.getName(); + } + return type.toString(); + } + + public static java8.util.stream.Stream walk(Path start, FileVisitOption... options) { + List acc = new ArrayList<>(); + collect(start.toFile(), acc); + return java8.util.stream.StreamSupport.stream(acc); + } + + private static void collect(File f, List acc) { + acc.add(java.nio.file.Paths.get(f.getPath())); + if (f.isDirectory()) { + File[] children = f.listFiles(); + if (children != null) { + java.util.Arrays.sort(children); + for (File c : children) { + collect(c, acc); + } + } + } + } + + public static Path toPath(File file) { + return java.nio.file.Paths.get(file.getPath()); + } + + public static FileChannel open(Path path, java.nio.file.OpenOption... options) throws IOException { + boolean write = false; + boolean append = false; + boolean truncate = false; + for (java.nio.file.OpenOption o : options) { + if (o == java.nio.file.StandardOpenOption.WRITE || o == java.nio.file.StandardOpenOption.CREATE + || o == java.nio.file.StandardOpenOption.CREATE_NEW) { + write = true; + } else if (o == java.nio.file.StandardOpenOption.APPEND) { + write = true; + append = true; + } else if (o == java.nio.file.StandardOpenOption.TRUNCATE_EXISTING) { + truncate = true; + } + } + RandomAccessFile raf = new RandomAccessFile(path.toFile(), write ? "rw" : "r"); + if (truncate) { + raf.setLength(0); + } + FileChannel ch = raf.getChannel(); + if (append) { + ch.position(ch.size()); + } + return ch; + } +} diff --git a/forge-gui-ios/src/forge/compat/JRegex8.java b/forge-gui-ios/src/forge/compat/JRegex8.java new file mode 100644 index 000000000000..6a3372e332a2 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JRegex8.java @@ -0,0 +1,81 @@ +package forge.compat; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; + +/** + * Bytecode-rewrite targets for java.util.regex.Matcher's named-group API, + * which MobiVM's Java-7-era runtime lacks (Matcher has only group()/group(int)). + * The group NAME -> INDEX mapping is derived by scanning the pattern source + * for named capturing groups — the standard trick for pre-API-26 Android. + */ +public final class JRegex8 { + private JRegex8() { } + + private static final Map> CACHE = new HashMap<>(); + + public static String group(Matcher m, String name) { + return m.group(indexOf(m, name)); + } + + public static int start(Matcher m, String name) { + return m.start(indexOf(m, name)); + } + + public static int end(Matcher m, String name) { + return m.end(indexOf(m, name)); + } + + private static int indexOf(Matcher m, String name) { + String regex = m.pattern().pattern(); + Map byName; + synchronized (CACHE) { + byName = CACHE.get(regex); + if (byName == null) { + byName = parseGroupNames(regex); + CACHE.put(regex, byName); + } + } + Integer idx = byName.get(name); + if (idx == null) { + throw new IllegalArgumentException("No group with name <" + name + ">"); + } + return idx; + } + + private static Map parseGroupNames(String regex) { + Map result = new HashMap<>(); + int groupIndex = 0; + boolean inClass = false; + for (int i = 0; i < regex.length(); i++) { + char c = regex.charAt(i); + if (c == '\\') { + i++; // skip escaped char + } else if (inClass) { + if (c == ']') { + inClass = false; + } + } else if (c == '[') { + inClass = true; + } else if (c == '(') { + if (i + 1 < regex.length() && regex.charAt(i + 1) == '?') { + // named group: (?...) — but not lookbehind (?<= (?', i + 3); + if (close > 0) { + groupIndex++; + result.put(regex.substring(i + 3, close), groupIndex); + } + } + // other (?...) constructs are non-capturing + } else { + groupIndex++; + } + } + } + return result; + } +} diff --git a/forge-gui-ios/src/forge/compat/JString8.java b/forge-gui-ios/src/forge/compat/JString8.java new file mode 100644 index 000000000000..f867c07df2b9 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JString8.java @@ -0,0 +1,60 @@ +package forge.compat; + +import java.util.Iterator; + +/** + * Bytecode-rewrite targets for java.lang.String / CharSequence Java 8 + * members missing from MobiVM's Java-7-era runtime. Referenced only by the + * iOS bridge pass (tmp/jvmdg) — never from source. Bodies are + * self-implemented: they must NOT call the APIs they replace (the bridge + * pass also processes this class and would self-loop). + */ +public final class JString8 { + private JString8() { } + + public static String join(CharSequence delimiter, CharSequence... elements) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < elements.length; i++) { + if (i > 0) { + sb.append(delimiter); + } + sb.append(elements[i]); + } + return sb.toString(); + } + + public static String join(CharSequence delimiter, Iterable elements) { + StringBuilder sb = new StringBuilder(); + Iterator it = elements.iterator(); + boolean firstDone = false; + while (it.hasNext()) { + if (firstDone) { + sb.append(delimiter); + } + sb.append(it.next()); + firstDone = true; + } + return sb.toString(); + } + + public static java8.util.stream.IntStream chars(final CharSequence cs) { + return java8.util.stream.IntStreams.range(0, cs.length()) + .map(new java8.util.function.IntUnaryOperator() { + @Override public int applyAsInt(int i) { return cs.charAt(i); } + }); + } + + public static java8.util.stream.IntStream codePoints(final CharSequence cs) { + // Simple correct implementation: collect code points then stream them. + int[] buf = new int[cs.length()]; + int n = 0; + for (int i = 0; i < cs.length(); ) { + int cp = Character.codePointAt(cs, i); + buf[n++] = cp; + i += Character.charCount(cp); + } + int[] exact = new int[n]; + System.arraycopy(buf, 0, exact, 0, n); + return java8.util.J8Arrays.stream(exact); + } +} diff --git a/forge-gui-ios/src/forge/compat/JThreadLocal8.java b/forge-gui-ios/src/forge/compat/JThreadLocal8.java new file mode 100644 index 000000000000..1b6cbe2ca797 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/JThreadLocal8.java @@ -0,0 +1,21 @@ +package forge.compat; + +/** + * Bytecode-rewrite target for ThreadLocal.withInitial (Java 8), missing from + * MobiVM's Java-7-era runtime. The Supplier parameter type is written as + * java8.util.function.Supplier directly because the bridge pass remaps all + * java.util.function references to java8.util.function — call-site + * descriptors will match after remapping. + */ +public final class JThreadLocal8 { + private JThreadLocal8() { } + + public static ThreadLocal withInitial(final java8.util.function.Supplier supplier) { + return new ThreadLocal() { + @Override + protected S initialValue() { + return supplier.get(); + } + }; + } +} diff --git a/forge-gui-ios/src/forge/compat/mgmt/ManagementFactory.java b/forge-gui-ios/src/forge/compat/mgmt/ManagementFactory.java new file mode 100644 index 000000000000..df1681615fef --- /dev/null +++ b/forge-gui-ios/src/forge/compat/mgmt/ManagementFactory.java @@ -0,0 +1,60 @@ +package forge.compat.mgmt; + +import java.util.Collections; +import java.util.List; + +/** + * Whole-type remap target for java.lang.management.ManagementFactory (absent + * on MobiVM). Backed by java.lang.Runtime; satisfies the boot-time probes of + * apfloat's ApfloatContext, tinylog's runtime provider, and RestartUtil. + */ +public final class ManagementFactory { + private static final long START = System.currentTimeMillis(); + + private static final RuntimeMXBean RUNTIME = new RuntimeMXBean() { + @Override + public String getName() { + return "1@forge-ios"; + } + + @Override + public long getStartTime() { + return START; + } + + @Override + public long getUptime() { + return System.currentTimeMillis() - START; + } + + @Override + public List getInputArguments() { + return Collections.emptyList(); + } + }; + + private static final MemoryMXBean MEMORY = new MemoryMXBean() { + @Override + public MemoryUsage getHeapMemoryUsage() { + Runtime rt = Runtime.getRuntime(); + long used = rt.totalMemory() - rt.freeMemory(); + long max = rt.maxMemory(); + return new MemoryUsage(rt.totalMemory(), used, rt.totalMemory(), max); + } + + @Override + public MemoryUsage getNonHeapMemoryUsage() { + return new MemoryUsage(0, 0, 0, -1); + } + }; + + private ManagementFactory() { } + + public static RuntimeMXBean getRuntimeMXBean() { + return RUNTIME; + } + + public static MemoryMXBean getMemoryMXBean() { + return MEMORY; + } +} diff --git a/forge-gui-ios/src/forge/compat/mgmt/MemoryMXBean.java b/forge-gui-ios/src/forge/compat/mgmt/MemoryMXBean.java new file mode 100644 index 000000000000..fc2aea76a5e3 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/mgmt/MemoryMXBean.java @@ -0,0 +1,8 @@ +package forge.compat.mgmt; + +/** Whole-type remap target for java.lang.management.MemoryMXBean. */ +public interface MemoryMXBean { + MemoryUsage getHeapMemoryUsage(); + + MemoryUsage getNonHeapMemoryUsage(); +} diff --git a/forge-gui-ios/src/forge/compat/mgmt/MemoryUsage.java b/forge-gui-ios/src/forge/compat/mgmt/MemoryUsage.java new file mode 100644 index 000000000000..1540eaedca64 --- /dev/null +++ b/forge-gui-ios/src/forge/compat/mgmt/MemoryUsage.java @@ -0,0 +1,37 @@ +package forge.compat.mgmt; + +/** Whole-type remap target for java.lang.management.MemoryUsage. */ +public class MemoryUsage { + private final long init; + private final long used; + private final long committed; + private final long max; + + public MemoryUsage(long init, long used, long committed, long max) { + this.init = init; + this.used = used; + this.committed = committed; + this.max = max; + } + + public long getInit() { + return init; + } + + public long getUsed() { + return used; + } + + public long getCommitted() { + return committed; + } + + public long getMax() { + return max; + } + + @Override + public String toString() { + return "init = " + init + " used = " + used + " committed = " + committed + " max = " + max; + } +} diff --git a/forge-gui-ios/src/forge/compat/mgmt/RuntimeMXBean.java b/forge-gui-ios/src/forge/compat/mgmt/RuntimeMXBean.java new file mode 100644 index 000000000000..fcc960caa80b --- /dev/null +++ b/forge-gui-ios/src/forge/compat/mgmt/RuntimeMXBean.java @@ -0,0 +1,17 @@ +package forge.compat.mgmt; + +import java.util.List; + +/** + * Whole-type remap target for java.lang.management.RuntimeMXBean (absent on + * MobiVM). Interface because JDK callers use invokeinterface. + */ +public interface RuntimeMXBean { + String getName(); + + long getStartTime(); + + long getUptime(); + + List 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); }