diff --git a/build.gradle b/build.gradle index bfa7d4c..28bdf67 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,10 @@ dependencies { compileOnly fg.deobf("blank:WariumVS:1.1.7") compileOnly fg.deobf("blank:futurism:0.0.4") + // GeckoLib 4.8.4, which Warium VS animates its control surfaces with (optional dependency, remapped, the surface animation mixin + // only applies when it and Warium VS are present) + compileOnly fg.deobf("maven.modrinth:geckolib:aC5KMoNg") + // AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped) compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}") } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java index 0f5d785..e1ae0e0 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java @@ -220,6 +220,11 @@ public final class Config { .comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound") .define("soundEnabled", true); + public static final ForgeConfigSpec.BooleanValue ANIMATE_SURFACES = CLIENT_BUILDER + .comment("Control surfaces and flaps visibly deflect by as much as the flight physics deflects them, instead of Warium's", + "fixed 20 degree steps. Needs physicsRateAerodynamics on the server") + .define("animateControlSurfaces", true); + static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build(); private Config() { diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java index 28d0d64..f984cbb 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java @@ -29,6 +29,7 @@ public class ImprovedAerodynamics { MinecraftForge.EVENT_BUS.register(ExhaustSync.class); MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.class); MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class); + MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.DeflectionSync.class); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC); // Own file name: the default one is taken by Config.CLIENT_SPEC diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java new file mode 100644 index 0000000..5e08033 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java @@ -0,0 +1,84 @@ +package dev.lumareth.improvedAerodynamics.client.animation; + +import dev.lumareth.improvedAerodynamics.Config; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.ClientPlayerNetworkEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import software.bernie.geckolib.core.animatable.model.CoreGeoBone; +import software.bernie.geckolib.model.GeoModel; + +/** + * Client side of the control surface animation. The server tells the clients how far each control surface is deflected + * (see {@code DeflectionSync}); this class keeps those values, eases towards them, and sets the rotation of the "Fin" + * bone of Warium's control surface models to them after GeckoLib has played Warium's own fixed animation. + *

+ * Warium's animations tilt the Fin bone by 20 degrees about X for a positive input, which GeckoLib turns into a + * rotation of -20 degrees (it negates X for rotations), and the same sign is used here, so a surface moves the way + * Warium's own animation moves it, only by the amount the physics uses. + */ +@Mod.EventBusSubscriber(modid = dev.lumareth.improvedAerodynamics.ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT) +public final class SurfaceAnimation { + private static final String BONE = "Fin"; + // A value that has not been refreshed for this long is dropped, Warium's own animation takes over again + private static final long STALE_NANOS = 3_000_000_000L; + // Time in seconds the visible deflection takes to close most of the way to the latest value + private static final double EASE_SECONDS = 0.07; + + private static final class Entry { + float target; + float shown; + long updated; + long drawn; + boolean placed; + } + + private static final Long2ObjectOpenHashMap ENTRIES = new Long2ObjectOpenHashMap<>(); + + private SurfaceAnimation() { + } + + /** New deflection of a control surface in degrees, in Warium's animation sign (positive is its "input up" tilt). */ + public static void set(long pos, float degrees) { + Entry entry = ENTRIES.computeIfAbsent(pos, key -> new Entry()); + entry.target = degrees; + entry.updated = System.nanoTime(); + if (!entry.placed) { + // First value of a surface: show it right away, it may have been there before the player came near + entry.shown = degrees; + entry.placed = true; + } + } + + public static void apply(BlockEntity blockEntity, GeoModel model) { + if (ENTRIES.isEmpty() || !Config.ANIMATE_SURFACES.get()) { + return; + } + Entry entry = ENTRIES.get(blockEntity.getBlockPos().asLong()); + if (entry == null) { + return; + } + long now = System.nanoTime(); + if (now - entry.updated > STALE_NANOS) { + ENTRIES.remove(blockEntity.getBlockPos().asLong()); + return; + } + // The same block can be drawn more than once a frame (shadows, other passes), the easing steps once per frame + if (entry.drawn != 0L && now != entry.drawn) { + double dt = (now - entry.drawn) / 1.0E9; + entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS)); + } + entry.drawn = now; + CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE); + if (bone != null) { + bone.setRotX((float) -Math.toRadians(entry.shown)); + } + } + + @SubscribeEvent + public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { + ENTRIES.clear(); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java index 8505cbf..9c08e43 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java @@ -42,6 +42,8 @@ public final class AeroState implements PhysicsBridge.Publishable { private boolean[] stalled = new boolean[0]; private boolean wingStalled; private final Map nodeStates = new ConcurrentHashMap<>(); + // Every ship's node states by node position, for the control surface animation on the server thread + private static final Map ALL_NODE_STATES = new ConcurrentHashMap<>(); private final Wrench wrench = new Wrench(); private final Wrench probe = new Wrench(); @@ -57,6 +59,8 @@ public final class AeroState implements PhysicsBridge.Publishable { double trimOutput; // Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow double flapAngle; + // Wall clock time of the last physics substep that ran this node, to tell whether its ship is flying + volatile long updatedMillis; // Total pitch input the flight computer applied last substep, pilot and trim together double computerOutput; // Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep @@ -209,9 +213,15 @@ public final class AeroState implements PhysicsBridge.Publishable { for (int i = 0; i < stalled.length; i++) { stalled[i] = previous.get(frame.surfaces[i].key()); } - nodeStates.keySet().removeIf(key -> !frame.nodes.containsKey((long) key)); + nodeStates.keySet().removeIf(key -> { + boolean gone = !frame.nodes.containsKey((long) key); + if (gone) { + ALL_NODE_STATES.remove(key); + } + return gone; + }); for (NodeData node : frame.nodes.values()) { - nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)); + ALL_NODE_STATES.put(node.key(), nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node))); } current = frame; } @@ -333,6 +343,7 @@ public final class AeroState implements PhysicsBridge.Publishable { state.yaw = moveTowards(state.yaw, effective.yaw(), step); state.roll = moveTowards(state.roll, effective.roll(), step); updateFlaps(state, node, worldVelocity.length(), dt); + state.updatedMillis = System.currentTimeMillis(); AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, torque, dt, improved && wingStalled); } } @@ -351,6 +362,11 @@ public final class AeroState implements PhysicsBridge.Publishable { state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt); } + /** The physics state of a control node, null if no physics runs for it. */ + static NodeState nodeState(long nodeKey) { + return ALL_NODE_STATES.get(nodeKey); + } + static double moveTowards(double value, double target, double maxStep) { if (Math.abs(target - value) <= maxStep) { return target; diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java new file mode 100644 index 0000000..94c24f3 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java @@ -0,0 +1,154 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import dev.lumareth.improvedAerodynamics.Config; +import dev.lumareth.improvedAerodynamics.network.DeflectionPacket; +import dev.lumareth.improvedAerodynamics.network.Network; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ShortOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.shorts.ShortArrayList; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.server.ServerStoppedEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.network.PacketDistributor; +import org.joml.Vector3d; +import org.valkyrienskies.core.api.ships.Ship; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tells the clients near a ship how far its control surfaces and flaps are deflected, so they can show it, see + * {@code SurfaceAnimation}. Every tick each Warium control surface block reports its deflection from the procedure that + * runs it ({@link WariumCapture#begin}); at the end of the tick what changed goes out, in one packet per ship, and + * everything again once a second so players who arrive later get it too. + *

+ * The deflection is what the physics uses: the smoothed pilot input plus trim and flap angle of the surface's control + * node. While the ship is not flying (no physics, nothing is moving) the node's raw input and flap lever are used, and the + * flaps are moved towards their angle here, at the configured speed. + */ +public final class DeflectionSync { + /** Degrees of deflection the physics gives a surface at full input, see ImprovedAero.MAX_DEFLECTION. */ + private static final double FULL_INPUT_DEGREES = 20.0; + private static final long RESEND_TICKS = 20; + private static final double RANGE = 256.0; + // A node state the physics thread has not touched for this long is not flying any more + private static final long FRESH_MILLIS = 500; + + private record Batch(ServerLevel level, double x, double y, double z, LongArrayList positions, ShortArrayList tenths) { + } + + private static final Map, Long2ShortOpenHashMap> LAST_VALUE = new HashMap<>(); + private static final Map, Long2LongOpenHashMap> LAST_SENT = new HashMap<>(); + // Flap angle (radians) shown while a node is not flying, and the tick it was last moved + private static final Map STATIC_FLAPS = new HashMap<>(); + private static final Map BATCHES = new HashMap<>(); + + private DeflectionSync() { + } + + /** Called by the procedure of every control surface block each tick, before it adds its forces. */ + static void report(ServerLevel level, BlockPos pos, SurfaceType type) { + if (!type.controllable) { + return; + } + BlockEntity blockEntity = level.getBlockEntity(pos); + if (blockEntity == null) { + return; + } + CompoundTag tag = blockEntity.getPersistentData(); + BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ")); + BlockEntity nodeEntity = level.getBlockEntity(nodePos); + if (nodeEntity == null || !tag.contains("ControlX")) { + return; + } + long nodeKey = nodePos.asLong(); + CompoundTag nodeTag = nodeEntity.getPersistentData(); + AeroState.NodeState state = AeroState.nodeState(nodeKey); + boolean flying = state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS; + + double input = axis(tag.getString("Key"), nodeTag, state, flying) + axis(tag.getString("Key2"), nodeTag, state, flying); + double degrees = FULL_INPUT_DEGREES * Math.max(-1.0, Math.min(1.0, input)); + if (type == SurfaceType.CONTROL_SURFACE && tag.getBoolean(WariumCapture.FLAP_TAG)) { + degrees += Math.toDegrees(flying ? state.flapAngle : staticFlap(level, nodeKey, nodeTag)); + } + + short tenths = (short) Math.round(Math.max(-90.0, Math.min(90.0, degrees)) * 10.0); + ResourceKey dimension = level.dimension(); + Long2ShortOpenHashMap lastValue = LAST_VALUE.computeIfAbsent(dimension, key -> new Long2ShortOpenHashMap()); + Long2LongOpenHashMap lastSent = LAST_SENT.computeIfAbsent(dimension, key -> new Long2LongOpenHashMap()); + long posKey = pos.asLong(); + long now = level.getGameTime(); + if (lastSent.containsKey(posKey) && lastValue.get(posKey) == tenths && now - lastSent.get(posKey) < RESEND_TICKS) { + return; + } + lastValue.put(posKey, tenths); + lastSent.put(posKey, now); + + Ship ship = VSGameUtilsKt.getShipManagingPos(level, pos); + long shipId = ship != null ? ship.getId() : Long.MIN_VALUE; + Batch batch = BATCHES.get(shipId); + if (batch == null) { + Vector3d world = new Vector3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + if (ship != null) { + ship.getShipToWorld().transformPosition(world); + } + batch = new Batch(level, world.x, world.y, world.z, new LongArrayList(), new ShortArrayList()); + BATCHES.put(shipId, batch); + } + batch.positions.add(posKey); + batch.tenths.add(tenths); + } + + /** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". */ + private static double axis(String key, CompoundTag nodeTag, AeroState.NodeState state, boolean flying) { + ControlAxis axis = ControlAxis.of(key); + double multiplier = ControlAxis.multiplier(key); + return switch (axis) { + case PITCH -> (flying ? state.pitch + state.trimOutput : nodeTag.getDouble("Pitch")) * multiplier; + case YAW -> (flying ? state.yaw : nodeTag.getDouble("Yaw")) * multiplier; + case ROLL -> (flying ? state.roll : nodeTag.getDouble("Roll")) * multiplier; + default -> 0.0; + }; + } + + /** Flap angle of a node that is not flying: moves towards the lever's angle, once per tick however many flaps ask. */ + private static double staticFlap(ServerLevel level, long nodeKey, CompoundTag nodeTag) { + double target = Math.toRadians(FlapSetting.of(nodeTag.getInt(WariumCapture.FLAP_SETTING_TAG)).degrees()); + double[] entry = STATIC_FLAPS.computeIfAbsent(nodeKey, key -> new double[]{target, Double.NaN}); + long now = level.getGameTime(); + if (entry[1] != now) { + entry[1] = now; + entry[0] = AeroState.moveTowards(entry[0], target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) / 20.0); + } + return entry[0]; + } + + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END || BATCHES.isEmpty()) { + return; + } + for (Batch batch : BATCHES.values()) { + DeflectionPacket packet = new DeflectionPacket(batch.positions.toLongArray(), batch.tenths.toShortArray()); + Network.CHANNEL.send(PacketDistributor.NEAR.with(() -> new PacketDistributor.TargetPoint(batch.x, batch.y, batch.z, + RANGE, batch.level.dimension())), packet); + } + BATCHES.clear(); + } + + @SubscribeEvent + public static void onServerStopped(ServerStoppedEvent event) { + BATCHES.clear(); + LAST_VALUE.clear(); + LAST_SENT.clear(); + STATIC_FLAPS.clear(); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java index 9dce857..a86da77 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java @@ -23,11 +23,13 @@ public final class MixinPlugin implements IMixinConfigPlugin { private boolean warium; private boolean futurism; + private boolean geckolib; @Override public void onLoad(String mixinPackage) { warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS"); futurism = supported("futurism", FUTURISM_VERSION, "Futurism"); + geckolib = LoadingModList.get().getModFileById("geckolib") != null; } private static boolean supported(String modId, String version, String name) { @@ -49,6 +51,10 @@ public final class MixinPlugin implements IMixinConfigPlugin { if (mixinClassName.contains(".mixin.warium.")) { return warium; } + if (mixinClassName.contains(".mixin.gecko.")) { + // Animates Warium's control surfaces, which are GeckoLib models + return warium && geckolib; + } if (mixinClassName.contains(".mixin.futurism.")) { return futurism; } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java index 4c57b67..a3a7401 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java @@ -50,6 +50,9 @@ public final class WariumCapture { world = level; pos = BlockPos.containing(x, y, z); state = blockState; + if (surfaceType.controllable && level instanceof ServerLevel serverLevel && Config.PHYSICS_RATE_AERODYNAMICS.get()) { + DeflectionSync.report(serverLevel, pos, surfaceType); + } } /** diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/gecko/GeoModelMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/gecko/GeoModelMixin.java new file mode 100644 index 0000000..fe666bb --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/gecko/GeoModelMixin.java @@ -0,0 +1,25 @@ +package dev.lumareth.improvedAerodynamics.flight.mixin.gecko; + +import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation; +import net.minecraft.world.level.block.entity.BlockEntity; +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.CallbackInfo; +import software.bernie.geckolib.core.animatable.GeoAnimatable; +import software.bernie.geckolib.core.animation.AnimationState; +import software.bernie.geckolib.model.GeoModel; + +/** + * Runs after GeckoLib has played an animatable's animations, to put Warium's control surfaces at the deflection the + * flight physics gives them, see {@link SurfaceAnimation}. + */ +@Mixin(value = GeoModel.class, remap = false) +public abstract class GeoModelMixin { + @Inject(method = "handleAnimations", at = @At("RETURN")) + private void improvedAerodynamics$deflectSurface(GeoAnimatable animatable, long instanceId, AnimationState animationState, CallbackInfo ci) { + if (animatable instanceof BlockEntity blockEntity) { + SurfaceAnimation.apply(blockEntity, (GeoModel) (Object) this); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/DeflectionPacket.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/DeflectionPacket.java new file mode 100644 index 0000000..c7fae36 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/DeflectionPacket.java @@ -0,0 +1,50 @@ +package dev.lumareth.improvedAerodynamics.network; + +import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.DistExecutor; +import net.minecraftforge.network.NetworkEvent; + +import java.util.function.Supplier; + +/** How far control surfaces are deflected, for the clients near a ship: block positions and tenths of a degree. */ +public final class DeflectionPacket { + private final long[] positions; + private final short[] tenths; + + public DeflectionPacket(long[] positions, short[] tenths) { + this.positions = positions; + this.tenths = tenths; + } + + public void encode(FriendlyByteBuf buffer) { + buffer.writeVarInt(positions.length); + for (int i = 0; i < positions.length; i++) { + buffer.writeLong(positions[i]); + buffer.writeShort(tenths[i]); + } + } + + public static DeflectionPacket decode(FriendlyByteBuf buffer) { + int count = buffer.readVarInt(); + long[] positions = new long[count]; + short[] tenths = new short[count]; + for (int i = 0; i < count; i++) { + positions[i] = buffer.readLong(); + tenths[i] = buffer.readShort(); + } + return new DeflectionPacket(positions, tenths); + } + + public void handle(Supplier context) { + long[] pos = positions; + short[] values = tenths; + DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> { + for (int i = 0; i < pos.length; i++) { + SurfaceAnimation.set(pos[i], values[i] / 10.0F); + } + }); + context.get().setPacketHandled(true); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java index 5941690..dfcf0ab 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java @@ -44,6 +44,11 @@ public final class Network { .decoder(ControlModePacket::decode) .consumerMainThread(ControlModePacket::handle) .add(); + CHANNEL.messageBuilder(DeflectionPacket.class, 11, NetworkDirection.PLAY_TO_CLIENT) + .encoder(DeflectionPacket::encode) + .decoder(DeflectionPacket::decode) + .consumerMainThread(DeflectionPacket::handle) + .add(); // Higher id than the neighbours so it does not collide with packets added on other branches CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER) .encoder(FlapPacket::encode) diff --git a/src/main/resources/improved_aerodynamics.mixins.json b/src/main/resources/improved_aerodynamics.mixins.json index b56432d..39940dc 100644 --- a/src/main/resources/improved_aerodynamics.mixins.json +++ b/src/main/resources/improved_aerodynamics.mixins.json @@ -6,6 +6,7 @@ "compatibilityLevel": "JAVA_17", "mixins": [ "futurism.ForceInducedShipsMixin", + "gecko.GeoModelMixin", "warium.AirBrakeTickProcedureMixin", "warium.BypassedCompressorScriptProcedureMixin", "warium.ControlSurfaceTickProcedureMixin",