commit d0df860db553d49ad9577921e485218cc97f2c8f from: rohanverma2007 date: Tue Apr 21 20:32:38 2026 UTC init 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}. + * + *

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. + * + *

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}. + * + *

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}. + * + *

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}. + * + *

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}). + * + *

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 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 proxyUrl; + public final ModConfigSpec.ConfigValue 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. + *

+ * Flow: POSTs to {@code /api/resolve} with JSON {@code {"url": "..."}} + * and bearer auth. The service runs yt-dlp, returns metadata plus an + * {@code audio_url} pointing at {@code /api/stream/.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 HOST_SUFFIXES = List.of( + "youtube.com", + "youtu.be", + "youtube-nocookie.com", + "music.youtube.com" + ); + + private final Map 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 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 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 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 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 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 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. + * + *

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. + * + *

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 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 myTargets, Set otherTargets) {} + @Override public List 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 every music disc (vanilla, + * datapack, Etched YouTube) as long as Etched is installed. + * + *

Root cause. 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. + * + *

Etched, however, installs its own wrapper over every + * 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. + * + *

Fix. 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. + * + *

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. + * + *

Bug. 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. + * + *

Fix. 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. + * + *

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 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"