diff --git a/build.gradle b/build.gradle
index faf18a7..dda01d6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -73,8 +73,9 @@ dependencies {
compileOnly files('libs/vs-core-api-1.1.0.jar', 'libs/vs-core-util-1.1.0.jar', 'libs/joml-primitives-1.10.0.jar')
compileOnly 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
- // Warium VS 1.1.6 (optional dependency, for the planned flight physics overhaul)
+ // Warium VS 1.1.6 and Futurism 0.0.4 (optional dependencies, the flight physics mixins only apply when they are present)
compileOnly fg.deobf("blank:WariumVS:1.1.6")
+ compileOnly fg.deobf("blank:futurism:0.0.4")
}
@@ -93,6 +94,13 @@ tasks.named('processResources', ProcessResources).configure {
}
+// Every mixin uses remap = false (Warium and VS are not obfuscated), so no refmap is needed
+tasks.named('jar', Jar).configure {
+ manifest {
+ attributes 'MixinConfigs': "${mod_id}.mixins.json"
+ }
+}
+
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
diff --git a/gradle.properties b/gradle.properties
index c5442aa..3b4d505 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
mod_id=improved_aerodynamics
mod_name=VS: Improved Aerodynamics
mod_license=MIT
-mod_version=0.1.0
+mod_version=0.2.0
mod_group_id=dev.lumareth.improvedaerodynamics
mod_authors=Lumareth
-mod_description=Valkyrien Skies addon that keeps terrain streaming ahead of fast ships, so they no longer slam into invisible walls at unloaded chunks.
+mod_description=Valkyrien Skies addon that keeps terrain streaming ahead of fast ships and smooths out Warium flight physics, with an optional flight model with real stalls and auto trim.
diff --git a/libs/futurism-0.0.4.jar b/libs/futurism-0.0.4.jar
new file mode 100644
index 0000000..698cfff
Binary files /dev/null and b/libs/futurism-0.0.4.jar differ
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
index 333ca8f..cabcd2e 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
@@ -44,6 +44,56 @@ public final class Config {
.comment("Ticks a placeholder chunk stays air after no ship needs it anymore, then it goes back to solid")
.defineInRange("placeholderReleaseTicks", 100, 20, 6000);
+ static {
+ BUILDER.pop();
+ BUILDER.comment("Warium VS (and Futurism) flight physics. Only has an effect when those mods are installed").push("flight");
+ }
+
+ public static final ForgeConfigSpec.BooleanValue SMOOTH_FORCE_DELIVERY = BUILDER
+ .comment("Warium computes its forces 20 times per second but applies them in only one of the physics substeps",
+ "in between, which makes thrust and lift jerky. When enabled, each tick's forces are spread evenly",
+ "over all substeps. The total push per tick stays the same, so existing craft keep their tuning")
+ .define("smoothForceDelivery", true);
+
+ public static final ForgeConfigSpec.BooleanValue PHYSICS_RATE_AERODYNAMICS = BUILDER
+ .comment("Recompute wing, stabilizer, control surface and air brake forces every physics substep from the",
+ "ship's current motion instead of once per tick from its motion one tick ago. Uses Warium's own",
+ "formulas, so craft fly the same, minus the wobble. Required for auto trim and the improved flight model")
+ .define("physicsRateAerodynamics", true);
+
+ public static final ForgeConfigSpec.BooleanValue CONTROL_SMOOTHING = BUILDER
+ .comment("Control surfaces move back to neutral as smoothly as they deflect, instead of snapping back on key release")
+ .define("controlSmoothing", true);
+
+ public static final ForgeConfigSpec.BooleanValue IMPLICIT_DAMPING = BUILDER
+ .comment("Integrate aerodynamic damping implicitly, so many wings on a light craft can no longer overshoot",
+ "and make it shake. Only changes anything where the damping would otherwise overshoot")
+ .define("implicitDamping", true);
+
+ public static final ForgeConfigSpec.BooleanValue IMPROVED_FLIGHT_MODEL = BUILDER
+ .comment("Replace Warium's lift and damping with a model based on each surface's angle of attack:",
+ "lift grows with the square of airspeed, surfaces stall one by one when their angle of attack gets",
+ "too high, and stalled surfaces stop damping rotation, which allows post-stall maneuvers with",
+ "thrust vectoring. Changes how craft fly")
+ .define("improvedFlightModel", false);
+
+ public static final ForgeConfigSpec.DoubleValue DESIGN_SPEED = BUILDER
+ .comment("Improved flight model: airspeed (blocks per second) at which a level wing makes the same lift as in Warium.",
+ "Slower flight needs a higher angle of attack, faster flight a lower one")
+ .defineInRange("designSpeed", 40.0D, 5.0D, 300.0D);
+
+ public static final ForgeConfigSpec.DoubleValue STALL_ANGLE_SCALE = BUILDER
+ .comment("Improved flight model: multiplier for the angle of attack at which surfaces stall")
+ .defineInRange("stallAngleScale", 1.0D, 0.5D, 2.0D);
+
+ public static final ForgeConfigSpec.DoubleValue TRIM_AUTHORITY = BUILDER
+ .comment("Auto trim: largest pitch input (fraction of full deflection) the trim may hold")
+ .defineInRange("trimAuthority", 0.5D, 0.0D, 1.0D);
+
+ public static final ForgeConfigSpec.DoubleValue TRIM_MIN_SPEED = BUILDER
+ .comment("Auto trim: below this airspeed (blocks per second) the trim holds its current value")
+ .defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D);
+
static {
BUILDER.pop();
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
index 1d288ea..f7dd972 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
@@ -1,6 +1,8 @@
package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils;
+import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
+import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.fml.ModLoadingContext;
@@ -16,6 +18,8 @@ public class ImprovedAerodynamics {
public ImprovedAerodynamics() {
MinecraftForge.EVENT_BUS.register(LookaheadLoader.class);
+ MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
+ Network.register();
}
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java
new file mode 100644
index 0000000..edeea51
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java
@@ -0,0 +1,55 @@
+package dev.lumareth.improvedAerodynamics.client;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
+import dev.lumareth.improvedAerodynamics.network.Network;
+import dev.lumareth.improvedAerodynamics.network.ToggleAutoTrimPacket;
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.Minecraft;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
+import net.minecraftforge.client.settings.KeyConflictContext;
+import net.minecraftforge.event.TickEvent;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+import net.minecraftforge.fml.common.Mod;
+import org.lwjgl.glfw.GLFW;
+
+public final class KeyBindings {
+ public static final KeyMapping TOGGLE_AUTO_TRIM = new KeyMapping("key.improved_aerodynamics.toggle_auto_trim",
+ KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_J, "key.categories.improved_aerodynamics");
+
+ private KeyBindings() {
+ }
+
+ @Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
+ public static final class Registration {
+ private Registration() {
+ }
+
+ @SubscribeEvent
+ public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
+ event.register(TOGGLE_AUTO_TRIM);
+ }
+ }
+
+ @Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
+ public static final class Handler {
+ private Handler() {
+ }
+
+ @SubscribeEvent
+ public static void onClientTick(TickEvent.ClientTickEvent event) {
+ if (event.phase != TickEvent.Phase.END) {
+ return;
+ }
+ Minecraft minecraft = Minecraft.getInstance();
+ while (TOGGLE_AUTO_TRIM.consumeClick()) {
+ // Only while seated, and only if the server has this mod too
+ if (minecraft.player != null && minecraft.player.getVehicle() != null && minecraft.getConnection() != null
+ && Network.CHANNEL.isRemotePresent(minecraft.getConnection().getConnection())) {
+ Network.CHANNEL.sendToServer(new ToggleAutoTrimPacket());
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroHolder.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroHolder.java
new file mode 100644
index 0000000..46ee596
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroHolder.java
@@ -0,0 +1,6 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/** Added to Warium's ForceInducedShips by mixin, gives access to the ship's aerodynamics. */
+public interface AeroHolder {
+ AeroState improvedAerodynamics$aero();
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
new file mode 100644
index 0000000..de65dc1
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
@@ -0,0 +1,309 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import dev.lumareth.improvedAerodynamics.Config;
+import it.unimi.dsi.fastutil.longs.Long2BooleanOpenHashMap;
+import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
+import org.joml.Matrix3dc;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+import org.valkyrienskies.core.api.ships.PhysShip;
+import org.valkyrienskies.core.api.ships.properties.ShipTransform;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Aerodynamics of one ship's Warium surfaces, evaluated every physics substep. The server thread registers the
+ * surfaces whose procedures ran (and so passed their conditions) during a tick, plus the control nodes they listen
+ * to; at the end of the tick that set is handed to the physics thread, which computes the forces from the ship's
+ * motion in each substep until the next tick arrives.
+ *
+ * The physics side also smooths pilot input, runs auto trim, and integrates damping implicitly so stiff damping
+ * cannot overshoot.
+ */
+public final class AeroState implements PhysicsBridge.Publishable {
+ // Warium's seat moves inputs by 0.2 per tick when pressing a key, smoothing uses that for releasing too
+ private static final double INPUT_RATE_PER_TICK = 0.2;
+ private static final double VELOCITY_EPSILON = 0.05;
+ private static final double ANGULAR_EPSILON = 0.005;
+
+ private final SubstepClock clock;
+
+ // Server thread
+ private Long2ObjectOpenHashMap buildingSurfaces = new Long2ObjectOpenHashMap<>();
+ private Long2ObjectOpenHashMap buildingNodes = new Long2ObjectOpenHashMap<>();
+
+ private volatile Frame published;
+
+ // Physics thread, except that the server thread reads trim values from nodeStates to save them
+ private Frame current;
+ private boolean[] stalled = new boolean[0];
+ private boolean wingStalled;
+ private final Map nodeStates = new ConcurrentHashMap<>();
+ private final Wrench wrench = new Wrench();
+ private final Wrench probe = new Wrench();
+
+ private record Frame(long tick, AeroSurface[] surfaces, Long2ObjectOpenHashMap nodes) {
+ }
+
+ /** Smoothed inputs and trim of a control node. */
+ static final class NodeState {
+ double pitch;
+ double yaw;
+ double roll;
+ double trimIntegral;
+ double trimOutput;
+ // Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep
+ final Vector3d pitchResponse = new Vector3d();
+ final Vector3d pitchResponseBuilding = new Vector3d();
+ volatile double savedTrim;
+
+ NodeState(NodeData data) {
+ pitch = data.pitch();
+ yaw = data.yaw();
+ roll = data.roll();
+ trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0;
+ trimOutput = trimIntegral;
+ savedTrim = trimIntegral;
+ }
+ }
+
+ public AeroState(SubstepClock clock) {
+ this.clock = clock;
+ }
+
+ // ---- Server thread ----
+
+ public boolean has(long surfaceKey) {
+ return buildingSurfaces.containsKey(surfaceKey);
+ }
+
+ public boolean hasNode(long nodeKey) {
+ return buildingNodes.containsKey(nodeKey);
+ }
+
+ public void add(AeroSurface surface) {
+ if (buildingSurfaces.isEmpty()) {
+ PhysicsBridge.touch(this);
+ }
+ buildingSurfaces.put(surface.key(), surface);
+ }
+
+ public void addNode(NodeData node) {
+ buildingNodes.put(node.key(), node);
+ }
+
+ /** Trim auto trim currently holds on a node, NaN if the physics thread has not seen the node. */
+ public double trim(long nodeKey) {
+ NodeState state = nodeStates.get(nodeKey);
+ return state != null ? state.savedTrim : Double.NaN;
+ }
+
+ @Override
+ public void publish(long tick) {
+ published = new Frame(tick, buildingSurfaces.values().toArray(new AeroSurface[0]), buildingNodes);
+ buildingSurfaces = new Long2ObjectOpenHashMap<>();
+ buildingNodes = new Long2ObjectOpenHashMap<>();
+ }
+
+ // ---- Physics thread ----
+
+ /** Applies this substep's aerodynamic forces. Call after {@link SubstepClock#step()}. */
+ public void apply(PhysShip ship, boolean autoScale) {
+ Frame frame = published;
+ if (frame == null || frame.tick < PhysicsBridge.publishedTick() || clock.stale()) {
+ return;
+ }
+ if (frame != current) {
+ switchFrame(frame);
+ }
+
+ ShipTransform transform = ship.getTransform();
+ Quaterniondc rotation = transform.getShipToWorldRotation();
+ Vector3dc centerOfMass = transform.getPositionInShip();
+ Vector3dc worldVelocity = ship.getVelocity();
+ Vector3d velocity = rotation.transformInverse(worldVelocity, new Vector3d());
+ Vector3d omega = rotation.transformInverse(ship.getOmega(), new Vector3d());
+ double[] v = {velocity.x, velocity.y, velocity.z};
+ double[] w = {omega.x, omega.y, omega.z};
+ double dt = clock.substepSeconds();
+ boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
+ Matrix3dc inertia = ship.getMomentOfInertia();
+
+ updateNodes(frame, worldVelocity, rotation, inertia, w, dt, improved);
+ evaluate(frame, centerOfMass, v, w, improved, true, wrench);
+
+ if (Config.IMPLICIT_DAMPING.get()) {
+ // Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
+ // diagonal of the force's derivative with respect to velocity and angular velocity
+ double[] diagonal = {inertia.m00(), inertia.m11(), inertia.m22()};
+ for (int axis = 0; axis < 3; axis++) {
+ v[axis] += VELOCITY_EPSILON;
+ evaluate(frame, centerOfMass, v, w, improved, false, probe);
+ v[axis] -= VELOCITY_EPSILON;
+ double damping = -(probe.force[axis] - wrench.force[axis]) / VELOCITY_EPSILON;
+ if (damping > 0.0) {
+ wrench.force[axis] /= 1.0 + dt * damping / ship.getMass();
+ }
+
+ w[axis] += ANGULAR_EPSILON;
+ evaluate(frame, centerOfMass, v, w, improved, false, probe);
+ w[axis] -= ANGULAR_EPSILON;
+ double angularDamping = -(probe.totalTorque(axis) - wrench.totalTorque(axis)) / ANGULAR_EPSILON;
+ if (angularDamping > 0.0 && diagonal[axis] > 0.0) {
+ double factor = 1.0 / (1.0 + dt * angularDamping / diagonal[axis]);
+ wrench.forceTorque[axis] *= factor;
+ wrench.torque[axis] *= factor;
+ }
+ }
+ }
+
+ // Same frames and scaling as Warium applies its forces in
+ double scale = transform.getShipToWorldScaling().x();
+ double forceScale = autoScale ? scale * scale * scale : 1.0;
+ Vector3d force = new Vector3d(wrench.force[0], wrench.force[1], wrench.force[2]).mul(forceScale);
+ Vector3d forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
+ if (force.isFinite() && forceTorque.isFinite()) {
+ ship.applyInvariantForce(rotation.transform(force));
+ ship.applyInvariantTorque(rotation.transform(forceTorque));
+ }
+ Vector3d torque = new Vector3d(wrench.torque[0], wrench.torque[1], wrench.torque[2]);
+ if (torque.lengthSquared() > 0.0 && torque.isFinite()) {
+ ship.applyRotDependentTorque(torque);
+ }
+ }
+
+ private void switchFrame(Frame frame) {
+ // Carry stall state over by block, the surface order changes from tick to tick
+ Long2BooleanOpenHashMap previous = new Long2BooleanOpenHashMap();
+ if (current != null) {
+ for (int i = 0; i < current.surfaces.length; i++) {
+ if (stalled[i]) {
+ previous.put(current.surfaces[i].key(), true);
+ }
+ }
+ }
+ stalled = new boolean[frame.surfaces.length];
+ 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));
+ for (NodeData node : frame.nodes.values()) {
+ nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node));
+ }
+ current = frame;
+ }
+
+ /**
+ * Sums the forces of all surfaces into out.
+ *
+ * @param commit whether this is the real evaluation of the substep (updates stall state and trim response)
+ * rather than a probe for the damping derivatives
+ */
+ private void evaluate(Frame frame, Vector3dc centerOfMass, double[] v, double[] w, boolean improved, boolean commit, Wrench out) {
+ out.clear();
+ double weight = clock.weight();
+ double liftConstant = ImprovedAero.liftConstant(Config.DESIGN_SPEED.get());
+ double stallAngleScale = Config.STALL_ANGLE_SCALE.get();
+ double[] r = new double[3];
+ if (commit) {
+ wingStalled = false;
+ for (NodeState state : nodeStates.values()) {
+ state.pitchResponseBuilding.zero();
+ }
+ }
+
+ for (int i = 0; i < frame.surfaces.length; i++) {
+ AeroSurface s = frame.surfaces[i];
+ r[0] = s.x() - centerOfMass.x();
+ r[1] = s.y() - centerOfMass.y();
+ r[2] = s.z() - centerOfMass.z();
+ NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null;
+ NodeState state = node != null ? nodeStates.get(node.key()) : null;
+ double input = s.type().controllable ? input(s, node, state, 0.0) : 0.0;
+
+ if (improved) {
+ boolean nowStalled = ImprovedAero.evaluate(s, v, w, r, input, weight, liftConstant, stallAngleScale, stalled[i], out);
+ if (commit) {
+ stalled[i] = nowStalled;
+ wingStalled |= nowStalled && s.type() == SurfaceType.WING;
+ }
+ } else {
+ WariumAero.evaluate(s, v, w, r, input, weight, out);
+ }
+
+ // How much a little more pitch input on this node would change the torque, for auto trim
+ if (commit && node != null && node.autoTrim() && s.type().controllable && s.listensToPitch()) {
+ double delta = 0.05;
+ probe.clear();
+ double base = 0.0;
+ if (improved) {
+ ImprovedAero.evaluate(s, v, w, r, input, weight, liftConstant, stallAngleScale, stalled[i], probe);
+ } else {
+ WariumAero.evaluate(s, v, w, r, input, weight, probe);
+ }
+ double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)};
+ probe.clear();
+ double nudged = input(s, node, state, delta);
+ if (improved) {
+ ImprovedAero.evaluate(s, v, w, r, nudged, weight, liftConstant, stallAngleScale, stalled[i], probe);
+ } else {
+ WariumAero.evaluate(s, v, w, r, nudged, weight, probe);
+ }
+ state.pitchResponseBuilding.add(
+ (probe.totalTorque(0) - before[0]) / delta,
+ (probe.totalTorque(1) - before[1]) / delta,
+ (probe.totalTorque(2) - before[2]) / delta);
+ }
+ }
+
+ if (commit) {
+ for (NodeState state : nodeStates.values()) {
+ state.pitchResponse.set(state.pitchResponseBuilding);
+ }
+ }
+ }
+
+ /** A control surface's input, combined from its two keys and clamped like Warium does. */
+ private static double input(AeroSurface s, NodeData node, NodeState state, double extraPitch) {
+ if (node == null || state == null) {
+ return 0.0;
+ }
+ double value = axisValue(s.axis1(), node, state, extraPitch) * s.multiplier1()
+ + axisValue(s.axis2(), node, state, extraPitch) * s.multiplier2();
+ return Math.max(-1.0, Math.min(1.0, value));
+ }
+
+ private static double axisValue(ControlAxis axis, NodeData node, NodeState state, double extraPitch) {
+ return switch (axis) {
+ case PITCH -> state.pitch + state.trimOutput + extraPitch;
+ case YAW -> state.yaw;
+ case ROLL -> state.roll;
+ case THROTTLE -> node.throttle();
+ case NONE -> 0.0;
+ };
+ }
+
+ private void updateNodes(Frame frame, Vector3dc worldVelocity, Quaterniondc rotation, Matrix3dc inertia,
+ double[] w, double dt, boolean improved) {
+ double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY;
+ for (NodeData node : frame.nodes.values()) {
+ NodeState state = nodeStates.get(node.key());
+ if (state == null) {
+ continue;
+ }
+ state.pitch = moveTowards(state.pitch, node.pitch(), step);
+ state.yaw = moveTowards(state.yaw, node.yaw(), step);
+ state.roll = moveTowards(state.roll, node.roll(), step);
+ AutoTrim.update(state, node, worldVelocity, rotation, inertia, w, dt, improved && wingStalled);
+ }
+ }
+
+ static double moveTowards(double value, double target, double maxStep) {
+ if (Math.abs(target - value) <= maxStep) {
+ return target;
+ }
+ return value + Math.copySign(maxStep, target - value);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java
new file mode 100644
index 0000000..7ce4557
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java
@@ -0,0 +1,34 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * One Warium aerodynamic block as seen in the latest tick, everything the physics thread needs to compute its
+ * forces from the ship's current motion. Positions are ship coordinates.
+ *
+ * @param key BlockPos.asLong of the block
+ * @param x block center
+ * @param facingX Warium's "facing" of the block, the chord direction of the surface
+ * @param pressure Warium's air pressure factor at the block's altitude
+ * @param fluidFactor Warium's multiplier for blocks under water (1 in air)
+ * @param speedMultiplier Warium's aerodynamicSpeedMultiplier game rule / 100, at least 1
+ * @param node BlockPos.asLong of the Vehicle Control Node the surface listens to, {@link #NO_NODE} if none
+ * @param brakeInput air brakes only: Warium's input value, they ignore smoothing and trim
+ */
+public record AeroSurface(long key, SurfaceType type, boolean slab, double x, double y, double z, int facingX, int facingZ,
+ double pressure, double fluidFactor, double speedMultiplier,
+ long node, ControlAxis axis1, double multiplier1, ControlAxis axis2, double multiplier2,
+ double brakeInput) {
+ public static final long NO_NODE = Long.MIN_VALUE;
+
+ /** Warium's "Direction.getClockWise(Axis.Y)" of the facing, the span direction of a horizontal surface. */
+ int rightX() {
+ return -facingZ;
+ }
+
+ int rightZ() {
+ return facingX;
+ }
+
+ boolean listensToPitch() {
+ return node != NO_NODE && (axis1 == ControlAxis.PITCH || axis2 == ControlAxis.PITCH);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrim.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrim.java
new file mode 100644
index 0000000..5dd765a
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrim.java
@@ -0,0 +1,74 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import dev.lumareth.improvedAerodynamics.Config;
+import org.joml.Matrix3dc;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+/**
+ * Auto trim for the pitch control surfaces of one control node: while the pilot gives no pitch input it adds a
+ * pitch offset that brings the flight path back to level and then holds it there, cancelling whatever pitching
+ * moment the craft has (off-center weight, thrust line, speed changes).
+ *
+ * Two loops: the flight path angle sets a gentle target pitch rate, and a PI controller moves the trim to reach it.
+ * The trim's effect is measured every substep (torque per unit of pitch input), which gives the pitch axis and lets
+ * the gains be expressed as angular acceleration, so the same tuning works for any craft, speed and altitude.
+ * The trim holds still while the pilot pitches, when too slow, and when wings stall, so it never fights maneuvers.
+ */
+final class AutoTrim {
+ // Target pitch rate per radian of flight path angle, and its limit (rad/s)
+ private static final double PATH_GAIN = 0.8;
+ private static final double MAX_PITCH_RATE = 0.1;
+ // Angular acceleration per rad/s of pitch rate error, and integral gain
+ private static final double PROPORTIONAL_GAIN = 3.0;
+ private static final double INTEGRAL_GAIN = 1.5;
+ // Fastest the trim moves, in input per second
+ private static final double TRIM_RATE = 1.0;
+ private static final double PILOT_DEADBAND = 0.01;
+
+ private AutoTrim() {
+ }
+
+ static void update(AeroState.NodeState state, NodeData node, Vector3dc worldVelocity, Quaterniondc rotation,
+ Matrix3dc inertia, double[] w, double dt, boolean stalled) {
+ if (!node.autoTrim()) {
+ state.trimIntegral = 0.0;
+ state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt);
+ state.savedTrim = 0.0;
+ return;
+ }
+ double authority = Config.TRIM_AUTHORITY.get();
+ double response = state.pitchResponse.length();
+ double speed = worldVelocity.length();
+ boolean hold = Math.abs(node.pitch()) > PILOT_DEADBAND || speed < Config.TRIM_MIN_SPEED.get() || stalled
+ || response < 1.0E-6;
+
+ double target = state.trimIntegral;
+ if (!hold) {
+ // Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does
+ Vector3d axis = state.pitchResponse.div(response, new Vector3d());
+ double axisInertia = axis.dot(inertia.transform(axis, new Vector3d()));
+ double pitchRate = axis.x * w[0] + axis.y * w[1] + axis.z * w[2];
+
+ // Does rotating about that axis raise the flight path? Sign of the vertical part of axis x direction
+ Vector3d worldAxis = rotation.transform(axis, new Vector3d());
+ Vector3d direction = worldVelocity.normalize(new Vector3d());
+ double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y);
+ double pathAngle = Math.asin(Math.max(-1.0, Math.min(1.0, direction.y)));
+
+ double targetRate = Math.max(-MAX_PITCH_RATE, Math.min(MAX_PITCH_RATE, -PATH_GAIN * pathAngle * noseUp));
+ double error = targetRate - pitchRate;
+ // Pitch input needed per rad/s² of angular acceleration
+ double gain = axisInertia / response;
+ state.trimIntegral = clamp(state.trimIntegral + INTEGRAL_GAIN * error * dt * gain, authority);
+ target = clamp(state.trimIntegral + PROPORTIONAL_GAIN * error * gain, authority);
+ }
+ state.trimOutput = AeroState.moveTowards(state.trimOutput, target, TRIM_RATE * dt);
+ state.savedTrim = state.trimIntegral;
+ }
+
+ private static double clamp(double value, double limit) {
+ return Math.max(-limit, Math.min(limit, value));
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java
new file mode 100644
index 0000000..58da24c
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java
@@ -0,0 +1,63 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import dev.lumareth.improvedAerodynamics.Config;
+import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
+import net.mcreator.valkyrienwarium.init.ValkyrienWariumModBlocks;
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.entity.BlockEntity;
+
+/** Server side of the auto trim key. The setting lives in the control node, so it stays with the craft. */
+public final class AutoTrimToggle {
+ private AutoTrimToggle() {
+ }
+
+ public static void toggle(ServerPlayer player) {
+ Entity vehicle = player.getVehicle();
+ if (!(vehicle instanceof SeatEntityEntity)) {
+ player.displayClientMessage(Component.translatable("message.improved_aerodynamics.auto_trim.no_seat"), true);
+ return;
+ }
+ BlockEntity node = findNode(vehicle.level(), vehicle.blockPosition());
+ if (node == null) {
+ player.displayClientMessage(Component.translatable("message.improved_aerodynamics.auto_trim.no_node"), true);
+ return;
+ }
+
+ CompoundTag tag = node.getPersistentData();
+ boolean enabled = !tag.getBoolean(WariumCapture.AUTO_TRIM_TAG);
+ tag.putBoolean(WariumCapture.AUTO_TRIM_TAG, enabled);
+ tag.putDouble(WariumCapture.TRIM_TAG, 0.0);
+ node.setChanged();
+
+ if (enabled && !Config.PHYSICS_RATE_AERODYNAMICS.get()) {
+ player.displayClientMessage(Component.translatable("message.improved_aerodynamics.auto_trim.unavailable"), true);
+ } else {
+ player.displayClientMessage(Component.translatable(enabled
+ ? "message.improved_aerodynamics.auto_trim.on" : "message.improved_aerodynamics.auto_trim.off"), true);
+ }
+ }
+
+ /**
+ * The control node the seat under the player is linked to. Warium's control seat block finds its seat entity
+ * within half a block of itself, so the seat block is at the entity's block or just below it.
+ */
+ private static BlockEntity findNode(Level level, BlockPos seatPos) {
+ for (BlockPos pos : new BlockPos[]{seatPos, seatPos.below()}) {
+ BlockEntity seat = level.getBlockEntity(pos);
+ if (seat == null || !seat.getPersistentData().contains("ControlX")) {
+ continue;
+ }
+ CompoundTag tag = seat.getPersistentData();
+ BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
+ if (level.getBlockState(nodePos).is(ValkyrienWariumModBlocks.VEHICLE_CONTROL_NODE.get())) {
+ return level.getBlockEntity(nodePos);
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/ControlAxis.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ControlAxis.java
new file mode 100644
index 0000000..88401f6
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ControlAxis.java
@@ -0,0 +1,28 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/** The pilot input a Warium control block follows, set with its Key/Key2 (e.g. "Pitch+"). */
+public enum ControlAxis {
+ NONE,
+ PITCH,
+ YAW,
+ ROLL,
+ THROTTLE;
+
+ /** Axis of a Warium key name, the +/- suffix is the multiplier, see {@link #multiplier(String)}. */
+ public static ControlAxis of(String key) {
+ if (key.startsWith("Pitch")) {
+ return PITCH;
+ } else if (key.startsWith("Yaw")) {
+ return YAW;
+ } else if (key.startsWith("Roll")) {
+ return ROLL;
+ } else if (key.startsWith("Throttle")) {
+ return THROTTLE;
+ }
+ return NONE;
+ }
+
+ public static double multiplier(String key) {
+ return key.endsWith("-") ? -1.0 : 1.0;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/ForceSmoother.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ForceSmoother.java
new file mode 100644
index 0000000..77d4125
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ForceSmoother.java
@@ -0,0 +1,125 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import net.minecraft.core.BlockPos;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+import org.valkyrienskies.core.api.ships.PhysShip;
+import org.valkyrienskies.core.api.ships.properties.ShipTransform;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Replacement for the force queue of Warium's (and Futurism's) ForceInducedShips. That queue is emptied by the
+ * first physics substep after each tick, so every force acts in one substep out of three. Here a tick's forces are
+ * summed on the server thread and then applied in equal parts in every substep until the next tick, which keeps the
+ * total push per tick identical.
+ *
+ * Forces are summed as a force plus a torque about the block grid origin, then moved to the center of mass when
+ * applied, which is exactly equivalent to applying each force at its own block.
+ */
+public final class ForceSmoother implements PhysicsBridge.Publishable {
+ private final SubstepClock clock;
+ private Snapshot building = new Snapshot();
+ private volatile Snapshot published;
+
+ /** One tick's forces, in the same frames and units the original code applies them in. */
+ private static final class Snapshot {
+ long tick;
+ boolean empty = true;
+ // ForceMode.POSITION with ship directions: sum of forces and sum of (block center x force), ship coordinates
+ final Vector3d shipForce = new Vector3d();
+ final Vector3d shipMoment = new Vector3d();
+ // ForceMode.POSITION with world directions: {block center x, y, z, force x, y, z}, rare (drag handle)
+ final List worldForcesAtBlocks = new ArrayList<>(0);
+ // ForceMode.GLOBAL forces, applied at the center of mass
+ final Vector3d globalShipForce = new Vector3d();
+ final Vector3d globalWorldForce = new Vector3d();
+ // Rotations: ForceMode.POSITION ones are ship relative, ForceMode.GLOBAL ones world relative
+ final Vector3d shipTorque = new Vector3d();
+ final Vector3d worldTorque = new Vector3d();
+ }
+
+ public ForceSmoother(SubstepClock clock) {
+ this.clock = clock;
+ }
+
+ /** Server thread. Same arguments as ForceInducedShips.addForce, with its enums turned into flags. */
+ public synchronized void addForce(BlockPos pos, Vector3dc dir, double throttle, boolean global, boolean shipDirection) {
+ if (throttle == 0.0) {
+ return;
+ }
+ Vector3d force = dir.mul(throttle, new Vector3d());
+ if (global) {
+ (shipDirection ? building.globalShipForce : building.globalWorldForce).add(force);
+ } else if (shipDirection) {
+ Vector3d center = new Vector3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
+ building.shipForce.add(force);
+ building.shipMoment.add(center.cross(force));
+ } else {
+ building.worldForcesAtBlocks.add(new double[]{pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
+ force.x, force.y, force.z});
+ }
+ markBuilding();
+ }
+
+ /** Server thread. Same arguments as ForceInducedShips.addRotation. */
+ public synchronized void addRotation(Vector3dc rot, double throttle, boolean global) {
+ (global ? building.worldTorque : building.shipTorque).add(rot.mul(throttle, new Vector3d()));
+ markBuilding();
+ }
+
+ private void markBuilding() {
+ if (building.empty) {
+ building.empty = false;
+ PhysicsBridge.touch(this);
+ }
+ }
+
+ @Override
+ public synchronized void publish(long tick) {
+ building.tick = tick;
+ published = building;
+ building = new Snapshot();
+ }
+
+ /** Physics thread, once per substep. Applies this substep's share of the latest tick's forces. */
+ public void apply(PhysShip ship, boolean autoScale) {
+ Snapshot snapshot = published;
+ if (snapshot == null || snapshot.tick < PhysicsBridge.publishedTick() || clock.stale()) {
+ return;
+ }
+ ShipTransform transform = ship.getTransform();
+ Quaterniondc rotation = transform.getShipToWorldRotation();
+ Vector3dc centerOfMass = transform.getPositionInShip();
+ double scale = transform.getShipToWorldScaling().x();
+ double weight = clock.weight();
+ // Warium's autoScale: forces grow with the cube of the ship's scale, global torques with its fifth power
+ double forceWeight = autoScale ? weight * scale * scale * scale : weight;
+ double torqueWeight = autoScale ? forceWeight * scale * scale : weight;
+
+ if (snapshot.shipForce.lengthSquared() > 0.0 || snapshot.shipMoment.lengthSquared() > 0.0) {
+ // Moment about the center of mass: sum(p x F) - com x sum(F)
+ Vector3d moment = snapshot.shipMoment.sub(centerOfMass.cross(snapshot.shipForce, new Vector3d()), new Vector3d());
+ ship.applyInvariantForce(rotation.transform(snapshot.shipForce.mul(forceWeight, new Vector3d())));
+ ship.applyInvariantTorque(rotation.transform(moment.mul(forceWeight)));
+ }
+ for (double[] entry : snapshot.worldForcesAtBlocks) {
+ Vector3d force = new Vector3d(entry[3], entry[4], entry[5]).mul(forceWeight);
+ ship.applyInvariantForceToPos(force, new Vector3d(entry[0], entry[1], entry[2]).sub(centerOfMass));
+ }
+ if (snapshot.globalShipForce.lengthSquared() > 0.0) {
+ ship.applyInvariantForce(rotation.transform(snapshot.globalShipForce.mul(forceWeight, new Vector3d())));
+ }
+ if (snapshot.globalWorldForce.lengthSquared() > 0.0) {
+ ship.applyInvariantForce(snapshot.globalWorldForce.mul(forceWeight, new Vector3d()));
+ }
+ if (snapshot.shipTorque.lengthSquared() > 0.0) {
+ ship.applyRotDependentTorque(snapshot.shipTorque.mul(weight, new Vector3d()));
+ }
+ if (snapshot.worldTorque.lengthSquared() > 0.0) {
+ ship.applyInvariantTorque(snapshot.worldTorque.mul(torqueWeight, new Vector3d()));
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java
new file mode 100644
index 0000000..f9498b5
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java
@@ -0,0 +1,131 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Flight model based on each surface's angle of attack, used instead of {@link WariumAero} when enabled.
+ *
+ * Every block is treated as one unit of wing area that sees the airflow at its own position, including the part
+ * caused by the ship's rotation. Lift is {@code K * pressure * airspeed² * CL(angle of attack)}: slow, heavy or
+ * high-g flight needs a higher angle of attack for the same lift until a surface reaches its critical angle and
+ * stalls. Stalls are per surface, soft (lift fades over a few degrees into flat-plate behaviour) and sticky (a
+ * stalled surface only recovers a few degrees below its critical angle). Rotational damping is not added
+ * separately, it comes from the surfaces' changing angle of attack while rotating, so it vanishes for stalled
+ * surfaces and thrust vectoring can hold the nose past the stall.
+ *
+ * K is chosen so that a level wing at the configured design speed makes exactly Warium's lift.
+ */
+final class ImprovedAero {
+ // Lift slope of a finite wing per radian, about 80% of thin airfoil theory's 2 pi
+ private static final double LIFT_SLOPE = 2.0 * Math.PI * 0.8;
+ private static final double INDUCED_DRAG = 0.05;
+ // Fully separated flow behaves like a flat plate
+ private static final double SEPARATED_LIFT = 1.1;
+ private static final double SEPARATED_DRAG = 1.8;
+ private static final double STALL_BLEND = Math.toRadians(2.0);
+ private static final double STALL_HYSTERESIS = Math.toRadians(3.0);
+ private static final double MAX_DEFLECTION = Math.toRadians(20.0);
+ // Share of the deflection that acts as angle of attack, gives Warium's control authority at the design speed
+ private static final double FLAP_EFFECTIVENESS = 0.75;
+ // Angle of attack of a level cambered wing, where K is calibrated
+ private static final double REFERENCE_ANGLE = Math.toRadians(4.0);
+
+ private ImprovedAero() {
+ }
+
+ /** Lift constant K for the given design speed. */
+ static double liftConstant(double designSpeed) {
+ return 160.0 / (designSpeed * LIFT_SLOPE * REFERENCE_ANGLE);
+ }
+
+ /**
+ * Adds the surface's forces to out.
+ *
+ * @param v ship velocity, ship axes
+ * @param w ship angular velocity, ship axes
+ * @param r block center relative to the center of mass, ship axes
+ * @param input control input of the surface, -1 to 1
+ * @param stalled whether the surface was stalled in the previous substep
+ * @return whether the surface is stalled now
+ */
+ static boolean evaluate(AeroSurface s, double[] v, double[] w, double[] r, double input, double weight,
+ double liftConstant, double stallAngleScale, boolean stalled, Wrench out) {
+ // Airflow at the block: ship velocity plus w x r
+ double vx = v[0] + w[1] * r[2] - w[2] * r[1];
+ double vy = v[1] + w[2] * r[0] - w[0] * r[2];
+ double vz = v[2] + w[0] * r[1] - w[1] * r[0];
+ double localSpeed = Math.sqrt(vx * vx + vy * vy + vz * vz);
+ double p = s.pressure() * weight;
+ double m = s.speedMultiplier();
+ double fd = s.fluidFactor();
+
+ if (s.type() == SurfaceType.AIR_BRAKE) {
+ double ds = -(s.facingX() * vx + s.facingZ() * vz);
+ double brake = -Math.abs(ds * 100.0 * s.brakeInput() * p * fd);
+ out.addForce(r, vx * brake, vy * brake, vz * brake);
+ return false;
+ }
+
+ // Warium's own per-block drag
+ double parasitic = -localSpeed * s.type().parasiticDrag * p / m;
+ out.addForce(r, vx * parasitic, vy * parasitic, vz * parasitic);
+
+ // Chord c along the facing, normal n up for horizontal surfaces and to the side for vertical ones
+ double cx = s.facingX();
+ double cz = s.facingZ();
+ double nx = s.type().vertical ? s.rightX() : 0.0;
+ double ny = s.type().vertical ? 0.0 : 1.0;
+ double nz = s.type().vertical ? s.rightZ() : 0.0;
+ double u = vx * cx + vz * cz;
+ double vn = vx * nx + vy * ny + vz * nz;
+ double planeSpeedSq = u * u + vn * vn;
+ if (planeSpeedSq < 1.0E-4) {
+ return stalled;
+ }
+ double planeSpeed = Math.sqrt(planeSpeedSq);
+
+ // Moving down through the air (vn < 0) means air hits the underside: positive angle of attack.
+ // Surfaces work the same with air coming from either end of the chord, like in Warium
+ double alpha = Math.atan2(-vn, Math.abs(u));
+ alpha += Math.toRadians(s.type().camber);
+ if (s.type().controllable) {
+ // Same sign convention as Warium: positive input pushes along n while moving against the facing
+ alpha += FLAP_EFFECTIVENESS * MAX_DEFLECTION * input * (u <= 0.0 ? 1.0 : -1.0);
+ }
+ alpha = Math.max(-Math.PI / 2.0, Math.min(Math.PI / 2.0, alpha));
+
+ double critical = Math.toRadians(s.slab() ? SurfaceType.SLAB_STALL_ANGLE : s.type().stallAngle) * stallAngleScale;
+ double absAlpha = Math.abs(alpha);
+ // Flips only where both blends agree, so the hysteresis never makes the force jump
+ if (!stalled && absAlpha > critical + STALL_BLEND) {
+ stalled = true;
+ } else if (stalled && absAlpha < critical - STALL_HYSTERESIS - STALL_BLEND) {
+ stalled = false;
+ }
+ double center = stalled ? critical - STALL_HYSTERESIS : critical;
+ double attached = 1.0 - smoothstep(center - STALL_BLEND, center + STALL_BLEND, absAlpha);
+
+ double attachedLift = LIFT_SLOPE * alpha;
+ double lift = attached * attachedLift + (1.0 - attached) * SEPARATED_LIFT * Math.sin(2.0 * alpha);
+ double drag = attached * INDUCED_DRAG * attachedLift * attachedLift
+ + (1.0 - attached) * SEPARATED_DRAG * Math.sin(alpha) * Math.sin(alpha);
+
+ double q = liftConstant * p * fd * planeSpeedSq;
+ // Lift is perpendicular to the airflow in the chord/normal plane, on the side of n
+ double side = u < 0.0 ? -1.0 : 1.0;
+ double lx = (Math.abs(u) * nx - side * vn * cx) / planeSpeed;
+ double ly = Math.abs(u) * ny / planeSpeed;
+ double lz = (Math.abs(u) * nz - side * vn * cz) / planeSpeed;
+ // Drag opposes the airflow in that plane
+ double dx = -(u * cx + vn * nx) / planeSpeed;
+ double dy = -(vn * ny) / planeSpeed;
+ double dz = -(u * cz + vn * nz) / planeSpeed;
+ double liftForce = q * lift;
+ double dragForce = q * drag / m;
+ out.addForce(r, lx * liftForce + dx * dragForce, ly * liftForce + dy * dragForce, lz * liftForce + dz * dragForce);
+ return stalled;
+ }
+
+ private static double smoothstep(double edge0, double edge1, double x) {
+ double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
+ return t * t * (3.0 - 2.0 * t);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java
new file mode 100644
index 0000000..d5f11d4
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java
@@ -0,0 +1,79 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import net.minecraftforge.fml.loading.LoadingModList;
+import net.minecraftforge.fml.loading.moddiscovery.ModFileInfo;
+import org.objectweb.asm.tree.ClassNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
+import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Applies the Warium and Futurism mixins only when those mods are installed in the version they were written
+ * against. They hook into specific calls inside those mods, so another version is skipped with a warning rather
+ * than risking a crash; the rest of the mod keeps working.
+ */
+public final class MixinPlugin implements IMixinConfigPlugin {
+ private static final Logger LOGGER = LoggerFactory.getLogger("improved_aerodynamics");
+ private static final String WARIUM_VERSION = "1.1.6";
+ private static final String FUTURISM_VERSION = "0.0.4";
+
+ private boolean warium;
+ private boolean futurism;
+
+ @Override
+ public void onLoad(String mixinPackage) {
+ warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS");
+ futurism = supported("futurism", FUTURISM_VERSION, "Futurism");
+ }
+
+ private static boolean supported(String modId, String version, String name) {
+ ModFileInfo file = LoadingModList.get().getModFileById(modId);
+ if (file == null) {
+ return false;
+ }
+ String installed = file.getMods().get(0).getVersion().toString();
+ if (!installed.equals(version)) {
+ LOGGER.warn("{} {} is installed, but the flight physics changes are made for {} {}, they are disabled for it",
+ name, installed, name, version);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
+ if (mixinClassName.contains(".mixin.warium.")) {
+ return warium;
+ }
+ if (mixinClassName.contains(".mixin.futurism.")) {
+ return futurism;
+ }
+ return true;
+ }
+
+ @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 targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+ }
+
+ @Override
+ public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/NodeData.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/NodeData.java
new file mode 100644
index 0000000..bc5e0a8
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/NodeData.java
@@ -0,0 +1,10 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Pilot inputs a Vehicle Control Node held in the latest tick (copied there from its control seat), plus its auto
+ * trim state.
+ *
+ * @param storedTrim trim saved in the node, used when the physics thread first sees the node
+ */
+public record NodeData(long key, double pitch, double yaw, double roll, double throttle, boolean autoTrim, double storedTrim) {
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/PhysicsBridge.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/PhysicsBridge.java
new file mode 100644
index 0000000..422ab04
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/PhysicsBridge.java
@@ -0,0 +1,63 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import net.minecraftforge.event.TickEvent;
+import net.minecraftforge.event.server.ServerStoppedEvent;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.Set;
+
+/**
+ * Hands what the server thread collected during a tick over to the physics thread in one piece, at the end of the
+ * tick. Warium pushes its forces while the physics thread is already consuming them, so without this a tick's
+ * forces can end up split over several substeps or merged with the next tick's.
+ */
+public final class PhysicsBridge {
+ /** Something that collects data on the server thread and hands it to the physics thread at the end of each tick. */
+ public interface Publishable {
+ void publish(long tick);
+ }
+
+ private static final Set TOUCHED = Collections.newSetFromMap(new IdentityHashMap<>());
+ private static long tick;
+ private static volatile long publishedTick;
+
+ private PhysicsBridge() {
+ }
+
+ /** Marks a collector as having data for this tick. */
+ public static synchronized void touch(Publishable publishable) {
+ TOUCHED.add(publishable);
+ }
+
+ /**
+ * Tick number of the latest handover. Data published at an older tick is outdated: its ship received nothing in
+ * the latest tick, so nothing should be applied anymore.
+ */
+ public static long publishedTick() {
+ return publishedTick;
+ }
+
+ @SubscribeEvent
+ public static void onServerTick(TickEvent.ServerTickEvent event) {
+ if (event.phase != TickEvent.Phase.END) {
+ return;
+ }
+ synchronized (PhysicsBridge.class) {
+ tick++;
+ for (Publishable publishable : TOUCHED) {
+ publishable.publish(tick);
+ }
+ TOUCHED.clear();
+ publishedTick = tick;
+ }
+ }
+
+ @SubscribeEvent
+ public static void onServerStopped(ServerStoppedEvent event) {
+ synchronized (PhysicsBridge.class) {
+ TOUCHED.clear();
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/SubstepClock.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/SubstepClock.java
new file mode 100644
index 0000000..060576d
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/SubstepClock.java
@@ -0,0 +1,44 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Tracks how many physics substeps run per game tick for one ship (VS's physicsTicksPerGameTick, 3 by default),
+ * measured rather than read from VS's config so it also holds while the server or the physics thread lags.
+ * Only used on the physics thread.
+ */
+public final class SubstepClock {
+ private static final double TICK_SECONDS = 0.05;
+ // Keep applying the last tick's data for this many ticks while the server lags, then stop
+ private static final int MAX_HOLD_TICKS = 10;
+
+ private long lastTick = -1;
+ private int substepsSinceTick;
+ private double substepsPerTick = 3.0;
+
+ /** Call once at the start of every physics substep. */
+ public void step() {
+ long tick = PhysicsBridge.publishedTick();
+ if (tick != lastTick) {
+ if (lastTick >= 0 && substepsSinceTick > 0 && substepsSinceTick <= 20) {
+ substepsPerTick += (substepsSinceTick - substepsPerTick) * 0.1;
+ }
+ lastTick = tick;
+ substepsSinceTick = 0;
+ }
+ substepsSinceTick++;
+ }
+
+ /** Share of a tick's force that one substep applies, so the total per tick stays what Warium intended. */
+ public double weight() {
+ return 1.0 / substepsPerTick;
+ }
+
+ /** Length of one substep in seconds. */
+ public double substepSeconds() {
+ return TICK_SECONDS / substepsPerTick;
+ }
+
+ /** The server has not delivered a tick for so long that its last forces should no longer be applied. */
+ public boolean stale() {
+ return substepsSinceTick > MAX_HOLD_TICKS * substepsPerTick;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/SurfaceType.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/SurfaceType.java
new file mode 100644
index 0000000..b38e242
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/SurfaceType.java
@@ -0,0 +1,35 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Kinds of Warium aerodynamic blocks, one per Warium procedure. The numbers are only used by the improved flight
+ * model: the angle of attack (degrees) at which the surface stalls, how far its airfoil's lift is shifted towards
+ * positive angles (Warium calls wings asymmetric aerofoils, stabilizers symmetric ones) and its parasitic drag
+ * coefficient, which is Warium's own per-block drag.
+ */
+public enum SurfaceType {
+ WING(false, false, 16.0, 4.0, 1.0),
+ STABILIZER(false, false, 14.0, 0.0, 1.0),
+ VERTICAL_WING(true, false, 15.0, 0.0, 0.5),
+ CONTROL_SURFACE(false, true, 15.0, 0.0, 0.0),
+ VERTICAL_CONTROL_SURFACE(true, true, 15.0, 0.0, 0.0),
+ AIR_BRAKE(false, false, 0.0, 0.0, 0.0);
+
+ // Slab variants are thinner and stall earlier
+ static final double SLAB_STALL_ANGLE = 12.0;
+
+ /** Surface stands upright, so its lift acts sideways. */
+ final boolean vertical;
+ /** Deflects with pilot input. */
+ final boolean controllable;
+ final double stallAngle;
+ final double camber;
+ final double parasiticDrag;
+
+ SurfaceType(boolean vertical, boolean controllable, double stallAngle, double camber, double parasiticDrag) {
+ this.vertical = vertical;
+ this.controllable = controllable;
+ this.stallAngle = stallAngle;
+ this.camber = camber;
+ this.parasiticDrag = parasiticDrag;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java
new file mode 100644
index 0000000..312dbf0
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java
@@ -0,0 +1,78 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Warium VS 1.1.6's aerodynamic forces, term for term as its block procedures compute them, but evaluated from the
+ * ship's current motion. Like in Warium every block uses the velocity of the ship's center of mass. The procedures'
+ * conditions (air around the block, minimum speed, linked control node) were already checked on the server thread.
+ * Quirks are kept on purpose so craft fly exactly as before: vertical wings have no drag (Warium never sets the
+ * pressure that term uses), vertical control surface damping and air brakes ignore the speed multiplier.
+ */
+final class WariumAero {
+ private WariumAero() {
+ }
+
+ /**
+ * @param v ship velocity, ship axes
+ * @param w ship angular velocity, ship axes
+ * @param r block center relative to the center of mass, ship axes
+ * @param input control input of the surface, already combined from its keys and clamped like Warium does
+ * @param weight share of a tick's force this substep applies
+ */
+ static void evaluate(AeroSurface s, double[] v, double[] w, double[] r, double input, double weight, Wrench out) {
+ double speed = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+ double p = s.pressure() * weight;
+ double m = s.speedMultiplier();
+ double fd = s.fluidFactor();
+ int fx = s.facingX();
+ int fz = s.facingZ();
+ // Speed along the facing, Warium's "directionalspeed" of control surfaces and air brakes
+ double ds = -(fx * v[0] + fz * v[2]);
+
+ switch (s.type()) {
+ case WING -> {
+ out.addForce(r, 0.0, v[1] * Math.abs(v[1]) * -45.0 * p / m, 0.0);
+ out.addForce(r, 0.0, Math.abs(fx * v[0] + fz * v[2]) * 160.0 * p, 0.0);
+ double damping = speed * 600.0 * p / m;
+ out.addTorque(-w[0] * damping, -0.1 * w[1] * damping, -w[2] * damping);
+ double drag = -speed * p / m;
+ out.addForce(r, v[0] * drag, v[1] * drag, v[2] * drag);
+ }
+ case STABILIZER -> {
+ out.addForce(r, 0.0, v[1] * Math.abs(v[1]) * -45.0 * p / m, 0.0);
+ double damping = speed * 600.0 * p / m;
+ out.addTorque(-w[0] * damping, 0.0, -w[2] * damping);
+ double drag = -speed * p / m;
+ out.addForce(r, v[0] * drag, v[1] * drag, v[2] * drag);
+ }
+ case VERTICAL_WING -> {
+ sideForce(s, v, r, p, m, fd, out);
+ double damping = speed * 900.0 * p / m;
+ out.addTorque(-0.1 * w[0] * damping, -w[1] * damping, -0.1 * w[2] * damping);
+ }
+ case CONTROL_SURFACE -> {
+ out.addForce(r, 0.0, ds * 600.0 * input * p * fd / m, 0.0);
+ double damping = Math.abs(v[1]) * -90.0 * p / m;
+ out.addForce(r, 0.1 * v[0] * damping, v[1] * damping, 0.1 * v[2] * damping);
+ }
+ case VERTICAL_CONTROL_SURFACE -> {
+ double control = ds * 600.0 * input * p * fd / m;
+ out.addForce(r, s.rightX() * control, 0.0, s.rightZ() * control);
+ sideForce(s, v, r, p, m, fd, out);
+ double damping = speed * 600.0 * p;
+ out.addTorque(-0.1 * w[0] * damping, -w[1] * damping, -0.1 * w[2] * damping);
+ }
+ case AIR_BRAKE -> {
+ double brake = -Math.abs(ds * 100.0 * s.brakeInput() * p * fd);
+ out.addForce(r, v[0] * brake, v[1] * brake, v[2] * brake);
+ }
+ }
+ }
+
+ /** Vertical wings resisting sideways motion through them. */
+ private static void sideForce(AeroSurface s, double[] v, double[] r, double p, double m, double fd, Wrench out) {
+ int ax = Math.abs(s.rightX());
+ int az = Math.abs(s.rightZ());
+ double throttle = fd * (ax * (Math.abs(v[0]) + 0.1) + az * (Math.abs(v[2]) + 0.1)) * -40.0 * p / m;
+ out.addForce(r, v[0] * ax * throttle, 0.0, v[2] * az * throttle);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java
new file mode 100644
index 0000000..da487f9
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumCapture.java
@@ -0,0 +1,167 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+import dev.lumareth.improvedAerodynamics.Config;
+import net.mcreator.valkyrienwarium.init.ValkyrienWariumModBlocks;
+import net.mcreator.valkyrienwarium.init.ValkyrienWariumModGameRules;
+import net.mcreator.valkyrienwarium.procedures.PressureGetProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.LiquidBlock;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.state.properties.DirectionProperty;
+import net.minecraft.world.level.block.state.properties.EnumProperty;
+import net.minecraft.world.level.block.state.properties.Property;
+import net.minecraftforge.registries.ForgeRegistries;
+import org.joml.Vector3d;
+import org.valkyrienskies.core.api.ships.Ship;
+import org.valkyrienskies.mod.common.VSGameUtilsKt;
+
+/**
+ * Server thread side of the physics rate aerodynamics: the Warium procedure mixins call {@link #begin} when a
+ * procedure starts and {@link #capture} instead of each force it adds. A procedure only adds forces after passing
+ * all its conditions (air around the block, minimum speed, linked node), so a captured block is one whose forces
+ * apply this tick; its forces are then computed on the physics thread from the ship's current motion.
+ */
+public final class WariumCapture {
+ public static final String AUTO_TRIM_TAG = "improved_aerodynamics_autotrim";
+ public static final String TRIM_TAG = "improved_aerodynamics_trim";
+ private static final int TRIM_SAVE_INTERVAL = 20;
+
+ // The procedure currently running, procedures run one at a time on the server thread
+ private static SurfaceType type;
+ private static LevelAccessor world;
+ private static BlockPos pos;
+ private static BlockState state;
+
+ private WariumCapture() {
+ }
+
+ public static void begin(SurfaceType surfaceType, LevelAccessor level, double x, double y, double z, BlockState blockState) {
+ type = surfaceType;
+ world = level;
+ pos = BlockPos.containing(x, y, z);
+ state = blockState;
+ }
+
+ /**
+ * Called instead of a force the running procedure adds.
+ *
+ * @return true if the block was taken over, so the original force must not be added
+ */
+ public static boolean capture(ForceInducedShips inducer) {
+ if (!Config.PHYSICS_RATE_AERODYNAMICS.get() || !(world instanceof ServerLevel level)) {
+ return false;
+ }
+ // ForceInducedShips is final, so the compiler needs the detour through Object to accept the mixed-in interface
+ AeroState aero = ((AeroHolder) (Object) inducer).improvedAerodynamics$aero();
+ if (!aero.has(pos.asLong())) {
+ aero.add(createSurface(level, aero));
+ }
+ return true;
+ }
+
+ private static AeroSurface createSurface(ServerLevel level, AeroState aero) {
+ Direction facing = facing(state);
+ ResourceLocation id = ForgeRegistries.BLOCKS.getKey(state.getBlock());
+ boolean slab = id != null && id.getPath().contains("slab");
+ double speedMultiplier = Math.max(1, level.getGameRules().getInt(ValkyrienWariumModGameRules.AERODYNAMIC_SPEED_MULTIPLIER) / 100);
+ double pressure = PressureGetProcedure.execute(level, pos.getX(), pos.getY(), pos.getZ());
+
+ long node = AeroSurface.NO_NODE;
+ String key = "Null";
+ String key2 = "Null";
+ double brakeInput = 0.0;
+ if (type.controllable || type == SurfaceType.AIR_BRAKE) {
+ BlockEntity blockEntity = level.getBlockEntity(pos);
+ if (blockEntity != null) {
+ CompoundTag tag = blockEntity.getPersistentData();
+ key = tag.getString("Key");
+ key2 = type == SurfaceType.AIR_BRAKE ? "Null" : tag.getString("Key2");
+ BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
+ if (level.getBlockState(nodePos).is(ValkyrienWariumModBlocks.VEHICLE_CONTROL_NODE.get())) {
+ node = nodePos.asLong();
+ readNode(level, nodePos, aero);
+ if (type == SurfaceType.AIR_BRAKE) {
+ brakeInput = nodeValue(level, nodePos, key) * ControlAxis.multiplier(key);
+ }
+ }
+ }
+ }
+
+ return new AeroSurface(pos.asLong(), type, slab, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
+ facing.getStepX(), facing.getStepZ(), pressure, fluidFactor(level), speedMultiplier,
+ node, ControlAxis.of(key), ControlAxis.multiplier(key), ControlAxis.of(key2), ControlAxis.multiplier(key2),
+ brakeInput);
+ }
+
+ /** Registers a node's inputs once per tick, and saves its trim to it now and then so it survives reloading. */
+ private static void readNode(ServerLevel level, BlockPos nodePos, AeroState aero) {
+ long nodeKey = nodePos.asLong();
+ BlockEntity blockEntity = level.getBlockEntity(nodePos);
+ if (aero.hasNode(nodeKey) || blockEntity == null) {
+ return;
+ }
+ CompoundTag tag = blockEntity.getPersistentData();
+ boolean autoTrim = tag.getBoolean(AUTO_TRIM_TAG);
+ double storedTrim = tag.getDouble(TRIM_TAG);
+ if (autoTrim && level.getGameTime() % TRIM_SAVE_INTERVAL == 0) {
+ double trim = aero.trim(nodeKey);
+ if (!Double.isNaN(trim) && Math.abs(trim - storedTrim) > 1.0E-3) {
+ tag.putDouble(TRIM_TAG, trim);
+ blockEntity.setChanged();
+ storedTrim = trim;
+ }
+ }
+ aero.addNode(new NodeData(nodeKey, tag.getDouble("Pitch"), tag.getDouble("Yaw"), tag.getDouble("Roll"),
+ tag.getDouble("Throttle"), autoTrim, storedTrim));
+ }
+
+ /** Raw node value for a key like "Pitch+", as Warium's air brakes read it. */
+ private static double nodeValue(ServerLevel level, BlockPos nodePos, String key) {
+ BlockEntity blockEntity = level.getBlockEntity(nodePos);
+ if (blockEntity == null || key.isEmpty()) {
+ return 0.0;
+ }
+ String name = key.endsWith("+") || key.endsWith("-") ? key.substring(0, key.length() - 1) : key;
+ return blockEntity.getPersistentData().getDouble(name);
+ }
+
+ /** Warium's multiplier for surfaces under water, checked at the block's world position. */
+ private static double fluidFactor(ServerLevel level) {
+ double underWater = switch (type) {
+ case VERTICAL_WING -> 15.0;
+ case CONTROL_SURFACE, VERTICAL_CONTROL_SURFACE -> 80.0;
+ case AIR_BRAKE -> 40.0;
+ default -> 1.0;
+ };
+ if (underWater == 1.0) {
+ return 1.0;
+ }
+ Vector3d center = new Vector3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
+ Ship ship = VSGameUtilsKt.getShipManagingPos(level, pos);
+ if (ship != null) {
+ ship.getShipToWorld().transformPosition(center);
+ }
+ boolean inLiquid = level.getBlockState(BlockPos.containing(center.x, center.y, center.z)).getBlock() instanceof LiquidBlock;
+ return inLiquid ? underWater : 1.0;
+ }
+
+ /** Warium's way of reading a block's direction: its "facing", else the positive end of its "axis", else north. */
+ private static Direction facing(BlockState blockState) {
+ Property> facing = blockState.getBlock().getStateDefinition().getProperty("facing");
+ if (facing instanceof DirectionProperty directionProperty) {
+ return blockState.getValue(directionProperty);
+ }
+ Property> axis = blockState.getBlock().getStateDefinition().getProperty("axis");
+ if (axis instanceof EnumProperty> enumProperty && blockState.getValue(enumProperty) instanceof Direction.Axis value) {
+ return Direction.fromAxisAndDirection(value, Direction.AxisDirection.POSITIVE);
+ }
+ return Direction.NORTH;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/Wrench.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/Wrench.java
new file mode 100644
index 0000000..ad1082b
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/Wrench.java
@@ -0,0 +1,39 @@
+package dev.lumareth.improvedAerodynamics.flight;
+
+/**
+ * Sum of the forces and torques of a set of surfaces, in ship axes. Torques of forces are taken about the center of
+ * mass; pure torques (Warium's ship relative rotations) are kept apart since VS scales them differently.
+ */
+final class Wrench {
+ final double[] force = new double[3];
+ final double[] forceTorque = new double[3];
+ final double[] torque = new double[3];
+
+ void clear() {
+ for (int i = 0; i < 3; i++) {
+ force[i] = 0.0;
+ forceTorque[i] = 0.0;
+ torque[i] = 0.0;
+ }
+ }
+
+ /** Force on the surface's block, with r the block center relative to the center of mass. */
+ void addForce(double[] r, double x, double y, double z) {
+ force[0] += x;
+ force[1] += y;
+ force[2] += z;
+ forceTorque[0] += r[1] * z - r[2] * y;
+ forceTorque[1] += r[2] * x - r[0] * z;
+ forceTorque[2] += r[0] * y - r[1] * x;
+ }
+
+ void addTorque(double x, double y, double z) {
+ torque[0] += x;
+ torque[1] += y;
+ torque[2] += z;
+ }
+
+ double totalTorque(int axis) {
+ return forceTorque[axis] + torque[axis];
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/ForceInducedShipsMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/ForceInducedShipsMixin.java
new file mode 100644
index 0000000..82b1034
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/ForceInducedShipsMixin.java
@@ -0,0 +1,84 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.futurism;
+
+import com.mojang.datafixers.util.Pair;
+import dev.lumareth.improvedAerodynamics.Config;
+import dev.lumareth.improvedAerodynamics.flight.ForceSmoother;
+import dev.lumareth.improvedAerodynamics.flight.SubstepClock;
+import net.mcreator.futurism.ships.ForceData;
+import net.mcreator.futurism.ships.ForceDirectionMode;
+import net.mcreator.futurism.ships.ForceInducedShips;
+import net.mcreator.futurism.ships.ForceMode;
+import net.mcreator.futurism.ships.RotData;
+import net.minecraft.core.BlockPos;
+import org.joml.Vector3d;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.valkyrienskies.core.api.ships.PhysShip;
+import org.valkyrienskies.core.api.world.PhysLevel;
+
+import java.util.Stack;
+
+/** Futurism ships its own copy of Warium's ForceInducedShips (vectored afterburners and other thrusters), same treatment. */
+@Mixin(value = ForceInducedShips.class, remap = false)
+public abstract class ForceInducedShipsMixin {
+ @Shadow
+ public boolean autoScale;
+ @Shadow
+ public Stack> nextTickForces;
+ @Shadow
+ public Stack nextTickRotations;
+
+ @Unique
+ private final transient SubstepClock improvedAerodynamics$clock = new SubstepClock();
+ @Unique
+ private final transient ForceSmoother improvedAerodynamics$smoother = new ForceSmoother(improvedAerodynamics$clock);
+
+ @Inject(method = "addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/futurism/ships/ForceData;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addForce(BlockPos pos, ForceData data, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addForce(pos, data.dir, data.throttle, data.mode == ForceMode.GLOBAL,
+ data.dirMode == ForceDirectionMode.SHIP);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addForce(Lnet/minecraft/core/BlockPos;Lorg/joml/Vector3d;DLnet/mcreator/futurism/ships/ForceMode;Lnet/mcreator/futurism/ships/ForceDirectionMode;)V",
+ at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addForce(BlockPos pos, Vector3d dir, double throttle, ForceMode mode, ForceDirectionMode dirMode, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addForce(pos, dir, throttle, mode == ForceMode.GLOBAL, dirMode == ForceDirectionMode.SHIP);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addRotation(Lnet/mcreator/futurism/ships/RotData;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addRotation(RotData data, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addRotation(data.rot, data.throttle, data.mode == ForceMode.GLOBAL);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addRotation(Lorg/joml/Vector3d;DLnet/mcreator/futurism/ships/ForceMode;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addRotation(Vector3d rot, double throttle, ForceMode mode, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addRotation(rot, throttle, mode == ForceMode.GLOBAL);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "physTick", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$physTick(PhysShip ship, PhysLevel level, CallbackInfo ci) {
+ improvedAerodynamics$clock.step();
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ nextTickForces.clear();
+ nextTickRotations.clear();
+ improvedAerodynamics$smoother.apply(ship, autoScale);
+ ci.cancel();
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/AirBrakeTickProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/AirBrakeTickProcedureMixin.java
new file mode 100644
index 0000000..c491a6a
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/AirBrakeTickProcedureMixin.java
@@ -0,0 +1,32 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.AirBrakeTickProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = AirBrakeTickProcedure.class, remap = false)
+public abstract class AirBrakeTickProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.AIR_BRAKE, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ControlSurfaceTickProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ControlSurfaceTickProcedureMixin.java
new file mode 100644
index 0000000..37f953d
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ControlSurfaceTickProcedureMixin.java
@@ -0,0 +1,32 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.ControlSurfaceTickProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = ControlSurfaceTickProcedure.class, remap = false)
+public abstract class ControlSurfaceTickProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.CONTROL_SURFACE, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ForceInducedShipsMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ForceInducedShipsMixin.java
new file mode 100644
index 0000000..8005a52
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/ForceInducedShipsMixin.java
@@ -0,0 +1,100 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import com.mojang.datafixers.util.Pair;
+import dev.lumareth.improvedAerodynamics.Config;
+import dev.lumareth.improvedAerodynamics.flight.AeroHolder;
+import dev.lumareth.improvedAerodynamics.flight.AeroState;
+import dev.lumareth.improvedAerodynamics.flight.ForceSmoother;
+import dev.lumareth.improvedAerodynamics.flight.SubstepClock;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceDirectionMode;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.mcreator.valkyrienwarium.ships.ForceMode;
+import net.mcreator.valkyrienwarium.ships.RotData;
+import net.minecraft.core.BlockPos;
+import org.joml.Vector3d;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.valkyrienskies.core.api.ships.PhysShip;
+import org.valkyrienskies.core.api.world.PhysLevel;
+
+import java.util.Stack;
+
+/**
+ * Routes Warium's force queue through {@link ForceSmoother} and evaluates the ship's aerodynamics every substep.
+ * The added fields are transient so VS does not save them with the attachment.
+ */
+@Mixin(value = ForceInducedShips.class, remap = false)
+public abstract class ForceInducedShipsMixin implements AeroHolder {
+ @Shadow
+ public boolean autoScale;
+ @Shadow
+ public Stack> nextTickForces;
+ @Shadow
+ public Stack nextTickRotations;
+
+ @Unique
+ private final transient SubstepClock improvedAerodynamics$clock = new SubstepClock();
+ @Unique
+ private final transient ForceSmoother improvedAerodynamics$smoother = new ForceSmoother(improvedAerodynamics$clock);
+ @Unique
+ private final transient AeroState improvedAerodynamics$aero = new AeroState(improvedAerodynamics$clock);
+
+ @Override
+ public AeroState improvedAerodynamics$aero() {
+ return improvedAerodynamics$aero;
+ }
+
+ @Inject(method = "addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addForce(BlockPos pos, ForceData data, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addForce(pos, data.dir, data.throttle, data.mode == ForceMode.GLOBAL,
+ data.dirMode == ForceDirectionMode.SHIP);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addForce(Lnet/minecraft/core/BlockPos;Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;Lnet/mcreator/valkyrienwarium/ships/ForceDirectionMode;)V",
+ at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addForce(BlockPos pos, Vector3d dir, double throttle, ForceMode mode, ForceDirectionMode dirMode, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addForce(pos, dir, throttle, mode == ForceMode.GLOBAL, dirMode == ForceDirectionMode.SHIP);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addRotation(Lnet/mcreator/valkyrienwarium/ships/RotData;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addRotation(RotData data, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addRotation(data.rot, data.throttle, data.mode == ForceMode.GLOBAL);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "addRotation(Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;)V", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$addRotation(Vector3d rot, double throttle, ForceMode mode, CallbackInfo ci) {
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ improvedAerodynamics$smoother.addRotation(rot, throttle, mode == ForceMode.GLOBAL);
+ ci.cancel();
+ }
+ }
+
+ @Inject(method = "physTick", at = @At("HEAD"), cancellable = true)
+ private void improvedAerodynamics$physTick(PhysShip ship, PhysLevel level, CallbackInfo ci) {
+ improvedAerodynamics$clock.step();
+ if (Config.PHYSICS_RATE_AERODYNAMICS.get()) {
+ improvedAerodynamics$aero.apply(ship, autoScale);
+ }
+ if (Config.SMOOTH_FORCE_DELIVERY.get()) {
+ // Anything queued before smoothing was switched on would otherwise stay in the stacks forever
+ nextTickForces.clear();
+ nextTickRotations.clear();
+ improvedAerodynamics$smoother.apply(ship, autoScale);
+ ci.cancel();
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalStabilizerScriptProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalStabilizerScriptProcedureMixin.java
new file mode 100644
index 0000000..1b909eb
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalStabilizerScriptProcedureMixin.java
@@ -0,0 +1,42 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.HorizontalStabilizerScriptProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.mcreator.valkyrienwarium.ships.ForceMode;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3d;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = HorizontalStabilizerScriptProcedure.class, remap = false)
+public abstract class HorizontalStabilizerScriptProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.STABILIZER, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addRotation(Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;)V"))
+ private static void improvedAerodynamics$addRotation(ForceInducedShips inducer, Vector3d rot, double throttle, ForceMode mode) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addRotation(rot, throttle, mode);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalWingScriptProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalWingScriptProcedureMixin.java
new file mode 100644
index 0000000..b3875d9
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/HorizontalWingScriptProcedureMixin.java
@@ -0,0 +1,42 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.HorizontalWingScriptProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.mcreator.valkyrienwarium.ships.ForceMode;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3d;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = HorizontalWingScriptProcedure.class, remap = false)
+public abstract class HorizontalWingScriptProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.WING, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addRotation(Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;)V"))
+ private static void improvedAerodynamics$addRotation(ForceInducedShips inducer, Vector3d rot, double throttle, ForceMode mode) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addRotation(rot, throttle, mode);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalAirBrakeTickProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalAirBrakeTickProcedureMixin.java
new file mode 100644
index 0000000..4e77563
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalAirBrakeTickProcedureMixin.java
@@ -0,0 +1,32 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.VerticalAirBrakeTickProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = VerticalAirBrakeTickProcedure.class, remap = false)
+public abstract class VerticalAirBrakeTickProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.AIR_BRAKE, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalControlSurfaceTickProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalControlSurfaceTickProcedureMixin.java
new file mode 100644
index 0000000..6b8d755
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalControlSurfaceTickProcedureMixin.java
@@ -0,0 +1,42 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.VerticalControlSurfaceTickProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.mcreator.valkyrienwarium.ships.ForceMode;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3d;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = VerticalControlSurfaceTickProcedure.class, remap = false)
+public abstract class VerticalControlSurfaceTickProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.VERTICAL_CONTROL_SURFACE, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addRotation(Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;)V"))
+ private static void improvedAerodynamics$addRotation(ForceInducedShips inducer, Vector3d rot, double throttle, ForceMode mode) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addRotation(rot, throttle, mode);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalWingScriptProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalWingScriptProcedureMixin.java
new file mode 100644
index 0000000..5da5c68
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VerticalWingScriptProcedureMixin.java
@@ -0,0 +1,42 @@
+package dev.lumareth.improvedAerodynamics.flight.mixin.warium;
+
+import dev.lumareth.improvedAerodynamics.flight.SurfaceType;
+import dev.lumareth.improvedAerodynamics.flight.WariumCapture;
+import net.mcreator.valkyrienwarium.procedures.VerticalWingScriptProcedure;
+import net.mcreator.valkyrienwarium.ships.ForceData;
+import net.mcreator.valkyrienwarium.ships.ForceInducedShips;
+import net.mcreator.valkyrienwarium.ships.ForceMode;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3d;
+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.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+/** Hands this procedure's forces to the physics rate aerodynamics, see {@link WariumCapture}. */
+@Mixin(value = VerticalWingScriptProcedure.class, remap = false)
+public abstract class VerticalWingScriptProcedureMixin {
+ @Inject(method = "execute", at = @At("HEAD"))
+ private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) {
+ WariumCapture.begin(SurfaceType.VERTICAL_WING, world, x, y, z, blockstate);
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addForce(Lnet/minecraft/core/BlockPos;Lnet/mcreator/valkyrienwarium/ships/ForceData;)V"))
+ private static void improvedAerodynamics$addForce(ForceInducedShips inducer, BlockPos pos, ForceData data) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addForce(pos, data);
+ }
+ }
+
+ @Redirect(method = "execute", at = @At(value = "INVOKE",
+ target = "Lnet/mcreator/valkyrienwarium/ships/ForceInducedShips;addRotation(Lorg/joml/Vector3d;DLnet/mcreator/valkyrienwarium/ships/ForceMode;)V"))
+ private static void improvedAerodynamics$addRotation(ForceInducedShips inducer, Vector3d rot, double throttle, ForceMode mode) {
+ if (!WariumCapture.capture(inducer)) {
+ inducer.addRotation(rot, throttle, mode);
+ }
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java
new file mode 100644
index 0000000..5249949
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java
@@ -0,0 +1,27 @@
+package dev.lumareth.improvedAerodynamics.network;
+
+import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraftforge.network.NetworkDirection;
+import net.minecraftforge.network.NetworkRegistry;
+import net.minecraftforge.network.simple.SimpleChannel;
+
+/** Optional on both sides: players without the mod can still join a server with it, and the other way round. */
+public final class Network {
+ private static final String VERSION = "1";
+
+ public static final SimpleChannel CHANNEL = NetworkRegistry.newSimpleChannel(
+ new ResourceLocation(ImprovedAerodynamics.MOD_ID, "main"), () -> VERSION,
+ NetworkRegistry.acceptMissingOr(VERSION), NetworkRegistry.acceptMissingOr(VERSION));
+
+ private Network() {
+ }
+
+ public static void register() {
+ CHANNEL.messageBuilder(ToggleAutoTrimPacket.class, 0, NetworkDirection.PLAY_TO_SERVER)
+ .encoder(ToggleAutoTrimPacket::encode)
+ .decoder(ToggleAutoTrimPacket::decode)
+ .consumerMainThread(ToggleAutoTrimPacket::handle)
+ .add();
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAutoTrimPacket.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAutoTrimPacket.java
new file mode 100644
index 0000000..5f5b484
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAutoTrimPacket.java
@@ -0,0 +1,26 @@
+package dev.lumareth.improvedAerodynamics.network;
+
+import dev.lumareth.improvedAerodynamics.flight.AutoTrimToggle;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.network.NetworkEvent;
+
+import java.util.function.Supplier;
+
+/** Sent when a player presses the auto trim key, toggles auto trim on the control node of the seat they sit in. */
+public final class ToggleAutoTrimPacket {
+ public void encode(FriendlyByteBuf buffer) {
+ }
+
+ public static ToggleAutoTrimPacket decode(FriendlyByteBuf buffer) {
+ return new ToggleAutoTrimPacket();
+ }
+
+ public void handle(Supplier context) {
+ ServerPlayer player = context.get().getSender();
+ if (player != null && ModList.get().isLoaded("valkyrien_warium")) {
+ AutoTrimToggle.toggle(player);
+ }
+ }
+}
diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml
index 37471fe..9248f23 100644
--- a/src/main/resources/META-INF/mods.toml
+++ b/src/main/resources/META-INF/mods.toml
@@ -30,10 +30,18 @@ versionRange = "[2.4.11,)"
ordering = "AFTER"
side = "BOTH"
-# Optional, for the planned Warium VS flight physics overhaul
+# Optional, the flight physics changes only apply with Warium VS
[[dependencies."${mod_id}"]]
modId = "valkyrien_warium"
mandatory = false
versionRange = "[1.1.6,)"
ordering = "AFTER"
side = "BOTH"
+
+# Optional, vectored afterburner thrust is smoothed as well
+[[dependencies."${mod_id}"]]
+modId = "futurism"
+mandatory = false
+versionRange = "[0.0.4,)"
+ordering = "AFTER"
+side = "BOTH"
diff --git a/src/main/resources/assets/improved_aerodynamics/lang/de_de.json b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json
new file mode 100644
index 0000000..357c0bd
--- /dev/null
+++ b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json
@@ -0,0 +1,9 @@
+{
+ "key.categories.improved_aerodynamics": "Improved Aerodynamics",
+ "key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten",
+ "message.improved_aerodynamics.auto_trim.on": "Automatische Trimmung an",
+ "message.improved_aerodynamics.auto_trim.off": "Automatische Trimmung aus",
+ "message.improved_aerodynamics.auto_trim.unavailable": "Automatische Trimmung an, sie braucht aber physicsRateAerodynamics in der Serverkonfiguration",
+ "message.improved_aerodynamics.auto_trim.no_seat": "Setz dich in einen Warium-Steuersitz, um die automatische Trimmung zu nutzen",
+ "message.improved_aerodynamics.auto_trim.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden"
+}
diff --git a/src/main/resources/assets/improved_aerodynamics/lang/en_us.json b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json
new file mode 100644
index 0000000..345f416
--- /dev/null
+++ b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json
@@ -0,0 +1,9 @@
+{
+ "key.categories.improved_aerodynamics": "Improved Aerodynamics",
+ "key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim",
+ "message.improved_aerodynamics.auto_trim.on": "Auto trim on",
+ "message.improved_aerodynamics.auto_trim.off": "Auto trim off",
+ "message.improved_aerodynamics.auto_trim.unavailable": "Auto trim on, but it needs physicsRateAerodynamics enabled in the server config",
+ "message.improved_aerodynamics.auto_trim.no_seat": "Sit in a Warium control seat to use auto trim",
+ "message.improved_aerodynamics.auto_trim.no_node": "This seat is not linked to a vehicle control node"
+}
diff --git a/src/main/resources/improved_aerodynamics.mixins.json b/src/main/resources/improved_aerodynamics.mixins.json
new file mode 100644
index 0000000..2607e64
--- /dev/null
+++ b/src/main/resources/improved_aerodynamics.mixins.json
@@ -0,0 +1,21 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "dev.lumareth.improvedAerodynamics.flight.mixin",
+ "plugin": "dev.lumareth.improvedAerodynamics.flight.MixinPlugin",
+ "compatibilityLevel": "JAVA_17",
+ "mixins": [
+ "futurism.ForceInducedShipsMixin",
+ "warium.AirBrakeTickProcedureMixin",
+ "warium.ControlSurfaceTickProcedureMixin",
+ "warium.ForceInducedShipsMixin",
+ "warium.HorizontalStabilizerScriptProcedureMixin",
+ "warium.HorizontalWingScriptProcedureMixin",
+ "warium.VerticalAirBrakeTickProcedureMixin",
+ "warium.VerticalControlSurfaceTickProcedureMixin",
+ "warium.VerticalWingScriptProcedureMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}