commit - /dev/null
commit + d0df860db553d49ad9577921e485218cc97f2c8f
blob - /dev/null
blob + d1c4d8dfb215598e988b394e801ac5a6a9f24c9a (mode 644)
--- /dev/null
+++ .gitignore
+# eclipse
+bin
+*.launch
+.settings
+.metadata
+.classpath
+.project
+
+# idea
+out
+*.ipr
+*.iws
+*.iml
+.idea
+
+# gradle
+build
+.gradle
+
+# other
+eclipse
+run
+runs
+run-data
+
+repo
+changelog.md
blob - /dev/null
blob + 70c6d5dbf3fc487305f4751c46fe6fc31b3bb542 (mode 644)
--- /dev/null
+++ README.md
+# AeronauticsCompat 1.0.0
+
+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).
+
+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
+
+### 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.
+
+**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.
+
+**Fix.** Mixin `StopListeningSound implements SoundInstanceDelegated`, holding a `MovingSoundInstanceDelegate` back-reference. Sable's constructor now stores the wrapper on us at play-time and reads it back at stop-time. OpenAL's channel key matches on both ends.
+
+*Activates when: Etched + Sable are both installed.*
+
+### WaterFrames × Sable: TV audio on contraptions is audible
+**Problem.** On a moving Sable contraption, WaterFrames TVs/projectors are silent, even at point-blank range. Video plays fine.
+
+**Cause.** WaterFrames attenuates audio by the Euclidean distance from the block to the player. On a Sable contraption, the block's logical position is in a sub-level at coordinates millions of blocks from the player's world-space position, so the computed distance is astronomical and the volume is clamped to zero.
+
+**Fix.** Mixin `@Inject` at HEAD of `WaterFrames.getDistance(Level, BlockPos, Position)`; when Sable is present, short-circuit to `Math.sqrt(SableCompanion.distanceSquaredWithSubLevels(level, Vec3.atCenterOf(pos), playerPos))`. This mirrors WaterFrames' existing Valkyrien Skies compat path exactly.
+
+*Activates when: WaterFrames + 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
+- **Optional** (each enables one or more patches): Sable, Etched, WaterFrames
+
+No target mod is a hard dependency. The mod loads and logs which patches are active on startup.
+
+## Building
+
+```
+./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/`.
+
+## License
+
+GPLv3.
blob - /dev/null
blob + 7b78ca1799a817aa89236cd6351aa7925aeec0a0 (mode 644)
--- /dev/null
+++ build.gradle
+plugins {
+ id 'java-library'
+ id 'net.neoforged.moddev' version '2.0.113'
+}
+
+tasks.named('wrapper', Wrapper).configure {
+ distributionType = Wrapper.DistributionType.BIN
+}
+
+version = mod_version
+group = mod_group_id
+
+repositories {
+ mavenLocal()
+ flatDir {
+ dirs 'libs'
+ }
+}
+
+base {
+ archivesName = mod_id
+}
+
+java {
+ toolchain.languageVersion = JavaLanguageVersion.of(21)
+ withSourcesJar()
+}
+
+neoForge {
+ version = project.neo_version
+
+ parchment {
+ mappingsVersion = project.parchment_mappings_version
+ minecraftVersion = project.parchment_minecraft_version
+ }
+
+ runs {
+ client {
+ client()
+ systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
+ }
+
+ server {
+ server()
+ programArgument '--nogui'
+ systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
+ }
+
+ configureEach {
+ systemProperty 'forge.logging.markers', 'REGISTRIES'
+ logLevel = org.slf4j.event.Level.DEBUG
+ }
+ }
+
+ mods {
+ "${mod_id}" {
+ sourceSet(sourceSets.main)
+ }
+ }
+}
+
+// Stubs for third-party types live alongside main sources at
+// src/main/java. At compile time they let us reference Sable's and
+// WaterFrames' types without depending on those mods. At runtime, the real
+// mods (when loaded) provide the real classes. The AeronauticsCompatMixinPlugin
+// disables any mixin whose target mod is missing, so the stubs are never
+// actually used.
+//
+// We exclude the stub packages from the output jar so they can't shadow
+// the real classes. See the `jar` block below.
+
+configurations {
+ runtimeClasspath.extendsFrom localRuntime
+}
+
+dependencies {
+ implementation "net.neoforged:neoforge:${neo_version}"
+
+ // Etched: dropped into ./libs/ at build time. The user provides etched.jar
+ // at runtime — we never bundle it.
+ compileOnly fileTree(dir: 'libs', include: ['*.jar'])
+ localRuntime fileTree(dir: 'libs', include: ['*.jar'])
+}
+
+// Don't ship the stub classes — they would shadow the real classes at runtime.
+jar {
+ exclude 'dev/ryanhcode/**'
+ exclude 'me/srrapero720/**'
+}
+
+var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
+ var replaceProperties = [
+ minecraft_version : minecraft_version,
+ minecraft_version_range: minecraft_version_range,
+ neo_version : neo_version,
+ neo_version_range : neo_version_range,
+ loader_version_range : loader_version_range,
+ etched_version_range : etched_version_range,
+ sable_version_range : sable_version_range,
+ waterframes_version_range: waterframes_version_range,
+ mod_id : mod_id,
+ mod_name : mod_name,
+ mod_license : mod_license,
+ mod_version : mod_version,
+ mod_authors : mod_authors,
+ mod_description : mod_description
+ ]
+ inputs.properties replaceProperties
+ expand replaceProperties
+ from "src/main/templates"
+ into "build/generated/sources/modMetadata"
+}
+
+sourceSets.main.resources.srcDir generateModMetadata
+neoForge.ideSyncTask generateModMetadata
+
+tasks.withType(JavaCompile).configureEach {
+ options.encoding = 'UTF-8'
+}
blob - /dev/null
blob + 8bdaf60c75ab801e22807dde59e12a8735a34077 (mode 644)
Binary files /dev/null and gradle/wrapper/gradle-wrapper.jar differ
blob - /dev/null
blob + 37f78a6af8379e9ac907b8527c9c68807bf42d20 (mode 644)
--- /dev/null
+++ gradle/wrapper/gradle-wrapper.properties
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
blob - /dev/null
blob + a58fe6a8db4b34d2862657f257dc460b65a21bcc (mode 644)
--- /dev/null
+++ gradle.properties
+org.gradle.jvmargs=-Xmx1G
+org.gradle.daemon=true
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+
+## Minecraft / Parchment
+parchment_minecraft_version=1.21.1
+parchment_mappings_version=2024.11.17
+
+# Environment Properties
+minecraft_version=1.21.1
+minecraft_version_range=[1.21.1]
+neo_version=21.1.77
+neo_version_range=[21.1.0,)
+loader_version_range=[1,)
+
+# Optional dependencies. All integrations are soft: missing mods are simply skipped.
+etched_version_range=[5.0.0,6.0.0)
+sable_version_range=[1.0.0,)
+waterframes_version_range=[2.1.0,3.0.0)
+
+# Mod Properties
+mod_id=aeronauticscompat
+mod_name=AeronauticsCompat
+mod_license=GPLv3
+mod_version=1.0.0
+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 + adff685a0348c64b2b64f0b302f1f82b28eeaaea (mode 755)
--- /dev/null
+++ gradlew
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
blob - /dev/null
blob + e509b2dd8fe5579a5954a2c28633ad914cd1c225 (mode 644)
--- /dev/null
+++ gradlew.bat
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
blob - /dev/null
blob + 8d86244e113130ca89162ece7f165074ee0a52bf (mode 644)
Binary files /dev/null and libs/etched.jar differ
blob - /dev/null
blob + db19ec46044723c3d8caf0658e294a802d7f5adc (mode 644)
--- /dev/null
+++ settings.gradle
+pluginManagement {
+ repositories {
+ mavenLocal()
+ gradlePluginPortal()
+ maven { url = 'https://maven.neoforged.net/releases' }
+ }
+}
+
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
+}
+rootProject.name = 'aeronauticscompat'
blob - /dev/null
blob + b4b7effa2d6806ca8669964bd3b23a907c211ab0 (mode 644)
--- /dev/null
+++ src/main/java/dev/ryanhcode/sable/sound/MovingSoundInstanceDelegate.java
+package dev.ryanhcode.sable.sound;
+
+/**
+ * COMPILE-TIME STUB of Sable's
+ * {@code dev.ryanhcode.sable.sound.MovingSoundInstanceDelegate}.
+ *
+ * <p>This stub exists only so Etchtube's mixin can refer to the type name.
+ * The mixin stores a reference via {@link SoundInstanceDelegated#setDelegate}
+ * and reads it back via {@link SoundInstanceDelegated#getDelegate}; it never
+ * touches any methods on this class, so a no-body stub is sufficient.
+ *
+ * <p>At runtime, Sable itself provides the real class. This stub is built
+ * into a separate source set whose output is excluded from the Etchtube jar
+ * (see {@code build.gradle}).
+ */
+public class MovingSoundInstanceDelegate {
+}
blob - /dev/null
blob + 752e29c85150b03e64d304506518bf364af5fa2e (mode 644)
--- /dev/null
+++ src/main/java/dev/ryanhcode/sable/sound/SoundInstanceDelegated.java
+package dev.ryanhcode.sable.sound;
+
+/**
+ * COMPILE-TIME STUB of Sable's {@code dev.ryanhcode.sable.sound.SoundInstanceDelegated}.
+ *
+ * <p>This file exists purely so Etchtube's mixin can reference this interface at
+ * compile time. It is built into a separate {@code sableStubs} source set whose
+ * output is explicitly excluded from the Etchtube jar (see {@code build.gradle}).
+ * At runtime, Sable itself provides the real interface; if Sable isn't loaded,
+ * the mixin that depends on it is disabled by {@code EtchtubeMixinPlugin}.
+ *
+ * <p>Keep this shape in sync with upstream Sable. If the upstream interface
+ * changes its method signatures, this stub must be updated to match.
+ */
+public interface SoundInstanceDelegated {
+ MovingSoundInstanceDelegate getDelegate();
+
+ void setDelegate(MovingSoundInstanceDelegate delegate);
+}
blob - /dev/null
blob + 8241691230773707dfbb36c24bcba50a5ec3f511 (mode 644)
--- /dev/null
+++ src/main/java/me/srrapero720/waterframes/WaterFrames.java
+package me.srrapero720.waterframes;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Position;
+import net.minecraft.world.level.Level;
+
+/**
+ * COMPILE-TIME STUB of
+ * {@code me.srrapero720.waterframes.WaterFrames}.
+ *
+ * <p>Only the method signature that AeronauticsCompat's mixin targets is
+ * declared. The real WaterFrames class at runtime provides the full
+ * implementation. This stub is built into a separate source set whose
+ * output is excluded from the AeronauticsCompat jar (see {@code build.gradle}).
+ *
+ * <p>Keep the {@code getDistance} signature in sync with upstream. The
+ * mixin targets the three-argument {@code (Level, BlockPos, Position)}
+ * overload by its full JVM descriptor.
+ */
+public class WaterFrames {
+
+ public static double getDistance(Level level, BlockPos pos, Position position) {
+ return 0.0d;
+ }
+}
blob - /dev/null
blob + 880df312550304f2369701161e1fb07708b704f6 (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/AeronauticsCompat.java
+package sh.rsap.aeronauticscompat;
+
+import com.mojang.logging.LogUtils;
+import net.neoforged.bus.api.IEventBus;
+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;
+
+@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"));
+ }
+}
blob - /dev/null
blob + eb6be1509926cb22eaaecc25bf125ddd75238757 (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/AeronauticsCompatConfig.java
+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 - /dev/null
blob + b0c9c249d56ddbe451aaa3c0bdb02ed2905b28df (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/compat/SableBridge.java
+package sh.rsap.aeronauticscompat.compat;
+
+import com.mojang.logging.LogUtils;
+import net.minecraft.core.Position;
+import net.minecraft.world.level.Level;
+import org.slf4j.Logger;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+/**
+ * Reflective bridge to Sable's {@code SableCompanion}. We don't compile
+ * against Sable; lookups short-circuit to a no-op when the class isn't
+ * present so the mod stays loadable without Sable.
+ */
+public final class SableBridge {
+
+ private static final Logger LOGGER = LogUtils.getLogger();
+
+ private SableBridge() {}
+
+ private static final String[] CANDIDATE_FQNS = {
+ "dev.ryanhcode.sable.companion.SableCompanion",
+ "dev.ryanhcode.sable.SableCompanion",
+ };
+
+ private static final Object INSTANCE;
+ /** {@code double distanceSquaredWithSubLevels(Level, Position, Position)} */
+ private static final Method DIST_SQ_METHOD;
+
+ static {
+ Object inst = null;
+ Method dist = null;
+ String foundFqn = null;
+
+ for (String fqn : CANDIDATE_FQNS) {
+ try {
+ Class<?> cls = Class.forName(fqn);
+ Field f = cls.getField("INSTANCE");
+ f.setAccessible(true);
+ inst = f.get(null);
+ dist = cls.getMethod("distanceSquaredWithSubLevels",
+ Level.class, Position.class, Position.class);
+ dist.setAccessible(true);
+ foundFqn = fqn;
+ break;
+ } catch (Throwable ignored) {
+ // try next FQN
+ }
+ }
+
+ INSTANCE = inst;
+ DIST_SQ_METHOD = dist;
+
+ if (INSTANCE != null && DIST_SQ_METHOD != null) {
+ LOGGER.info("[AeronauticsCompat] SableBridge bound to {}", foundFqn);
+ } else {
+ LOGGER.info("[AeronauticsCompat] SableBridge inactive (Sable not resolved).");
+ }
+ }
+
+ public static boolean isAvailable() {
+ return INSTANCE != null && DIST_SQ_METHOD != null;
+ }
+
+ /**
+ * Compute the squared distance between two points including any sub-level
+ * transforms. Returns {@code Double.NaN} when Sable isn't available — the
+ * caller should fall back to vanilla distance in that case.
+ */
+ public static double distanceSquaredWithSubLevels(Level level, Position a, Position b) {
+ if (!isAvailable()) return Double.NaN;
+ try {
+ return (Double) DIST_SQ_METHOD.invoke(INSTANCE, level, a, b);
+ } catch (Throwable t) {
+ return Double.NaN;
+ }
+ }
+}
blob - /dev/null
blob + f0570e37a18aa6d29b8123f10d92cba18aa67669 (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/etched/YoutubeSource.java
+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 - /dev/null
blob + b8bf15236cc2db118f44b5466ad06074cbee9131 (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/etched/YoutubeSourceRegistrar.java
+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 - /dev/null
blob + 12c587e977718538fe9861761f888e5073ea36dc (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/AeronauticsCompatMixinPlugin.java
+package sh.rsap.aeronauticscompat.mixin;
+
+import org.objectweb.asm.tree.ClassNode;
+import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
+import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Gates each mixin on the required target mod(s) being present. A mixin is
+ * applied only if every marker class it depends on resolves.
+ *
+ * <p>If a required mod is missing the mixin is silently skipped — the rest
+ * of AeronauticsCompat (config, logs, other mixins) keeps working.
+ */
+public class AeronauticsCompatMixinPlugin implements IMixinConfigPlugin {
+
+ 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";
+
+ /** 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}
+ );
+
+ @Override
+ public void onLoad(String mixinPackage) {
+ ClassLoader cl = AeronauticsCompatMixinPlugin.class.getClassLoader();
+ System.out.println("[AeronauticsCompat] Mod detection: "
+ + "sable=" + resolves(SABLE, cl)
+ + " etched=" + resolves(ETCHED, cl)
+ + " waterframes=" + resolves(WATERFRAMES, cl));
+ }
+
+ @Override
+ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
+ String[] markers = REQUIREMENTS.get(mixinClassName);
+ if (markers == null) return true;
+ ClassLoader cl = AeronauticsCompatMixinPlugin.class.getClassLoader();
+ for (String m : markers) {
+ if (!resolves(m, cl)) return false;
+ }
+ return true;
+ }
+
+ private static boolean resolves(String fqn, ClassLoader cl) {
+ // IMPORTANT: don't use Class.forName here — it *defines* the class even
+ // with initialize=false, which trips Mixin's "target loaded too early"
+ // check if we happen to probe a class that another mixin wants to
+ // transform. getResource only looks for the .class file on the
+ // classpath; it does not load it.
+ String resource = fqn.replace('.', '/') + ".class";
+ return cl.getResource(resource) != null;
+ }
+
+ @Override public String getRefMapperConfig() { return null; }
+ @Override public void acceptTargets(Set<String> myTargets, Set<String> otherTargets) {}
+ @Override public List<String> getMixins() { return null; }
+ @Override public void preApply(String t, ClassNode c, String m, IMixinInfo i) {}
+ @Override public void postApply(String t, ClassNode c, String m, IMixinInfo i) {}
+}
blob - /dev/null
blob + ed00e90ee8d9dc9d9540525f82a6d0deb1e50ddc (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/etched/EtchedStopListeningSoundMixin.java
+package sh.rsap.aeronauticscompat.mixin.etched;
+
+import dev.ryanhcode.sable.sound.MovingSoundInstanceDelegate;
+import dev.ryanhcode.sable.sound.SoundInstanceDelegated;
+import gg.moonflower.etched.api.sound.StopListeningSound;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+
+/**
+ * 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.
+ *
+ * <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
+ * {@code SoundEngineMixin.sable$play} wraps the sound in a
+ * {@link MovingSoundInstanceDelegate} at play-time — OpenAL's channel is
+ * keyed by that wrapper. On stop, Sable's {@code sable$stop} unwraps back
+ * to the wrapper via {@link SoundInstanceDelegated}, so the lookup hits
+ * and the channel is closed.
+ *
+ * <p>Etched, however, installs its own wrapper over <em>every</em>
+ * jukebox sound via {@code LevelRendererMixin.modifySoundInstance}, which
+ * rewrites the local variable in {@code LevelRenderer.playRecord} to be a
+ * {@link StopListeningSound}. {@code StopListeningSound} does not extend
+ * {@code AbstractSoundInstance}, so it doesn't inherit Sable's
+ * {@code SoundInstanceDelegated} mixin. Consequence: at stop time,
+ * Sable's unwrap check ({@code instance instanceof SoundInstanceDelegated})
+ * fails, the {@link StopListeningSound} is passed through to OpenAL, no
+ * channel matches it, and the sound plays forever.
+ *
+ * <p><strong>Fix.</strong> Make {@code StopListeningSound} implement
+ * {@link SoundInstanceDelegated}. Sable's constructor sets the back-ref
+ * on us at play-time; Sable's stop-time unwrap reads it back. Now both
+ * ends of the Sable pipeline see a matched channel key and the sound
+ * stops when the disc is ejected.
+ *
+ * <p>This mixin is conditionally applied (see
+ * {@code sh.rsap.etchtube.mixin.EtchtubeMixinPlugin}): if Sable isn't
+ * loaded, the mixin isn't applied, and Etchtube runs without it.
+ */
+@Mixin(StopListeningSound.class)
+public abstract class EtchedStopListeningSoundMixin implements SoundInstanceDelegated {
+
+ @Unique
+ private MovingSoundInstanceDelegate etchtube$sableDelegate;
+
+ @Override
+ public MovingSoundInstanceDelegate getDelegate() {
+ return this.etchtube$sableDelegate;
+ }
+
+ @Override
+ public void setDelegate(MovingSoundInstanceDelegate delegate) {
+ this.etchtube$sableDelegate = delegate;
+ }
+}
blob - /dev/null
blob + e039ac8160c40beeac10f16935c1e04dddc8715b (mode 644)
--- /dev/null
+++ src/main/java/sh/rsap/aeronauticscompat/mixin/waterframes/WaterFramesSableDistanceMixin.java
+package sh.rsap.aeronauticscompat.mixin.waterframes;
+
+import me.srrapero720.waterframes.WaterFrames;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Position;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Mixin;
+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;
+
+/**
+ * Makes WaterFrames' TVs audible when they ride a Sable / Create: Aeronautics
+ * contraption.
+ *
+ * <p><b>Bug.</b> WaterFrames attenuates audio by the distance between the
+ * TV's block position and the player. On a Sable contraption the block lives
+ * in a sub-level at coordinates around 20 million blocks from the player's
+ * world-space position — so the computed distance is astronomical and the
+ * TV is silent no matter how close you stand.
+ *
+ * <p><b>Fix.</b> WaterFrames already has an exact analog for Valkyrien Skies
+ * ({@code VSCompat.getSquaredDistance}). This mixin adds the Sable equivalent
+ * at HEAD: if Sable is loaded, short-circuit to its
+ * {@code distanceSquaredWithSubLevels}, which projects both endpoints out
+ * of any enclosing sub-level before measuring.
+ *
+ * <p>Gated on both WaterFrames and Sable being loaded — see
+ * {@code AeronauticsCompatMixinPlugin}.
+ */
+@Mixin(WaterFrames.class)
+public abstract class WaterFramesSableDistanceMixin {
+
+ @Inject(
+ method = "getDistance(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/core/Position;)D",
+ at = @At("HEAD"),
+ cancellable = true,
+ remap = false
+ )
+ private static void aeronauticscompat$sableDistance(
+ Level level, BlockPos pos, Position position,
+ CallbackInfoReturnable<Double> cir
+ ) {
+ if (!SableBridge.isAvailable()) return;
+
+ double dSq = SableBridge.distanceSquaredWithSubLevels(
+ level,
+ Vec3.atCenterOf(pos),
+ position
+ );
+ if (Double.isNaN(dSq)) return;
+ cir.setReturnValue(Math.sqrt(dSq));
+ }
+}
blob - /dev/null
blob + 99d5c3e821739f7533be36fd847ab5e6ab4fc242 (mode 644)
--- /dev/null
+++ src/main/resources/aeronauticscompat.mixins.json
+{
+ "required": true,
+ "package": "sh.rsap.aeronauticscompat.mixin",
+ "plugin": "sh.rsap.aeronauticscompat.mixin.AeronauticsCompatMixinPlugin",
+ "compatibilityLevel": "JAVA_21",
+ "minVersion": "0.8",
+ "client": [
+ "etched.EtchedStopListeningSoundMixin",
+ "waterframes.WaterFramesSableDistanceMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
blob - /dev/null
blob + b88b1ede6017051e0a67ac4932122d7a33e80fc5 (mode 644)
--- /dev/null
+++ src/main/resources/assets/aeronauticscompat/lang/en_us.json
+{
+ "sound_source.aeronauticscompat.brand": "Provided by YouTube"
+}
blob - /dev/null
blob + de8d417aece25c66fea44e38663dda633460d94f (mode 644)
--- /dev/null
+++ src/main/templates/META-INF/neoforge.mods.toml
+modLoader = "javafml"
+loaderVersion = "${loader_version_range}"
+license = "${mod_license}"
+
+[[mods]]
+modId = "${mod_id}"
+version = "${mod_version}"
+displayName = "${mod_name}"
+authors = "${mod_authors}"
+description = '''${mod_description}'''
+
+[[mixins]]
+config = "${mod_id}.mixins.json"
+
+# Hard requirements
+[[dependencies.${mod_id}]]
+modId = "neoforge"
+type = "required"
+versionRange = "${neo_version_range}"
+ordering = "NONE"
+side = "BOTH"
+
+[[dependencies.${mod_id}]]
+modId = "minecraft"
+type = "required"
+versionRange = "${minecraft_version_range}"
+ordering = "NONE"
+side = "BOTH"
+
+# Soft integrations. Each patch applies only when its target mod is present;
+# none of these are required for AeronauticsCompat to load.
+[[dependencies.${mod_id}]]
+modId = "sable"
+type = "optional"
+versionRange = "${sable_version_range}"
+ordering = "AFTER"
+side = "BOTH"
+
+[[dependencies.${mod_id}]]
+modId = "etched"
+type = "optional"
+versionRange = "${etched_version_range}"
+ordering = "AFTER"
+side = "BOTH"
+
+[[dependencies.${mod_id}]]
+modId = "waterframes"
+type = "optional"
+versionRange = "${waterframes_version_range}"
+ordering = "AFTER"
+side = "BOTH"