Rohan Verma



Commit Diff


commit - /dev/null
commit + d0df860db553d49ad9577921e485218cc97f2c8f
blob - /dev/null
blob + d1c4d8dfb215598e988b394e801ac5a6a9f24c9a (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,27 @@
+# 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
@@ -0,0 +1,48 @@
+# 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
@@ -0,0 +1,119 @@
+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
@@ -0,0 +1,7 @@
+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
@@ -0,0 +1,30 @@
+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
@@ -0,0 +1,248 @@
+#!/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
@@ -0,0 +1,93 @@
+@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
@@ -0,0 +1,12 @@
+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
@@ -0,0 +1,17 @@
+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
@@ -0,0 +1,19 @@
+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
@@ -0,0 +1,25 @@
+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
@@ -0,0 +1,53 @@
+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
@@ -0,0 +1,45 @@
+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
@@ -0,0 +1,79 @@
+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
@@ -0,0 +1,291 @@
+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
@@ -0,0 +1,21 @@
+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
@@ -0,0 +1,67 @@
+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
@@ -0,0 +1,57 @@
+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
@@ -0,0 +1,56 @@
+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
@@ -0,0 +1,14 @@
+{
+  "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
@@ -0,0 +1,3 @@
+{
+  "sound_source.aeronauticscompat.brand": "Provided by YouTube"
+}
blob - /dev/null
blob + de8d417aece25c66fea44e38663dda633460d94f (mode 644)
--- /dev/null
+++ src/main/templates/META-INF/neoforge.mods.toml
@@ -0,0 +1,51 @@
+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"