commit - 4dde72f7ef2c407e3be26faa36f808d44a193f27
commit + 7559f2684f57cde53d5c8ecf448780a92ef7d269
blob - a4e3057ff19c17f5bd13c8875e0cfd7edc7870d1
blob + 99db605a70e585ea29a6c61f71c09d1012be530b
--- README.md
+++ README.md
-# AeronauticsCompat 1.0.4
+# AeronauticsCompat 1.0.6
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).
## Patches
### Etched × Sable: music discs on contraptions stop correctly
-**Problem.** On a moving Sable contraption (e.g., Create: Aeronautics ship), music discs never stop playing after the disc is ejected. Affects **every** disc — vanilla, datapack, and Etched YouTube discs — as long as Etched is installed.
+**Problem.** On a moving Sable contraption (e.g., Create: Aeronautics ship), music discs never stop playing after the disc is ejected. Affects **every** disc — vanilla, datapack, and Etched discs — as long as Etched is installed.
**Cause.** Etched's `LevelRendererMixin.modifySoundInstance` wraps every jukebox sound in `StopListeningSound`, which doesn't extend `AbstractSoundInstance`, so Sable's `AbstractSoundInstanceMixin` (which implements `SoundInstanceDelegated`) doesn't apply. On stop, Sable can't unwrap the sound to the `MovingSoundInstanceDelegate` keyed in OpenAL; the stop lookup misses and the sound plays forever.
### 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.
+**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 allocation exhausts JVM heap 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.
+**Fix.** Mixin `AdvancedPathNavigate.moveToXYZ` at HEAD. If the horizontal span between the mob's actual position and the target exceeds 4096 blocks on either axis, 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. The check is symmetric, so legitimate in-world pathing AND legitimate mob-to-mob pathing between two entities on the same contraption (where both endpoints are in the same sub-level coordinate frame, and therefore close in relative terms) both pass through untouched.
*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.
-
## Dependencies
- **Required**: NeoForge 1.21.1
./gradlew build
```
-No external jars required in `libs/`. Compile-time stubs for Sable and WaterFrames live in `src/stubs/java/` and are excluded from the output jar. If you want to cross-compile against a real Etched jar (e.g., to rebuild the YouTube source), drop `etched.jar` in `libs/`.
+No external jars required in `libs/`. Compile-time stubs for Sable and WaterFrames live in `src/stubs/java/` and are excluded from the output jar.
## License
blob - f8c6b5889d17546f887628362dd639d40a4045ac
blob + 201fece08b720c85c74dcbe0b1fc8646db031be4
--- gradle.properties
+++ gradle.properties
# Mod Properties
mod_id=aeronauticscompat
mod_name=AeronauticsCompat
-mod_license=MIT
-mod_version=1.0.4
-mod_authors=rsap
-mod_description=Compatibility fixes for Create: Aeronautics / Sable sub-levels
+mod_license=GPLv3
+mod_version=1.0.6
+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 - 880df312550304f2369701161e1fb07708b704f6
blob + 6f30b702572ce3c60dedc454f83b4ce528abb29c
--- src/main/java/sh/rsap/aeronauticscompat/AeronauticsCompat.java
+++ src/main/java/sh/rsap/aeronauticscompat/AeronauticsCompat.java
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.ModList;
import net.neoforged.fml.common.Mod;
-import net.neoforged.fml.config.ModConfig;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
-import net.neoforged.neoforge.common.ModConfigSpec;
-import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
+/**
+ * AeronauticsCompat entry point.
+ *
+ * <p>All of this mod's actual behavior lives in its mixins — see
+ * {@code aeronauticscompat.mixins.json} and
+ * {@link sh.rsap.aeronauticscompat.mixin.AeronauticsCompatMixinPlugin} for the
+ * list of patches and their per-mod gates. This class just logs what's on the
+ * classpath at common setup time so crash reports are easier to read.
+ */
@Mod(AeronauticsCompat.MOD_ID)
public final class AeronauticsCompat {
public static final String MOD_ID = "aeronauticscompat";
public static final Logger LOGGER = LogUtils.getLogger();
- public static final AeronauticsCompatConfig CONFIG;
- private static final ModConfigSpec CONFIG_SPEC;
-
- static {
- Pair<AeronauticsCompatConfig, ModConfigSpec> pair =
- new ModConfigSpec.Builder().configure(AeronauticsCompatConfig::new);
- CONFIG = pair.getLeft();
- CONFIG_SPEC = pair.getRight();
- }
-
public AeronauticsCompat(IEventBus bus, ModContainer container) {
bus.addListener(this::commonSetup);
- container.registerConfig(ModConfig.Type.CLIENT, CONFIG_SPEC);
}
private void commonSetup(FMLCommonSetupEvent event) {
ModList mods = ModList.get();
- if (mods.isLoaded("etched")) {
- // Loaded reflectively so the class isn't linked when Etched is absent.
- try {
- Class.forName("sh.rsap.aeronauticscompat.etched.YoutubeSourceRegistrar")
- .getMethod("register")
- .invoke(null);
- } catch (Throwable t) {
- LOGGER.error("[AeronauticsCompat] Failed to register YouTube source", t);
- }
- } else {
- LOGGER.info("[AeronauticsCompat] Etched not present; YouTube source skipped.");
- }
-
- LOGGER.info("[AeronauticsCompat] Sable={} Etched={} WaterFrames={}",
- mods.isLoaded("sable"), mods.isLoaded("etched"), mods.isLoaded("waterframes"));
+ LOGGER.info("[AeronauticsCompat] Sable={} Etched={} WaterFrames={} Citadel={}",
+ mods.isLoaded("sable"),
+ mods.isLoaded("etched"),
+ mods.isLoaded("waterframes"),
+ mods.isLoaded("citadel"));
}
}
blob - eb6be1509926cb22eaaecc25bf125ddd75238757 (mode 644)
blob + /dev/null
--- src/main/java/sh/rsap/aeronauticscompat/AeronauticsCompatConfig.java
+++ /dev/null
-package sh.rsap.aeronauticscompat;
-
-import net.neoforged.neoforge.common.ModConfigSpec;
-
-public final class AeronauticsCompatConfig {
-
- public final ModConfigSpec.ConfigValue<String> proxyUrl;
- public final ModConfigSpec.ConfigValue<String> proxyToken;
- public final ModConfigSpec.IntValue connectTimeoutSeconds;
- public final ModConfigSpec.IntValue readTimeoutSeconds;
-
- AeronauticsCompatConfig(ModConfigSpec.Builder builder) {
- builder.comment(
- "Etched YouTube proxy settings.",
- "Only used when Etched is installed."
- ).push("YoutubeProxy");
-
- this.proxyUrl = builder
- .comment(
- "Base URL of a self-hosted yt-dlp proxy (no trailing slash).",
- "Leave empty to disable YouTube support.",
- "Example: https://yt.example.com"
- )
- .define("URL", "");
-
- this.proxyToken = builder
- .comment(
- "Bearer token for the YouTube proxy.",
- "Leave empty to disable YouTube support."
- )
- .define("Token", "");
-
- this.connectTimeoutSeconds = builder
- .comment("HTTP connect timeout (seconds) when talking to the proxy.")
- .defineInRange("Connect Timeout", 15, 1, 300);
-
- this.readTimeoutSeconds = builder
- .comment(
- "HTTP read timeout (seconds). First resolves can take a while."
- )
- .defineInRange("Read Timeout", 120, 10, 600);
-
- builder.pop();
- }
-}
blob - f0570e37a18aa6d29b8123f10d92cba18aa67669 (mode 644)
blob + /dev/null
--- src/main/java/sh/rsap/aeronauticscompat/etched/YoutubeSource.java
+++ /dev/null
-package sh.rsap.aeronauticscompat.etched;
-
-import sh.rsap.aeronauticscompat.AeronauticsCompat;
-
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonParser;
-import gg.moonflower.etched.api.record.TrackData;
-import gg.moonflower.etched.api.sound.download.SoundDownloadSource;
-import gg.moonflower.etched.api.util.DownloadProgressListener;
-import net.minecraft.network.chat.Component;
-import net.minecraft.network.chat.TextColor;
-import net.minecraft.server.packs.resources.ResourceManager;
-import net.minecraft.util.GsonHelper;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.Proxy;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.WeakHashMap;
-
-/**
- * Resolves YouTube URLs by delegating to a self-hosted yt-dlp proxy service.
- * <p>
- * Flow: POSTs to {@code <proxy>/api/resolve} with JSON {@code {"url": "..."}}
- * and bearer auth. The service runs yt-dlp, returns metadata plus an
- * {@code audio_url} pointing at {@code <proxy>/api/stream/<sha1>.mp3}. That
- * stream endpoint is unauthenticated — the sha1 id is the capability (like
- * a presigned URL) so the mod's audio pipeline can fetch it without having
- * to attach an Authorization header.
- */
-public final class YoutubeSource implements SoundDownloadSource {
-
- private static final Component BRAND =
- Component.translatable("sound_source.aeronauticscompat.brand")
- .withStyle(style -> style.withColor(TextColor.fromRgb(0xFF0000)));
-
- private static final List<String> HOST_SUFFIXES = List.of(
- "youtube.com",
- "youtu.be",
- "youtube-nocookie.com",
- "music.youtube.com"
- );
-
- private final Map<String, Boolean> validCache = new WeakHashMap<>();
-
- // Short-lived cache of resolved track info so resolveTracks, resolveUrl,
- // and resolveAlbumCover (all called in close succession when a disc is
- // inserted) share one HTTP call.
- private final Map<String, ResolvedTrack> trackCache =
- Collections.synchronizedMap(new WeakHashMap<>());
-
- private record ResolvedTrack(String audioUrl, String title, String artist, double duration) {}
-
- private static String trimEndpoint() {
- String ep = AeronauticsCompat.CONFIG.proxyUrl.get();
- if (ep == null) return "";
- return ep.replaceAll("/+$", "");
- }
-
- private static String token() {
- String t = AeronauticsCompat.CONFIG.proxyToken.get();
- return t == null ? "" : t;
- }
-
- private static boolean configured() {
- return !trimEndpoint().isEmpty() && !token().isEmpty();
- }
-
- private ResolvedTrack resolve(String url,
- @Nullable DownloadProgressListener progressListener,
- Proxy proxy) throws IOException {
- ResolvedTrack cached = this.trackCache.get(url);
- if (cached != null) return cached;
-
- if (!configured()) {
- throw new IOException(
- "AeronauticsCompat YouTube proxy is not configured. " +
- "Set 'URL' and 'Token' in config/aeronauticscompat-client.toml."
- );
- }
-
- if (progressListener != null) {
- progressListener.progressStartRequest(Component.translatable(
- "sound_source.etched.requesting", this.getApiName()));
- }
-
- String body = "{\"url\":" + jsonString(url) + "}";
- URL endpointUrl;
- try {
- endpointUrl = new URI(trimEndpoint() + "/api/resolve").toURL();
- } catch (URISyntaxException e) {
- throw new IOException("Invalid proxy URL: " + trimEndpoint(), e);
- }
-
- HttpURLConnection conn = (HttpURLConnection) endpointUrl.openConnection(proxy);
- try {
- conn.setRequestMethod("POST");
- conn.setDoOutput(true);
- conn.setConnectTimeout(AeronauticsCompat.CONFIG.connectTimeoutSeconds.get() * 1000);
- conn.setReadTimeout(AeronauticsCompat.CONFIG.readTimeoutSeconds.get() * 1000);
- conn.setInstanceFollowRedirects(true);
- for (Map.Entry<String, String> h : SoundDownloadSource.getDownloadHeaders().entrySet()) {
- conn.setRequestProperty(h.getKey(), h.getValue());
- }
- conn.setRequestProperty("Content-Type", "application/json");
- conn.setRequestProperty("Accept", "application/json");
- conn.setRequestProperty("Authorization", "Bearer " + token());
-
- try (OutputStream os = conn.getOutputStream()) {
- os.write(body.getBytes(StandardCharsets.UTF_8));
- }
-
- int code = conn.getResponseCode();
- if (code != 200) {
- String err = readErrorMessage(conn);
- throw new IOException("proxy returned " + code + (err.isEmpty() ? "" : ": " + err));
- }
-
- JsonObject json;
- try (InputStreamReader reader = new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) {
- json = JsonParser.parseReader(reader).getAsJsonObject();
- }
-
- String audioUrl = GsonHelper.getAsString(json, "audio_url");
- String title = GsonHelper.getAsString(json, "title", "");
- String artist = GsonHelper.getAsString(json, "artist", "");
- double duration = 0.0;
- if (json.has("duration") && !json.get("duration").isJsonNull()) {
- duration = json.get("duration").getAsDouble();
- }
-
- ResolvedTrack t = new ResolvedTrack(audioUrl, title, artist, duration);
- this.trackCache.put(url, t);
- return t;
- } finally {
- conn.disconnect();
- }
- }
-
- @Override
- public Collection<URL> resolveUrl(String url,
- @Nullable DownloadProgressListener progressListener,
- Proxy proxy) throws IOException, URISyntaxException, JsonParseException {
- ResolvedTrack t = this.resolve(url, progressListener, proxy);
- return Collections.singletonList(new URI(t.audioUrl()).toURL());
- }
-
- @Override
- public Collection<TrackData> resolveTracks(String url,
- @Nullable DownloadProgressListener progressListener,
- Proxy proxy) throws IOException, URISyntaxException, JsonParseException {
- ResolvedTrack t = this.resolve(url, progressListener, proxy);
- String title = t.title().isEmpty() ? url : t.title();
- String artist = t.artist().isEmpty() ? "YouTube" : t.artist();
- return Collections.singletonList(new TrackData(url, artist, Component.literal(title)));
- }
-
- @Override
- public Optional<String> resolveAlbumCover(String url,
- @Nullable DownloadProgressListener progressListener,
- Proxy proxy,
- ResourceManager resourceManager) {
- String videoId = extractVideoId(url);
- if (videoId == null) return Optional.empty();
- return Optional.of("https://i.ytimg.com/vi/" + videoId + "/hqdefault.jpg");
- }
-
- @Override
- public boolean isValidUrl(String url) {
- return this.validCache.computeIfAbsent(url, key -> {
- try {
- String host = new URI(key).getHost();
- if (host == null) return false;
- String lower = host.toLowerCase();
- for (String suffix : HOST_SUFFIXES) {
- if (lower.equals(suffix) || lower.endsWith("." + suffix)) return true;
- }
- return false;
- } catch (URISyntaxException e) {
- return false;
- }
- });
- }
-
- @Override
- public boolean isTemporary(String url) {
- return true;
- }
-
- @Override
- public String getApiName() {
- return "YouTube";
- }
-
- @Override
- public Optional<Component> getBrandText(String url) {
- return Optional.of(BRAND);
- }
-
- // --- helpers ---
-
- private static @Nullable String extractVideoId(String url) {
- try {
- URI uri = new URI(url);
- String host = uri.getHost();
- if (host == null) return null;
- String lowerHost = host.toLowerCase();
- String path = uri.getPath() == null ? "" : uri.getPath();
-
- if (lowerHost.equals("youtu.be") || lowerHost.endsWith(".youtu.be")) {
- if (path.length() > 1) {
- return path.substring(1).split("/")[0];
- }
- return null;
- }
-
- for (String prefix : new String[]{"/shorts/", "/embed/", "/v/"}) {
- if (path.startsWith(prefix)) {
- String rest = path.substring(prefix.length());
- int slash = rest.indexOf('/');
- return slash >= 0 ? rest.substring(0, slash) : rest;
- }
- }
-
- String query = uri.getQuery();
- if (query == null) return null;
- for (String part : query.split("&")) {
- int eq = part.indexOf('=');
- if (eq <= 0) continue;
- if (part.substring(0, eq).equals("v")) {
- return part.substring(eq + 1);
- }
- }
- return null;
- } catch (URISyntaxException e) {
- return null;
- }
- }
-
- private static String readErrorMessage(HttpURLConnection conn) {
- try (InputStream es = conn.getErrorStream()) {
- if (es == null) {
- String msg = conn.getResponseMessage();
- return msg == null ? "" : msg;
- }
- String text = new String(es.readAllBytes(), StandardCharsets.UTF_8);
- try {
- JsonObject json = JsonParser.parseString(text).getAsJsonObject();
- if (json.has("error")) return GsonHelper.getAsString(json, "error");
- } catch (Throwable ignored) {
- }
- return text;
- } catch (IOException e) {
- return "";
- }
- }
-
- private static String jsonString(String s) {
- StringBuilder sb = new StringBuilder(s.length() + 2);
- sb.append('"');
- for (int i = 0; i < s.length(); i++) {
- char c = s.charAt(i);
- switch (c) {
- case '"' -> sb.append("\\\"");
- case '\\' -> sb.append("\\\\");
- case '\n' -> sb.append("\\n");
- case '\r' -> sb.append("\\r");
- case '\t' -> sb.append("\\t");
- default -> {
- if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
- else sb.append(c);
- }
- }
- }
- sb.append('"');
- return sb.toString();
- }
-}
blob - b8bf15236cc2db118f44b5466ad06074cbee9131 (mode 644)
blob + /dev/null
--- src/main/java/sh/rsap/aeronauticscompat/etched/YoutubeSourceRegistrar.java
+++ /dev/null
-package sh.rsap.aeronauticscompat.etched;
-
-import gg.moonflower.etched.api.sound.download.SoundSourceManager;
-import sh.rsap.aeronauticscompat.AeronauticsCompat;
-
-/**
- * Isolated registrar for the Etched YouTube source.
- *
- * <p>Lives in its own class so {@link SoundSourceManager} is only class-
- * loaded when Etched is installed. {@link AeronauticsCompat} calls
- * {@link #register()} reflectively after checking {@code ModList.isLoaded("etched")}.
- */
-public final class YoutubeSourceRegistrar {
-
- private YoutubeSourceRegistrar() {}
-
- public static void register() {
- SoundSourceManager.registerSource(new YoutubeSource());
- AeronauticsCompat.LOGGER.info("[AeronauticsCompat] Etched YouTube source registered.");
- }
-}
blob - 64849a8cafc5f34c7729c941d926fcd8b7d5b4b0
blob + 3faec0a4989be5bea853b1659507a0d278745e94
--- src/main/java/sh/rsap/aeronauticscompat/mixin/citadel/CitadelAdvancedPathNavigateMixin.java
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/citadel/CitadelAdvancedPathNavigateMixin.java
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.
+ * sub-levels. Those entities' raw positions live in <i>sub-level-local</i>
+ * coordinates — often tens of millions of blocks from any world-space mob
+ * that sees them.
*
- * <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>When a Citadel-powered mob (Alex's Mobs, Alex's Caves, Cataclysm, etc.)
+ * calls {@link AdvancedPathNavigate#moveToXYZ} with one of these targets,
+ * Citadel's {@code AbstractPathJob} allocates a {@code ChunkCache} sized by
+ * the bounding box between the mob's <b>world-space</b> start position and
+ * the <b>sub-level-space</b> end position:
*
- * <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}):
+ * <pre>
+ * final int minX = Math.min(start.getX(), end.getX()) - range/2;
+ * final int maxX = Math.max(start.getX(), end.getX()) + range/2;
+ * // ... same for Z, then a LevelChunk[dx/16 + 1][dz/16 + 1] is allocated.
+ * </pre>
*
- * <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>
+ * With one coordinate near origin and the other in the tens of millions, the
+ * chunk array is sized in the trillions of entries and the JVM OOMs. This
+ * happens on servers too — the path job runs on the server thread.
+ *
+ * <p><b>The span-based guard.</b> We measure the <i>span</i> between the
+ * mob's current position (what {@code AbstractPathJob.prepareStart} will use
+ * as {@code start}) and the requested target {@code (x, y, z)} (the
+ * {@code end}). If the horizontal span exceeds
+ * {@link #AERONAUTICSCOMPAT$MAX_REASONABLE_SPAN} blocks, the resulting
+ * {@code ChunkCache} would be too large to allocate safely — this is
+ * definitionally the bleed-through pattern, regardless of whether the mob
+ * is on a contraption or in world space. We reject the path and return
+ * {@code null}; next tick the goal will pick a saner target.
+ *
+ * <p>Crucially this guard is <i>symmetric</i>: it doesn't matter whether
+ * start is tiny and end is huge, or vice-versa — we only care about the
+ * span. That means legitimate mob-to-mob paths entirely within a single
+ * sub-level (both endpoints at, say, 20000001 and 20000005) are allowed
+ * through, because the span is small. And legitimate world-space paths
+ * (start and end both near world origin) are also allowed. Only the
+ * pathological cross-domain case is rejected.
*/
@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.
+ * Maximum horizontal span (blocks, per axis) between a mob's current
+ * position and a prospective path target before the call is rejected.
+ * Chosen so that {@code (span / 16 + 1)²} stays comfortably below
+ * anything that could exhaust a typical server heap — 4096 blocks yields
+ * a ~256×256 chunk array (65k entries), still plenty of headroom for
+ * any real pathing job while catching anything in the millions.
*/
@Unique
- private static final double AERONAUTICSCOMPAT$MAX_REASONABLE_DISTANCE_SQ = 1024.0 * 1024.0;
+ private static final double AERONAUTICSCOMPAT$MAX_REASONABLE_SPAN = 4096.0;
@Inject(
method = "moveToXYZ(DDDD)Lcom/github/alexthe666/citadel/server/entity/pathfinding/raycoms/PathResult;",
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;
+ final double dx = Math.abs(x - pos.x);
+ final double dz = Math.abs(z - pos.z);
- 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.
+ if (Double.isNaN(dx) || Double.isNaN(dz)
+ || Double.isInfinite(dx) || Double.isInfinite(dz)
+ || dx > AERONAUTICSCOMPAT$MAX_REASONABLE_SPAN
+ || dz > AERONAUTICSCOMPAT$MAX_REASONABLE_SPAN) {
+ // ChunkCache allocation would explode. This is the bleed-through
+ // pattern — reject the path; next tick the goal will pick something
+ // more sensible. Silent; this fires frequently.
cir.setReturnValue(null);
}
}
blob - ed00e90ee8d9dc9d9540525f82a6d0deb1e50ddc
blob + ffaba628e15d07a3e732d46ad4bc0441545f7284
--- src/main/java/sh/rsap/aeronauticscompat/mixin/etched/EtchedStopListeningSoundMixin.java
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/etched/EtchedStopListeningSoundMixin.java
/**
* Fixes the "jukebox music never stops on a Sable/Create: Aeronautics
* contraption" bug that affects <em>every</em> music disc (vanilla,
- * datapack, Etched YouTube) as long as Etched is installed.
+ * datapack, Etched) as long as Etched is installed.
*
* <p><strong>Root cause.</strong> When a jukebox sound is played on a
* block sitting in a Sable sub-level (i.e. a moving contraption), Sable's
blob - b88b1ede6017051e0a67ac4932122d7a33e80fc5
blob + 0967ef424bce6791893e9a57bb952f80fd536e93
--- src/main/resources/assets/aeronauticscompat/lang/en_us.json
+++ src/main/resources/assets/aeronauticscompat/lang/en_us.json
-{
- "sound_source.aeronauticscompat.brand": "Provided by YouTube"
-}
+{}