Rohan Verma



Commit Diff


commit - d0df860db553d49ad9577921e485218cc97f2c8f
commit + ea7b69c40058e45f428ebffe17534c7caca55fb3
blob - 70c6d5dbf3fc487305f4751c46fe6fc31b3bb542
blob + a4e3057ff19c17f5bd13c8875e0cfd7edc7870d1
--- README.md
+++ README.md
@@ -1,7 +1,9 @@
-# AeronauticsCompat 1.0.0
+# AeronauticsCompat 1.0.4
 
 A compatibility-patch mod for [Create: Aeronautics](https://github.com/Creators-of-Aeronautics/Simulated-Project) / [Sable](https://github.com/ryanhcode/sable) sub-levels (NeoForge 1.21.1).
 
+Tested against Sable 1.0.6 and 1.1.1, Create: Aeronautics 1.0.3 and 1.1.3. All patches target stable behavior that has not changed between these versions.
+
 All patches are **soft dependencies** — each one applies only when its target mod is installed. Uninstalling any of the target mods is safe; AeronauticsCompat stays loaded and continues to patch whatever else is present.
 
 ## Patches
@@ -24,6 +26,15 @@ All patches are **soft dependencies** — each one app
 
 *Activates when: WaterFrames + Sable are both installed.*
 
+### Citadel × Sable: mob pathfinding no longer crashes the server
+**Problem.** Server OOMs (`java.lang.OutOfMemoryError: Java heap space`) whenever a Citadel-powered mob ticks near a Sable contraption. Affects everything that depends on Citadel — Alex's Mobs, Alex's Caves, L\_Ender's Cataclysm, etc. Vanilla mobs and other mod mobs are unaffected.
+
+**Cause.** Sable's `SubLevelInclusiveLevelEntityGetter` wraps `Level#getEntities`, causing entity queries to also enumerate entities inside sub-levels. Those entities carry raw coordinates in sub-level space — tens of millions of blocks from the querying mob's position. When a Citadel mob picks such an entity as a target (or draws a stroll pos near one), its goal calls `AdvancedPathNavigate#moveToXYZ` with those coordinates. Citadel's `AbstractPathJob` sizes a `ChunkCache`'s `LevelChunk[][]` by the start-to-end bounding box; with end coords in the millions, the array is ~30M×30M and JVM heap is exhausted instantly.
+
+**Fix.** Mixin `AdvancedPathNavigate.moveToXYZ` at HEAD. If the target is more than 1024 blocks from the mob's actual position, return `null` — this looks to the goal exactly like "no path found, try again next tick." No path job is constructed, no `ChunkCache` allocated, no OOM. Legitimate pathing (FOLLOW\_RANGE is usually 16–48, at most ~128) is completely unaffected.
+
+*Activates when: Citadel + Sable are both installed.*
+
 ## Optional: YouTube source for Etched
 
 Also includes the legacy Etchtube YouTube-via-yt-dlp source, gated on Etched being installed. Configure the proxy URL and bearer token under `YoutubeProxy.URL` / `YoutubeProxy.Token` in `config/aeronauticscompat-client.toml`. Leave empty to disable. Not required for the patches above.
@@ -31,7 +42,7 @@ Also includes the legacy Etchtube YouTube-via-yt-dlp s
 ## Dependencies
 
 - **Required**: NeoForge 1.21.1
-- **Optional** (each enables one or more patches): Sable, Etched, WaterFrames
+- **Optional** (each enables one or more patches): Sable, Etched, WaterFrames, Citadel
 
 No target mod is a hard dependency. The mod loads and logs which patches are active on startup.
 
blob - 7b78ca1799a817aa89236cd6351aa7925aeec0a0
blob + 1717c77b0f7afac91b30ba8e469ae5b9416c0a7b
--- build.gradle
+++ build.gradle
@@ -86,6 +86,7 @@ dependencies {
 jar {
     exclude 'dev/ryanhcode/**'
     exclude 'me/srrapero720/**'
+    exclude 'com/github/alexthe666/**'
 }
 
 var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
@@ -98,6 +99,7 @@ var generateModMetadata = tasks.register("generateModM
             etched_version_range   : etched_version_range,
             sable_version_range    : sable_version_range,
             waterframes_version_range: waterframes_version_range,
+            citadel_version_range  : citadel_version_range,
             mod_id                 : mod_id,
             mod_name               : mod_name,
             mod_license            : mod_license,
blob - a58fe6a8db4b34d2862657f257dc460b65a21bcc
blob + 953a6cb36f0415b1e86e19c58db146769eb13cc0
--- gradle.properties
+++ gradle.properties
@@ -19,12 +19,13 @@ loader_version_range=[1,)
 etched_version_range=[5.0.0,6.0.0)
 sable_version_range=[1.0.0,)
 waterframes_version_range=[2.1.0,3.0.0)
+citadel_version_range=[2.0.0,3.0.0)
 
 # Mod Properties
 mod_id=aeronauticscompat
 mod_name=AeronauticsCompat
 mod_license=GPLv3
-mod_version=1.0.0
+mod_version=1.0.4
 mod_authors=rohan
 mod_description=Compatibility fixes for Create: Aeronautics / Sable sub-levels. Per-mod patches apply only when the target mod is installed.
 mod_group_id=sh.rsap.aeronauticscompat
blob - /dev/null
blob + 8f5ecc2c4322359fbb7f1749fbc698b91d3e7bba (mode 644)
--- /dev/null
+++ src/main/java/com/github/alexthe666/citadel/server/entity/pathfinding/raycoms/AbstractAdvancedPathNavigate.java
@@ -0,0 +1,22 @@
+package com.github.alexthe666.citadel.server.entity.pathfinding.raycoms;
+
+import net.minecraft.world.entity.Mob;
+
+/**
+ * COMPILE-TIME STUB of
+ * {@code com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.AbstractAdvancedPathNavigate}.
+ *
+ * <p>The real class at runtime is provided by Citadel and declares
+ * {@code protected final Mob ourEntity} and {@code public Mob getOurEntity()}.
+ * We only need {@code getOurEntity()} for the Citadel mixin to compile;
+ * the field itself is not referenced from our code.
+ *
+ * <p>Excluded from the output jar via {@code jar { exclude 'com/github/**' }}
+ * in build.gradle so it can never shadow the real class at runtime.
+ */
+public abstract class AbstractAdvancedPathNavigate {
+
+    public Mob getOurEntity() {
+        return null;
+    }
+}
blob - /dev/null
blob + 42dcdfb8fc946f8a4bea02b6ba9115b95a22103c (mode 644)
--- /dev/null
+++ src/main/java/com/github/alexthe666/citadel/server/entity/pathfinding/raycoms/AdvancedPathNavigate.java
@@ -0,0 +1,22 @@
+package com.github.alexthe666.citadel.server.entity.pathfinding.raycoms;
+
+/**
+ * COMPILE-TIME STUB of
+ * {@code com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.AdvancedPathNavigate}.
+ *
+ * <p>The real class at runtime extends {@code AbstractAdvancedPathNavigate}
+ * (which holds the {@code ourEntity} field and the {@code getOurEntity()}
+ * accessor we use). The mixin doesn't need to reference any fields directly
+ * — it casts {@code this} to the parent at runtime — so this stub only needs
+ * the inheritance link to be present so {@code (AbstractAdvancedPathNavigate)
+ * (Object) this} compiles cleanly.
+ *
+ * <p>Excluded from the output jar via {@code jar { exclude 'com/github/**' }}
+ * in build.gradle so it can never shadow the real class at runtime.
+ */
+public abstract class AdvancedPathNavigate extends AbstractAdvancedPathNavigate {
+
+    protected PathResult moveToXYZ(final double x, final double y, final double z, final double speedFactor) {
+        return null;
+    }
+}
blob - /dev/null
blob + 42992ad16c531461a662b90b737a84de2085c9b8 (mode 644)
--- /dev/null
+++ src/main/java/com/github/alexthe666/citadel/server/entity/pathfinding/raycoms/PathResult.java
@@ -0,0 +1,13 @@
+package com.github.alexthe666.citadel.server.entity.pathfinding.raycoms;
+
+/**
+ * COMPILE-TIME STUB of
+ * {@code com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.PathResult}.
+ *
+ * <p>The real class at runtime is provided by Citadel. This stub only needs
+ * to exist to satisfy the compiler for AeronauticsCompat's mixin; no fields
+ * or methods from it are used by the mixin itself. Excluded from the output
+ * jar via the {@code jar { exclude 'com/github/**' }} rule in build.gradle.
+ */
+public class PathResult<J> {
+}
blob - b0c9c249d56ddbe451aaa3c0bdb02ed2905b28df
blob + 0767b5fca0ee1a1e17b034b21949dd8212888137
--- src/main/java/sh/rsap/aeronauticscompat/compat/SableBridge.java
+++ src/main/java/sh/rsap/aeronauticscompat/compat/SableBridge.java
@@ -2,6 +2,7 @@ package sh.rsap.aeronauticscompat.compat;
 
 import com.mojang.logging.LogUtils;
 import net.minecraft.core.Position;
+import net.minecraft.world.entity.Entity;
 import net.minecraft.world.level.Level;
 import org.slf4j.Logger;
 
@@ -27,10 +28,13 @@ public final class SableBridge {
     private static final Object INSTANCE;
     /** {@code double distanceSquaredWithSubLevels(Level, Position, Position)} */
     private static final Method DIST_SQ_METHOD;
+    /** {@code SubLevel getContaining(Entity)} */
+    private static final Method GET_CONTAINING_ENTITY_METHOD;
 
     static {
         Object inst = null;
         Method dist = null;
+        Method getContainingEntity = null;
         String foundFqn = null;
 
         for (String fqn : CANDIDATE_FQNS) {
@@ -42,6 +46,15 @@ public final class SableBridge {
                 dist = cls.getMethod("distanceSquaredWithSubLevels",
                         Level.class, Position.class, Position.class);
                 dist.setAccessible(true);
+                // getContaining(Entity) lives on ActiveSableCompanion (the runtime
+                // subclass of SableCompanion). Best-effort lookup against the
+                // resolved instance class.
+                try {
+                    getContainingEntity = inst.getClass().getMethod("getContaining", Entity.class);
+                    getContainingEntity.setAccessible(true);
+                } catch (NoSuchMethodException ignored) {
+                    // older Sable without this overload — leave as null
+                }
                 foundFqn = fqn;
                 break;
             } catch (Throwable ignored) {
@@ -51,9 +64,11 @@ public final class SableBridge {
 
         INSTANCE = inst;
         DIST_SQ_METHOD = dist;
+        GET_CONTAINING_ENTITY_METHOD = getContainingEntity;
 
         if (INSTANCE != null && DIST_SQ_METHOD != null) {
-            LOGGER.info("[AeronauticsCompat] SableBridge bound to {}", foundFqn);
+            LOGGER.info("[AeronauticsCompat] SableBridge bound to {} (membership-check={})",
+                    foundFqn, GET_CONTAINING_ENTITY_METHOD != null);
         } else {
             LOGGER.info("[AeronauticsCompat] SableBridge inactive (Sable not resolved).");
         }
@@ -76,4 +91,25 @@ public final class SableBridge {
             return Double.NaN;
         }
     }
+
+    /**
+     * Whether the given entity is currently inside a Sable sub-level (i.e.
+     * standing on a contraption / ship). Used by the Citadel pathfinding
+     * guard to avoid rejecting legitimate paths between two entities that
+     * are both on the same contraption — those endpoints have huge
+     * world-space coords by design and would otherwise look identical to
+     * the sub-level entity bleed-through that the guard exists to catch.
+     *
+     * <p>Returns {@code false} if Sable isn't loaded, the membership lookup
+     * isn't available, or the call throws — all of which are fail-safe:
+     * the caller will then fall back to the conservative distance check.
+     */
+    public static boolean isInSubLevel(Entity entity) {
+        if (entity == null || GET_CONTAINING_ENTITY_METHOD == null) return false;
+        try {
+            return GET_CONTAINING_ENTITY_METHOD.invoke(INSTANCE, entity) != null;
+        } catch (Throwable t) {
+            return false;
+        }
+    }
 }
blob - 12c587e977718538fe9861761f888e5073ea36dc
blob + cb534c34f5b64b8a83873d3d7afb2cc161819a6f
--- src/main/java/sh/rsap/aeronauticscompat/mixin/AeronauticsCompatMixinPlugin.java
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/AeronauticsCompatMixinPlugin.java
@@ -20,13 +20,16 @@ public class AeronauticsCompatMixinPlugin implements I
     private static final String SABLE = "dev.ryanhcode.sable.sound.SoundInstanceDelegated";
     private static final String ETCHED = "gg.moonflower.etched.api.sound.StopListeningSound";
     private static final String WATERFRAMES = "me.srrapero720.waterframes.WaterFrames";
+    private static final String CITADEL = "com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.AdvancedPathNavigate";
 
     /** Per-mixin marker-class requirements (all must resolve to apply). */
     private static final Map<String, String[]> REQUIREMENTS = Map.of(
             "sh.rsap.aeronauticscompat.mixin.etched.EtchedStopListeningSoundMixin",
             new String[]{SABLE, ETCHED},
             "sh.rsap.aeronauticscompat.mixin.waterframes.WaterFramesSableDistanceMixin",
-            new String[]{SABLE, WATERFRAMES}
+            new String[]{SABLE, WATERFRAMES},
+            "sh.rsap.aeronauticscompat.mixin.citadel.CitadelAdvancedPathNavigateMixin",
+            new String[]{SABLE, CITADEL}
     );
 
     @Override
@@ -35,7 +38,8 @@ public class AeronauticsCompatMixinPlugin implements I
         System.out.println("[AeronauticsCompat] Mod detection: "
                 + "sable=" + resolves(SABLE, cl)
                 + " etched=" + resolves(ETCHED, cl)
-                + " waterframes=" + resolves(WATERFRAMES, cl));
+                + " waterframes=" + resolves(WATERFRAMES, cl)
+                + " citadel=" + resolves(CITADEL, cl));
     }
 
     @Override
blob - /dev/null
blob + 64849a8cafc5f34c7729c941d926fcd8b7d5b4b0 (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/citadel/CitadelAdvancedPathNavigateMixin.java
@@ -0,0 +1,110 @@
+package sh.rsap.aeronauticscompat.mixin.citadel;
+
+import com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.AbstractAdvancedPathNavigate;
+import com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.AdvancedPathNavigate;
+import com.github.alexthe666.citadel.server.entity.pathfinding.raycoms.PathResult;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Pseudo;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+import sh.rsap.aeronauticscompat.compat.SableBridge;
+
+/**
+ * Citadel-from-contraption OOM guard.
+ *
+ * <p>When Sable is installed,
+ * {@code dev.ryanhcode.sable.util.SubLevelInclusiveLevelEntityGetter} causes
+ * {@code Level#getEntities(AABB, ...)} to also scan entities living in Sable
+ * sub-levels. Those entities' raw coordinates are in sub-level space — often
+ * tens of millions of blocks away from the querying mob's own world position.
+ *
+ * <p>When a Citadel-powered mob (anything from Alex's Mobs, Alex's Caves,
+ * Cataclysm, etc.) sees such an entity and targets it — or picks a random
+ * stroll pos near it — its goal code calls
+ * {@link AdvancedPathNavigate#moveToXYZ} with sub-level coordinates. Citadel's
+ * {@code AbstractPathJob} then sizes a {@code ChunkCache}'s
+ * {@code LevelChunk[][]} array by the start-to-end bounding box. With end
+ * coords in the tens of millions, the array allocation exhausts the JVM heap
+ * and crashes the server.
+ *
+ * <p><b>The membership-aware check.</b> A naive distance-only reject breaks
+ * legitimate AI on contraptions: two mobs both standing on the same Sable
+ * ship are both at huge world coordinates by design, so the distance between
+ * them looks identical to a sub-level entity bleeding through into a
+ * world-space mob's awareness range. We disambiguate by asking Sable whether
+ * the path-issuing mob itself is currently inside a sub-level
+ * ({@link SableBridge#isInSubLevel}):
+ *
+ * <ul>
+ *   <li><b>Mob in a sub-level</b> → it's standing on a contraption. Allow
+ *       any target — the destination is most likely something else on the
+ *       same ship, and rejecting would freeze the mob in place.</li>
+ *   <li><b>Mob in world space</b> → fall back to the distance check. Any
+ *       path target more than {@link #AERONAUTICSCOMPAT$MAX_REASONABLE_DISTANCE_SQ}
+ *       blocks² away is the bleed-through pattern; reject and let the goal
+ *       pick something else next tick.</li>
+ * </ul>
+ */
+@Pseudo
+@Mixin(value = AdvancedPathNavigate.class, remap = false)
+public abstract class CitadelAdvancedPathNavigateMixin {
+
+    /**
+     * Max square-distance (blocks²) between a (world-space) mob and a
+     * prospective path target before AeronauticsCompat rejects the call.
+     * 1024 blocks is well beyond any reasonable FOLLOW_RANGE; legitimate
+     * world-space pathing is unaffected. Mobs on contraptions skip this
+     * check entirely.
+     */
+    @Unique
+    private static final double AERONAUTICSCOMPAT$MAX_REASONABLE_DISTANCE_SQ = 1024.0 * 1024.0;
+
+    @Inject(
+        method = "moveToXYZ(DDDD)Lcom/github/alexthe666/citadel/server/entity/pathfinding/raycoms/PathResult;",
+        at = @At("HEAD"),
+        cancellable = true,
+        remap = false
+    )
+    private void aeronauticscompat$rejectSubLevelTargets(
+        final double x,
+        final double y,
+        final double z,
+        final double speedFactor,
+        final CallbackInfoReturnable<PathResult> cir
+    ) {
+        // AdvancedPathNavigate extends AbstractAdvancedPathNavigate at runtime,
+        // so the cast is safe. We don't have a static type relationship here
+        // (the mixin class doesn't extend the target), hence the (Object) bounce.
+        final AbstractAdvancedPathNavigate self = (AbstractAdvancedPathNavigate) (Object) this;
+        final Mob m = self.getOurEntity();
+        if (m == null) {
+            return;
+        }
+
+        // If the mob is on a contraption, give the AI total freedom — the
+        // destination is almost certainly on the same ship and will look
+        // far away in world space by design.
+        if (SableBridge.isInSubLevel(m)) {
+            return;
+        }
+
+        final Vec3 pos = m.position();
+        final double dx = x - pos.x;
+        final double dy = y - pos.y;
+        final double dz = z - pos.z;
+        final double distSq = dx * dx + dy * dy + dz * dz;
+
+        if (Double.isNaN(distSq) || Double.isInfinite(distSq)
+            || distSq > AERONAUTICSCOMPAT$MAX_REASONABLE_DISTANCE_SQ) {
+            // Target is almost certainly a sub-level entity bleeding through
+            // Sable's SubLevelInclusiveLevelEntityGetter, or a random stroll
+            // pos drawn from such an entity's vicinity. Refuse the path —
+            // next tick the goal will pick something else.
+            cir.setReturnValue(null);
+        }
+    }
+}
blob - 99d5c3e821739f7533be36fd847ab5e6ab4fc242
blob + fbf6e07de129ff57517907bad472eb2852d548d7
--- src/main/resources/aeronauticscompat.mixins.json
+++ src/main/resources/aeronauticscompat.mixins.json
@@ -4,6 +4,9 @@
   "plugin": "sh.rsap.aeronauticscompat.mixin.AeronauticsCompatMixinPlugin",
   "compatibilityLevel": "JAVA_21",
   "minVersion": "0.8",
+  "mixins": [
+    "citadel.CitadelAdvancedPathNavigateMixin"
+  ],
   "client": [
     "etched.EtchedStopListeningSoundMixin",
     "waterframes.WaterFramesSableDistanceMixin"
blob - de8d417aece25c66fea44e38663dda633460d94f
blob + 7943a9e4cf2c5760b086fbe8f1e0245e20553aa8
--- src/main/templates/META-INF/neoforge.mods.toml
+++ src/main/templates/META-INF/neoforge.mods.toml
@@ -49,3 +49,10 @@ type = "optional"
 versionRange = "${waterframes_version_range}"
 ordering = "AFTER"
 side = "BOTH"
+
+[[dependencies.${mod_id}]]
+modId = "citadel"
+type = "optional"
+versionRange = "${citadel_version_range}"
+ordering = "AFTER"
+side = "BOTH"