Implement stable Sable gravity frame physics

This commit is contained in:
f1den committed 2026-08-11 00:16:55 +03:00
1 parent e1aa22858e
commit c186a35c72
38 files changed
+3113 -215

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
[mcp_servers.minecraft]
# The development bridge is reachable through IPv4 loopback only.
url = "http://127.0.0.1:8765/mcp"
enabled = true
required = false
startup_timeout_sec = 10
tool_timeout_sec = 120
default_tools_approval_mode = "writes"
+10
View File
@@ -43,3 +43,13 @@ repo/
!**/src/**/repo/ !**/src/**/repo/
### Local reference checkouts ### ### Local reference checkouts ###
SableGravity-oriented-aabb-test18-server-authoritative.zip
SableGravity-test21-rotated-obb-recovery-source.zip
### Local MCP development artifacts ###
.codex/recovery/
runclient.*.log
### Local reverse-engineering output ###
verification/*.javap.txt
verification/sable-*-inspect/
+49 -10
View File
@@ -1,12 +1,51 @@
# Sable Gravity 0.6.0 test16 # Sable Gravity 0.7.4
Built on the working test13 Sable OBB collision path. Sable Gravity is a multidimensional gravity-frame kernel for moving and arbitrarily rotated
Sable ships. A frame owns a dimension-qualified identity, a local coordinate system, a rigid
pose and a local `-Y` gravity direction.
Changes from test15: ## Physics model
- never imports Sable-mutated world deltaMovement back into local velocity; removes tilted-floor drift;
- uses Sable local floor contact as the authoritative ground state; - Players use an oriented body and plot-local feet anchor. Crouching, standing, jumping,
- keeps creative flight independent from stale world-axis onGround state; creative flight, edge sneaking and wall sliding are resolved in the ship frame without using
- captures flight ascend/descend against the exact previous post-tick velocity, so ship rotation cannot become fake acceleration; the parent-world AABB as the source of truth.
- updates walk animation directly from the actual local displacement because cancelled Player.travel never reaches vanilla calculateEntityAnimation; - Entities always remain in physical parent-world coordinates. Sable is the only owner of their
- replaces Sable/vanilla sneak edge backoff with a full local plot-space floor query; OBB collision, tracking and packet conversion; Sable Gravity rotates gravity, locomotion and
- leaves Entity.bb axis-aligned only for Minecraft broadphase. It is no longer used as the source of local ground, flight or sneak behaviour. drag into the selected frame without exposing the hidden plot grid to entity networking.
- Non-player entities keep their ordinary parent-world/network position. Their oriented body is
foot-anchored only inside Sable's collision transform, so items and mobs do not need a spawn-time
coordinate rebase and cannot leak plot coordinates into packets.
- Client particle attachment and packet conversion remain on Sable's native path. This mod does
not install a second universal particle lifecycle on top of Sable's particle mixins.
- For the local player, plot-local feet position and relative velocity are authoritative. A
translating or rotating ship is reconstructed from them every tick instead of accumulating pose
deltas.
## Dimensions and Cosmonautics
`GravityFrameId` contains provider, dimension and local UUID. Frame lookup is always performed in
the entity's current `Level`, so an identical SubLevel UUID in another dimension cannot alias the
old ship. A recreated destination frame is resolved again from that level's Sable container.
Gravity magnitude comes from the final parent-world `Entity#getGravity()` result while the entity
stays physical. Dimension/altitude modifiers such as Cosmonautics therefore compose with Sable
direction and never evaluate against the plot's synthetic Y coordinate.
There is no hardcoded list of planets, dimensions or entity types. The built-in provider accepts
Sable SubLevels from any `Level`, including Cosmonautics dimensions. Other mods can register a
higher-priority `GravityFrameProvider` through `SableGravityApi` for directional gravity and frame
consumers; arbitrary-orientation ship collision additionally requires a Sable-backed frame.
## Development
The development client loads Create, Aeronautics, Cosmonautics and the local Minecraft MCP bridge.
The bridge is configured for `127.0.0.1:8765` only; it is not packaged into the release jar.
Build with:
```powershell
.\gradlew.bat build
```
Player controls remain available through `/sablegravity enable|disable`,
`/sablegravity strength <value>` and `/sablegravity test <state|frame|support|all>`.
+12
View File
@@ -12,6 +12,13 @@ base {
java.toolchain.languageVersion = JavaLanguageVersion.of(21) java.toolchain.languageVersion = JavaLanguageVersion.of(21)
sourceSets {
// Cosmonautics 26.07.270 advertises a relocated Orekit javac plugin that is absent from its
// published jar. The dev-only provider keeps Minecraft MCP's in-process Java compiler usable.
// This source set is placed on runClient's classpath and is never included in sablegravity.jar.
mcpCompat
}
repositories { repositories {
mavenCentral() mavenCentral()
maven { maven {
@@ -61,7 +68,12 @@ dependencies {
// Development client mod set. Aeronautics requires Create and Sable. // Development client mod set. Aeronautics requires Create and Sable.
runtimeOnly "maven.modrinth:create:${project.create_version}" runtimeOnly "maven.modrinth:create:${project.create_version}"
runtimeOnly "maven.modrinth:create-aeronautics:${project.aeronautics_version}" runtimeOnly "maven.modrinth:create-aeronautics:${project.aeronautics_version}"
runtimeOnly "maven.modrinth:micg1iTH:${project.cosmonautics_version}"
runtimeOnly "maven.modrinth:the-one-probe:${project.the_one_probe_version}" runtimeOnly "maven.modrinth:the-one-probe:${project.the_one_probe_version}"
// Local-only game control/inspection bridge used by the development client.
runtimeOnly files('libs/minecraft_mcp-0.1.0.jar')
runtimeOnly sourceSets.mcpCompat.output
} }
tasks.withType(JavaCompile).configureEach { tasks.withType(JavaCompile).configureEach {
+2 -1
View File
@@ -2,11 +2,12 @@ org.gradle.jvmargs=-Xmx2G
org.gradle.daemon=false org.gradle.daemon=false
org.gradle.parallel=true org.gradle.parallel=true
mod_id=sablegravity mod_id=sablegravity
mod_version=0.6.0-test16-ground-flight-sneak mod_version=0.7.4-player-recovery
minecraft_version=1.21.1 minecraft_version=1.21.1
neo_version=21.1.228 neo_version=21.1.228
sable_version=2.0.3+mc1.21.1 sable_version=2.0.3+mc1.21.1
sable_companion_version=1.6.0 sable_companion_version=1.6.0
create_version=6.0.10+mc1.21.1 create_version=6.0.10+mc1.21.1
aeronautics_version=1.3.0+mc1.21.1 aeronautics_version=1.3.0+mc1.21.1
cosmonautics_version=c6IOumoH
the_one_probe_version=1.21_neo-12.0.8 the_one_probe_version=1.21_neo-12.0.8
Binary file not shown.
@@ -0,0 +1,42 @@
package dev.nightly.sablegravity;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.phys.Vec3;
import java.util.UUID;
/**
* Runtime state shared by every entity that can be bound to a gravity frame.
*
* <p>The frame identity is deliberately dimension-qualified. Cosmonautics recreates Sable
* SubLevels while warping a ship between dimensions, so a bare SubLevel UUID is not a stable
* cross-dimensional identity.</p>
*/
public interface GravityEntityState {
boolean sablegravity$isInLocalSimulation();
void sablegravity$setInLocalSimulation(boolean value);
boolean sablegravity$isLocalStateValid();
void sablegravity$setLocalStateValid(boolean value);
Vec3 sablegravity$getLocalPosition();
void sablegravity$setLocalPosition(Vec3 value);
Vec3 sablegravity$getLocalVelocity();
void sablegravity$setLocalVelocity(Vec3 value);
UUID sablegravity$getFrameId();
void sablegravity$setFrameId(UUID value);
ResourceLocation sablegravity$getFrameProvider();
void sablegravity$setFrameProvider(ResourceLocation value);
ResourceLocation sablegravity$getFrameDimension();
void sablegravity$setFrameDimension(ResourceLocation value);
boolean sablegravity$isGravitySampleValid();
void sablegravity$setGravitySampleValid(boolean value);
double sablegravity$getGravitySample();
void sablegravity$setGravitySample(double value);
boolean sablegravity$isFrameHandoffPending();
void sablegravity$setFrameHandoffPending(boolean value);
int sablegravity$getFrameHandoffExpiryTick();
void sablegravity$setFrameHandoffExpiryTick(int value);
}
@@ -4,16 +4,24 @@ import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.DoubleArgumentType; import com.mojang.brigadier.arguments.DoubleArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.SuggestionProvider;
import dev.nightly.sablegravity.physics.SableLocalCollisionProbe;
import dev.ryanhcode.sable.Sable;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.EntityArgument; import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer; import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent;
import org.joml.Quaterniondc;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -23,12 +31,16 @@ import java.util.Map;
@Mod(SableGravityMod.MOD_ID) @Mod(SableGravityMod.MOD_ID)
public final class SableGravityMod { public final class SableGravityMod {
public static final String MOD_ID = "sablegravity"; public static final String MOD_ID = "sablegravity";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public static final float VANILLA_STRENGTH = 9.8F; public static final float VANILLA_STRENGTH = 9.8F;
private static volatile float globalStrength = VANILLA_STRENGTH; private static volatile float globalStrength = VANILLA_STRENGTH;
private static final Map<String, TestFunction> TEST_FUNCTIONS = Map.of( private static final Map<String, TestFunction> TEST_FUNCTIONS = Map.of(
"axistestb", SableGravityMod::axisTestB "state", SableGravityMod::testState,
"frame", SableGravityMod::testFrame,
"support", SableGravityMod::testSupport,
"all", SableGravityMod::testAll
); );
private static final SuggestionProvider<CommandSourceStack> TEST_SUGGESTIONS = private static final SuggestionProvider<CommandSourceStack> TEST_SUGGESTIONS =
@@ -185,7 +197,11 @@ public final class SableGravityMod {
TEST_FUNCTIONS.keySet().stream().sorted().toList() TEST_FUNCTIONS.keySet().stream().sorted().toList()
); );
source.sendFailure( Component.literal("Unknown test function: " + functionName + ". Available: " + functions) ); source.sendFailure(
Component.literal(
"Unknown test function: " + functionName + ". Available: " + functions
)
);
return 0; return 0;
} }
@@ -213,37 +229,103 @@ public final class SableGravityMod {
} }
} }
private static void axisTestB(CommandSourceStack source) { private static void testState(CommandSourceStack source) throws Exception {
try { ServerPlayer player = source.getPlayerOrException();
ServerPlayer player = source.getPlayer(); SableGravityState state = (SableGravityState) player;
SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player);
if (player == null) { source.sendSuccess(
source.sendFailure(Component.literal("This test must be executed by a player")); () -> Component.literal(
return; "server-state: enabled=" + state.sablegravity$isEnabled()
} + ", strength=" + format(state.sablegravity$getStrength())
+ ", trackedFrame=" + (tracked == null ? "null" : tracked.getUniqueId())
player.getBoundingBox(); + ". Local physics lives on LocalPlayer; run /sablegravityclient all"
// Тут вызываешь свою void-функцию: ),
// false
// SomeClass.axisTestB(player); );
// someVoidFunction();
source.sendSuccess(() -> Component.literal("Axis test B executed"),false );
} catch (Exception e) {
source.sendFailure(Component.literal("ERROR:\n " + e.toString()));
}
} }
private static String format(float value) { private static void testFrame(CommandSourceStack source) throws Exception {
ServerPlayer player = source.getPlayerOrException();
SubLevel frame = requireTrackedFrame(player);
Quaterniondc q = frame.logicalPose().orientation();
Vec3 localPosition = frame.logicalPose().transformPositionInverse(player.position());
source.sendSuccess(
() -> Component.literal(
"frame: id=" + frame.getUniqueId()
+ ", q=(" + format(q.x()) + ", " + format(q.y()) + ", "
+ format(q.z()) + ", " + format(q.w()) + ")"
+ ", localPos=" + formatVec(localPosition)
),
false
);
}
private static void testSupport(CommandSourceStack source) throws Exception {
ServerPlayer player = source.getPlayerOrException();
SubLevel frame = requireTrackedFrame(player);
Vec3 localFeet = SableLocalCollisionProbe.getLocalFeet(player, frame);
double halfWidth = player.getBbWidth() * 0.5D;
double epsilon = 1.0E-5D;
AABB floorProbe = new AABB(
localFeet.x - halfWidth,
localFeet.y - player.maxUpStep() - epsilon,
localFeet.z - halfWidth,
localFeet.x + halfWidth,
localFeet.y + epsilon,
localFeet.z + halfWidth
);
boolean supported = SableLocalCollisionProbe.hasSupport(player, frame, floorProbe);
source.sendSuccess(
() -> Component.literal(
"support: " + supported
+ ", localFeet=" + formatVec(localFeet)
+ ", probe=" + formatBox(floorProbe)
),
false
);
}
private static void testAll(CommandSourceStack source) throws Exception {
testState(source);
testFrame(source);
testSupport(source);
source.sendSuccess(
() -> Component.literal(
"Server diagnostics completed. For real local velocity/bounds/collision use /sablegravityclient all"
),
false
);
}
private static SubLevel requireTrackedFrame(ServerPlayer player) {
SubLevel frame = Sable.HELPER.getTrackingSubLevel(player);
if (frame == null || frame.isRemoved()) {
throw new IllegalStateException("no tracked Sable SubLevel");
}
return frame;
}
private static String formatVec(Vec3 value) {
return "(" + format(value.x) + ", " + format(value.y) + ", " + format(value.z) + ")";
}
private static String formatBox(AABB box) {
return "[" + format(box.minX) + ", " + format(box.minY) + ", " + format(box.minZ)
+ " -> " + format(box.maxX) + ", " + format(box.maxY) + ", " + format(box.maxZ) + "]";
}
private static String format(double value) {
if (value == (long) value) { if (value == (long) value) {
return Long.toString((long) value); return Long.toString((long) value);
} }
return Float.toString(value); return String.format(Locale.ROOT, "%.6f", value);
} }
@FunctionalInterface @FunctionalInterface
private interface TestFunction { private interface TestFunction {
void run(CommandSourceStack source) throws Exception; void run(CommandSourceStack source) throws Exception;
} }
} }
@@ -1,23 +1,8 @@
package dev.nightly.sablegravity; package dev.nightly.sablegravity;
import net.minecraft.world.phys.Vec3; public interface SableGravityState extends GravityEntityState {
import java.util.UUID;
public interface SableGravityState {
boolean sablegravity$isEnabled(); boolean sablegravity$isEnabled();
void sablegravity$setEnabled(boolean enabled); void sablegravity$setEnabled(boolean enabled);
float sablegravity$getStrength(); float sablegravity$getStrength();
void sablegravity$setStrength(float strength); void sablegravity$setStrength(float strength);
boolean sablegravity$isInLocalSimulation();
void sablegravity$setInLocalSimulation(boolean value);
boolean sablegravity$isLocalStateValid();
void sablegravity$setLocalStateValid(boolean value);
Vec3 sablegravity$getLocalPosition();
void sablegravity$setLocalPosition(Vec3 value);
Vec3 sablegravity$getLocalVelocity();
void sablegravity$setLocalVelocity(Vec3 value);
UUID sablegravity$getFrameId();
void sablegravity$setFrameId(UUID value);
} }
@@ -0,0 +1,44 @@
package dev.nightly.sablegravity.api;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniondc;
/**
* A moving, rotating coordinate system whose local -Y axis is gravity down.
*
* <p>Positions and velocities are intentionally separate: a relative velocity must not inherit
* the frame translation twice. Consumers that need inertial velocity can add
* {@link #frameVelocityAt(Vec3)} explicitly.</p>
*/
public interface GravityFrame {
GravityFrameId id();
Level level();
boolean isValid();
Vec3 toLocalPosition(Vec3 worldPosition);
Vec3 toWorldPosition(Vec3 localPosition);
Vec3 toLocalVelocity(Vec3 worldVelocity);
Vec3 toWorldVelocity(Vec3 localVelocity);
Quaterniondc orientation(float partialTick);
default Vec3 down(float partialTick) {
Quaterniondc rotation = orientation(partialTick);
org.joml.Vector3d down = rotation.transform(
new org.joml.Vector3d(0.0D, -1.0D, 0.0D)
);
return new Vec3(down.x, down.y, down.z);
}
/** Movement contributed by the frame between its previous and current physics poses. */
Vec3 frameVelocityAt(Vec3 worldPosition);
/**
* Final gravity magnitude after vanilla attributes and other mods (including Cosmonautics)
* have had a chance to modify {@link Entity#getGravity()}.
*/
default double gravityPerTick(Entity entity) {
return Math.max(0.0D, entity.getGravity());
}
}
@@ -0,0 +1,19 @@
package dev.nightly.sablegravity.api;
import net.minecraft.resources.ResourceLocation;
import java.util.Objects;
import java.util.UUID;
/** Globally unambiguous identity of one gravity frame in one dimension. */
public record GravityFrameId(
ResourceLocation provider,
ResourceLocation dimension,
UUID localId
) {
public GravityFrameId {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(dimension, "dimension");
Objects.requireNonNull(localId, "localId");
}
}
@@ -0,0 +1,20 @@
package dev.nightly.sablegravity.api;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import java.util.Optional;
/** Extension point for Sable ships, artificial fields, planets, and other reference frames. */
public interface GravityFrameProvider {
Optional<? extends GravityFrame> resolve(Entity entity, GravityFrameId previousFrame);
default Optional<? extends GravityFrame> resolveAt(
Level level,
Vec3 worldPosition,
GravityFrameId previousFrame
) {
return Optional.empty();
}
}
@@ -0,0 +1,26 @@
package dev.nightly.sablegravity.api;
import dev.nightly.sablegravity.physics.GravityFrames;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import java.util.Optional;
/** Public, dependency-light entry point for gravity providers and consumers. */
public final class SableGravityApi {
private SableGravityApi() {}
/** Higher priority providers win. Registration is safe during parallel mod construction. */
public static void registerFrameProvider(GravityFrameProvider provider, int priority) {
GravityFrames.register(provider, priority);
}
public static Optional<GravityFrame> resolve(Entity entity) {
return GravityFrames.resolve(entity);
}
public static Optional<GravityFrame> resolveAt(Level level, Vec3 position) {
return GravityFrames.resolveAt(level, position, null);
}
}
@@ -0,0 +1,244 @@
package dev.nightly.sablegravity.client;
import com.mojang.brigadier.CommandDispatcher;
import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalCollisionProbe;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.nightly.sablegravity.physics.SableOrientedBounds;
import dev.nightly.sablegravity.physics.SableParentWorldCollision;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LivingEntityMovementExtension;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
import net.minecraft.client.Minecraft;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
import org.joml.Quaterniondc;
import java.util.Locale;
/** Diagnostics that execute on the actual LocalPlayer instead of the integrated server copy. */
@EventBusSubscriber(modid = SableGravityMod.MOD_ID, value = Dist.CLIENT)
public final class SableGravityClientCommands {
private SableGravityClientCommands() {}
@SubscribeEvent
public static void register(RegisterClientCommandsEvent event) {
CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();
dispatcher.register(
Commands.literal("sablegravityclient")
.executes(context -> run("all"))
.then(Commands.literal("state").executes(context -> run("state")))
.then(Commands.literal("frame").executes(context -> run("frame")))
.then(Commands.literal("bounds").executes(context -> run("bounds")))
.then(Commands.literal("support").executes(context -> run("support")))
.then(Commands.literal("collision").executes(context -> run("collision")))
.then(Commands.literal("velocity").executes(context -> run("velocity")))
.then(Commands.literal("all").executes(context -> run("all")))
);
}
private static int run(String function) {
Player player = Minecraft.getInstance().player;
if (player == null) {
return 0;
}
try {
switch (function) {
case "state" -> state(player);
case "frame" -> frame(player);
case "bounds" -> bounds(player);
case "support" -> support(player);
case "collision" -> collision(player);
case "velocity" -> velocity(player);
case "all" -> {
state(player);
frame(player);
bounds(player);
support(player);
collision(player);
velocity(player);
}
default -> throw new IllegalArgumentException("unknown function: " + function);
}
message(player, "client diagnostic completed: " + function);
return 1;
} catch (Exception exception) {
message(
player,
"client diagnostic " + function + " failed: "
+ exception.getClass().getSimpleName() + ": " + exception.getMessage()
);
exception.printStackTrace();
return 0;
}
}
private static void state(Player player) {
SableGravityState state = (SableGravityState) player;
SubLevel latched = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
SubLevel tracked = dev.ryanhcode.sable.Sable.HELPER.getTrackingSubLevel(player);
message(
player,
"client-state: enabled=" + state.sablegravity$isEnabled()
+ ", strength=" + format(state.sablegravity$getStrength())
+ ", localValid=" + state.sablegravity$isLocalStateValid()
+ ", grounded=" + SableLocalPlayerPhysics.isLocalGrounded(player)
+ ", stateFrame=" + state.sablegravity$getFrameId()
+ ", latchedFrame=" + frameId(latched)
+ ", sableTracked=" + frameId(tracked)
+ ", localVelocity=" + formatVec(state.sablegravity$getLocalVelocity())
);
}
private static void frame(Player player) {
SubLevel frame = requireFrame(player);
Quaterniondc q = SableOrientedBounds.logicalOrientation(frame);
message(
player,
"client-frame: id=" + frame.getUniqueId()
+ ", q=" + formatQuat(q)
+ ", localFeet=" + formatVec(SableLocalCollisionProbe.getLocalFeet(player, frame))
);
}
private static void bounds(Player player) {
SubLevel frame = requireFrame(player);
Quaterniondc q = SableOrientedBounds.logicalOrientation(frame);
AABB canonical = SableOrientedBounds.canonicalAabb(player);
AABB envelope = SableOrientedBounds.worldEnvelope(player, q);
Vec3 center = SableOrientedBounds.worldCenter(player, q);
Vec3 up = toVec(q.transform(
new org.joml.Vector3d(0.0D, 1.0D, 0.0D),
new org.joml.Vector3d()
));
message(
player,
"bounds: pose=" + player.getPose()
+ ", entityAabb=" + formatBox(player.getBoundingBox())
+ ", canonical=" + formatBox(canonical)
+ ", obbCenter=" + formatVec(center)
+ ", obbUp=" + formatVec(up)
+ ", physicalFeet=" + formatVec(SableOrientedBounds.worldFeet(player, q))
+ ", envelope=" + formatBox(envelope)
);
}
private static void support(Player player) {
SubLevel frame = requireFrame(player);
Vec3 localFeet = SableLocalCollisionProbe.getLocalFeet(player, frame);
AABB probe = SableLocalCollisionProbe.createFloorProbe(player, localFeet, 0.08D);
message(
player,
"support: " + SableLocalCollisionProbe.hasSupport(player, frame, probe)
+ ", localFeet=" + formatVec(localFeet)
+ ", probe=" + formatBox(probe)
);
}
private static void collision(Player player) {
SubLevelEntityCollision.CollisionInfo info =
((EntityMovementExtension) player).sable$getCollisionInfo();
if (info == null) {
message(player, "collision: null");
return;
}
int contacts = info.firstCollisions == null ? 0 : info.firstCollisions.size();
SableParentWorldCollision.CollisionResult parent =
SableParentWorldCollision.getLastResult(player);
message(
player,
"collision: horizontal=" + info.horizontalCollision
+ ", subHorizontal=" + info.subLevelHorizontalCollision
+ ", vertical=" + info.verticalCollision
+ ", below=" + info.verticalCollisionBelow
+ ", contacts=" + contacts
+ ", motion=" + formatVec(info.motion)
+ ", parentContacts=" + parent.contacts()
+ ", parentHorizontal=" + parent.horizontalCollision()
+ ", parentVertical=" + parent.verticalCollision()
+ ", parentBelow=" + parent.verticalCollisionBelow()
+ ", parentCorrection=" + format(parent.maxCorrection())
+ ", parentMotion=" + formatVec(parent.motion())
);
}
private static void velocity(Player player) {
SableGravityState state = (SableGravityState) player;
org.joml.Vector3dc inherited =
((LivingEntityMovementExtension) player).sable$getInheritedVelocity();
SubLevelEntityCollision.CollisionInfo info =
((EntityMovementExtension) player).sable$getCollisionInfo();
Vec3 inheritedMotion = info == null ? null : info.inheritedMotion;
Vec3 inheritedVelocity = new Vec3(inherited.x(), inherited.y(), inherited.z());
Vec3 correctedFrameMotion = SableLocalPlayerPhysics.getLastFrameMotion(player);
Vec3 rejectedSableMotion =
SableLocalPlayerPhysics.getLastRejectedSableInheritedMotion(player);
message(
player,
"velocity: local=" + formatVec(state.sablegravity$getLocalVelocity())
+ " |local|=" + format(state.sablegravity$getLocalVelocity().length())
+ ", deltaMovement=" + formatVec(player.getDeltaMovement())
+ " |delta|=" + format(player.getDeltaMovement().length())
+ ", correctedFrameMotion=" + formatVec(correctedFrameMotion)
+ " |frame|=" + format(correctedFrameMotion.length())
+ ", inheritedVelocity=" + formatVec(inheritedVelocity)
+ " |inherited|=" + format(inheritedVelocity.length())
+ ", liveSableInherited=" + formatVec(inheritedMotion)
+ ", rejectedSableInherited=" + formatVec(rejectedSableMotion)
);
}
private static SubLevel requireFrame(Player player) {
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) {
throw new IllegalStateException("no attached client Sable frame");
}
return frame;
}
private static String frameId(SubLevel frame) {
return frame == null ? "null" : frame.getUniqueId().toString();
}
private static Vec3 toVec(org.joml.Vector3dc value) {
return new Vec3(value.x(), value.y(), value.z());
}
private static void message(Player player, String text) {
player.displayClientMessage(Component.literal(text), false);
}
private static String formatQuat(Quaterniondc q) {
return "(" + format(q.x()) + ", " + format(q.y()) + ", "
+ format(q.z()) + ", " + format(q.w()) + ")";
}
private static String formatVec(Vec3 value) {
if (value == null) {
return "null";
}
return "(" + format(value.x) + ", " + format(value.y) + ", " + format(value.z) + ")";
}
private static String formatBox(AABB box) {
return "[" + format(box.minX) + ", " + format(box.minY) + ", " + format(box.minZ)
+ " -> " + format(box.maxX) + ", " + format(box.maxY) + ", " + format(box.maxZ) + "]";
}
private static String format(double value) {
return String.format(Locale.ROOT, "%.6f", value);
}
}
@@ -0,0 +1,171 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableFrameOrientation;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.nightly.sablegravity.physics.SableOrientedBounds;
import dev.nightly.sablegravity.physics.SableParentWorldCollision;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniond;
import org.spongepowered.asm.mixin.Mixin;
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.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import java.util.UUID;
/**
* Keeps Entity.bb as metadata/broadphase only while arbitrary gravity is active. This mixin has
* higher priority than Sable's Entity mixin so the semantic-flag restoration point is inserted
* before Sable wraps the same on-ground invocation.
*/
@Mixin(value = Entity.class, priority = 3000)
public abstract class EntityAabbIsolationMixin {
@Unique private Vec3 sablegravity$localFeetBeforeResize;
@Unique private UUID sablegravity$frameBeforeResize;
@Unique private boolean sablegravity$insideMove;
@Inject(method = "collide", at = @At("HEAD"), cancellable = true)
private void sablegravity$replaceUprightCollision(
Vec3 motion,
CallbackInfoReturnable<Vec3> cir
) {
Entity self = (Entity) (Object) this;
if (self instanceof Player player) {
SableGravityState state = (SableGravityState) player;
if (state.sablegravity$isEnabled()) {
if (player.isLocalPlayer()) {
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (validFrame(player, frame)) {
cir.setReturnValue(SableParentWorldCollision.collideOrTrust(
player,
motion,
frame,
sablegravity$insideMove
));
return;
}
}
}
}
}
@Inject(method = "move", at = @At("HEAD"))
private void sablegravity$beginMove(CallbackInfo ci) {
sablegravity$insideMove = true;
}
@Inject(
method = "move",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/world/entity/Entity;setOnGroundWithMovement(ZLnet/minecraft/world/phys/Vec3;)V",
shift = At.Shift.BEFORE
)
)
private void sablegravity$restoreLocalCollisionSemanticsBeforeGroundUpdate(
CallbackInfo ci
) {
Entity self = (Entity) (Object) this;
if (self instanceof Player player) {
SableParentWorldCollision.restoreSableFlags(player);
}
}
@Inject(method = "move", at = @At("TAIL"))
private void sablegravity$endMove(CallbackInfo ci) {
sablegravity$insideMove = false;
}
@Inject(method = "refreshDimensions", at = @At("HEAD"))
private void sablegravity$captureFeetBeforeResize(CallbackInfo ci) {
Entity self = (Entity) (Object) this;
if (!(self instanceof Player player) || !usesOrientedBody(player)) {
sablegravity$clearResizeAnchor();
return;
}
SubLevel frame = currentFrame(player);
if (!validFrame(player, frame)) {
sablegravity$clearResizeAnchor();
return;
}
// A pose transition belongs to the Sable frame, not to parent-world space. Anchoring the
// transient resize to a world point lets a simultaneously moving/rotating frame turn
// crouch into a visible teleport or tangent slide. Store the invariant local feet point.
Quaterniond orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F);
Vec3 worldFeet = SableOrientedBounds.worldFeet(player, orientation);
sablegravity$localFeetBeforeResize = frame.logicalPose()
.transformPositionInverse(worldFeet);
sablegravity$frameBeforeResize = frame.getUniqueId();
}
@Inject(method = "refreshDimensions", at = @At("TAIL"))
private void sablegravity$restoreFeetAfterResize(CallbackInfo ci) {
Entity self = (Entity) (Object) this;
if (!(self instanceof Player player)
|| sablegravity$localFeetBeforeResize == null) {
sablegravity$clearResizeAnchor();
return;
}
SubLevel frame = currentFrame(player);
if (!validFrame(player, frame)
|| !frame.getUniqueId().equals(sablegravity$frameBeforeResize)) {
sablegravity$clearResizeAnchor();
return;
}
Quaterniond orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F);
Vec3 physicalFeet = frame.logicalPose().transformPosition(
sablegravity$localFeetBeforeResize
);
Vec3 rawPosition = SableOrientedBounds.rawPositionForFeet(
player,
physicalFeet,
orientation
);
if (Double.isFinite(rawPosition.x)
&& Double.isFinite(rawPosition.y)
&& Double.isFinite(rawPosition.z)) {
player.setPos(rawPosition);
}
sablegravity$clearResizeAnchor();
}
@Unique
private static boolean usesOrientedBody(Player player) {
if (!((SableGravityState) player).sablegravity$isEnabled()) {
return false;
}
return player.isLocalPlayer();
}
@Unique
private static SubLevel currentFrame(Player player) {
if (player.isLocalPlayer()) {
SubLevel attached = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (validFrame(player, attached)) {
return attached;
}
}
return null;
}
@Unique
private static boolean validFrame(Player player, SubLevel frame) {
return frame != null && !frame.isRemoved() && frame.getLevel() == player.level();
}
@Unique
private void sablegravity$clearResizeAnchor() {
sablegravity$localFeetBeforeResize = null;
sablegravity$frameBeforeResize = null;
}
}
@@ -1,6 +1,9 @@
package dev.nightly.sablegravity.mixin; package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.GravityEntityState;
import dev.nightly.sablegravity.SableGravityState; import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.GravityFrames;
import dev.nightly.sablegravity.physics.SableFrameOrientation;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.api.entity.EntitySubLevelUtil; import dev.ryanhcode.sable.api.entity.EntitySubLevelUtil;
import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.SubLevel;
@@ -12,14 +15,7 @@ import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** /** Supplies Sable with a substep-interpolated full quaternion OBB orientation. */
* Exposes one authoritative ship-frame orientation to all Sable consumers.
*
* Sable's camera helper and its world-space OBB collision resolver both read
* EntitySubLevelUtil's custom orientation. The direct logical-pose quaternion
* maps player-local axes into world axes; Sable applies the inverse itself when
* constructing the view matrix.
*/
@Mixin(value = EntitySubLevelUtil.class, priority = 3000, remap = false) @Mixin(value = EntitySubLevelUtil.class, priority = 3000, remap = false)
public abstract class EntityCustomOrientationMixin { public abstract class EntityCustomOrientationMixin {
@Inject( @Inject(
@@ -33,21 +29,19 @@ public abstract class EntityCustomOrientationMixin {
float partialTicks, float partialTicks,
CallbackInfoReturnable<Quaterniondc> cir CallbackInfoReturnable<Quaterniondc> cir
) { ) {
if (!(entity instanceof Player player)) { GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$isInLocalSimulation()) {
return; return;
} }
SableGravityState state = (SableGravityState) player; SubLevel frame = resolveFrame(entity);
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) { if (frame == null || frame.isRemoved()) {
return; return;
} }
cir.setReturnValue(frame.logicalPose().orientation()); cir.setReturnValue(
SableFrameOrientation.getFrameOrientation(frame, partialTicks)
);
} }
@Inject( @Inject(
@@ -60,20 +54,38 @@ public abstract class EntityCustomOrientationMixin {
Entity entity, Entity entity,
CallbackInfoReturnable<Boolean> cir CallbackInfoReturnable<Boolean> cir
) { ) {
if (!(entity instanceof Player player)) { GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$isInLocalSimulation()) {
return; return;
} }
SableGravityState state = (SableGravityState) player; SubLevel frame = resolveFrame(entity);
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
if (frame == null || frame.isRemoved()) { if (frame == null || frame.isRemoved()) {
return; return;
} }
cir.setReturnValue(true); cir.setReturnValue(true);
} }
private static SubLevel resolveFrame(Entity entity) {
if (entity instanceof Player player) {
if (!player.isLocalPlayer()) {
return null;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled()) {
return null;
}
SubLevel attached = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (attached != null
&& !attached.isRemoved()
&& attached.getLevel() == player.level()) {
return attached;
}
return null;
}
return GravityFrames.resolveSable(entity)
.map(dev.nightly.sablegravity.physics.SableSubLevelGravityFrame::subLevel)
.orElse(null);
}
} }
@@ -0,0 +1,19 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.physics.DirectionalGravity;
import net.minecraft.world.entity.Entity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Covers modded calls to applyGravity that occur outside the wrapped entity tick. */
@Mixin(Entity.class)
public abstract class EntityDirectionalGravityMixin {
@Inject(method = "applyGravity", at = @At("HEAD"), cancellable = true)
private void sablegravity$applyFrameGravity(CallbackInfo ci) {
if (DirectionalGravity.apply((Entity) (Object) this)) {
ci.cancel();
}
}
}
@@ -0,0 +1,49 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.GravityEntityState;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import java.util.UUID;
/** Per-entity frame state. It is runtime state, not saved NBT: every load/warp re-resolves safely. */
@Mixin(Entity.class)
public abstract class EntityGravityStateMixin implements GravityEntityState {
@Unique private boolean sablegravity$inLocalSimulation;
@Unique private boolean sablegravity$localStateValid;
@Unique private Vec3 sablegravity$localPosition = Vec3.ZERO;
@Unique private Vec3 sablegravity$localVelocity = Vec3.ZERO;
@Unique private UUID sablegravity$frameId;
@Unique private ResourceLocation sablegravity$frameProvider;
@Unique private ResourceLocation sablegravity$frameDimension;
@Unique private boolean sablegravity$gravitySampleValid;
@Unique private double sablegravity$gravitySample;
@Unique private boolean sablegravity$frameHandoffPending;
@Unique private int sablegravity$frameHandoffExpiryTick;
@Override public boolean sablegravity$isInLocalSimulation() { return sablegravity$inLocalSimulation; }
@Override public void sablegravity$setInLocalSimulation(boolean value) { sablegravity$inLocalSimulation = value; }
@Override public boolean sablegravity$isLocalStateValid() { return sablegravity$localStateValid; }
@Override public void sablegravity$setLocalStateValid(boolean value) { sablegravity$localStateValid = value; }
@Override public Vec3 sablegravity$getLocalPosition() { return sablegravity$localPosition; }
@Override public void sablegravity$setLocalPosition(Vec3 value) { sablegravity$localPosition = value; }
@Override public Vec3 sablegravity$getLocalVelocity() { return sablegravity$localVelocity; }
@Override public void sablegravity$setLocalVelocity(Vec3 value) { sablegravity$localVelocity = value; }
@Override public UUID sablegravity$getFrameId() { return sablegravity$frameId; }
@Override public void sablegravity$setFrameId(UUID value) { sablegravity$frameId = value; }
@Override public ResourceLocation sablegravity$getFrameProvider() { return sablegravity$frameProvider; }
@Override public void sablegravity$setFrameProvider(ResourceLocation value) { sablegravity$frameProvider = value; }
@Override public ResourceLocation sablegravity$getFrameDimension() { return sablegravity$frameDimension; }
@Override public void sablegravity$setFrameDimension(ResourceLocation value) { sablegravity$frameDimension = value; }
@Override public boolean sablegravity$isGravitySampleValid() { return sablegravity$gravitySampleValid; }
@Override public void sablegravity$setGravitySampleValid(boolean value) { sablegravity$gravitySampleValid = value; }
@Override public double sablegravity$getGravitySample() { return sablegravity$gravitySample; }
@Override public void sablegravity$setGravitySample(double value) { sablegravity$gravitySample = value; }
@Override public boolean sablegravity$isFrameHandoffPending() { return sablegravity$frameHandoffPending; }
@Override public void sablegravity$setFrameHandoffPending(boolean value) { sablegravity$frameHandoffPending = value; }
@Override public int sablegravity$getFrameHandoffExpiryTick() { return sablegravity$frameHandoffExpiryTick; }
@Override public void sablegravity$setFrameHandoffExpiryTick(int value) { sablegravity$frameHandoffExpiryTick = value; }
}
@@ -0,0 +1,199 @@
package dev.nightly.sablegravity.mixin;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import dev.nightly.sablegravity.api.GravityFrame;
import dev.nightly.sablegravity.physics.GravityFrames;
import dev.nightly.sablegravity.physics.SableFrameOrientation;
import dev.nightly.sablegravity.physics.SableOrientedBounds;
import dev.nightly.sablegravity.physics.SableSubLevelGravityFrame;
import net.minecraft.core.BlockPos;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.animal.FlyingAnimal;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
/**
* Expresses ordinary living-entity locomotion, gravity and drag in the selected frame while the
* entity itself always remains in physical parent-world coordinates. Sable therefore stays the
* sole owner of OBB collision and entity packet coordinate conversion.
*/
@Mixin(value = LivingEntity.class, priority = 3000)
public abstract class LivingEntityDirectionalGravityMixin {
private static final double AIR_VERTICAL_DRAG = 0.9800000190734863D;
private static final double MOVEMENT_DAMPING = 0.91D;
private static final double EPSILON = 1.0E-10D;
@WrapOperation(
method = "handleRelativeFrictionAndCalculateMovement",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/world/entity/LivingEntity;moveRelative(FLnet/minecraft/world/phys/Vec3;)V"
)
)
private void sablegravity$accelerateInFramePlane(
LivingEntity self,
float speed,
Vec3 input,
Operation<Void> original
) {
if (self instanceof Player) {
original.call(self, speed, input);
return;
}
GravityFrame frame = GravityFrames.resolve(self).orElse(null);
if (frame == null) {
original.call(self, speed, input);
return;
}
Vec3 worldBefore = self.getDeltaMovement();
self.setDeltaMovement(frame.toLocalVelocity(worldBefore));
try {
// Vanilla yaw and travel input describe heading in the entity's local floor plane.
original.call(self, speed, input);
self.setDeltaMovement(frame.toWorldVelocity(self.getDeltaMovement()));
} catch (RuntimeException | Error failure) {
self.setDeltaMovement(worldBefore);
throw failure;
}
}
@WrapOperation(
method = "travel",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(DDD)V",
ordinal = 2
)
)
private void sablegravity$finishDiscardFrictionTravelInFrame(
LivingEntity self,
double vanillaX,
double vanillaY,
double vanillaZ,
Operation<Void> original
) {
sablegravity$finishOrdinaryTravel(
self,
vanillaX,
vanillaY,
vanillaZ,
original,
true
);
}
@WrapOperation(
method = "travel",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(DDD)V",
ordinal = 3
)
)
private void sablegravity$finishOrdinaryTravelInFrame(
LivingEntity self,
double vanillaX,
double vanillaY,
double vanillaZ,
Operation<Void> original
) {
sablegravity$finishOrdinaryTravel(
self,
vanillaX,
vanillaY,
vanillaZ,
original,
false
);
}
@Unique
private static void sablegravity$finishOrdinaryTravel(
LivingEntity self,
double vanillaX,
double vanillaY,
double vanillaZ,
Operation<Void> original,
boolean discardFriction
) {
if (self instanceof Player) {
original.call(self, vanillaX, vanillaY, vanillaZ);
return;
}
GravityFrame frame = GravityFrames.resolve(self).orElse(null);
if (frame == null) {
original.call(self, vanillaX, vanillaY, vanillaZ);
return;
}
Vec3 localVelocity = frame.toLocalVelocity(self.getDeltaMovement());
double localY = localVelocity.y;
if (self.hasEffect(MobEffects.LEVITATION)) {
int amplifier = self.getEffect(MobEffects.LEVITATION).getAmplifier();
localY += (0.05D * (amplifier + 1) - localY) * 0.2D;
} else {
double gravity = Math.max(0.0D, self.getGravity());
if (localY <= 0.0D && self.hasEffect(MobEffects.SLOW_FALLING)) {
gravity = Math.min(gravity, 0.01D);
self.fallDistance = 0.0F;
}
localY -= gravity;
}
double horizontalDrag = discardFriction
? 1.0D
: sablegravity$getHorizontalDrag(self, frame);
double verticalDrag = self instanceof FlyingAnimal
? horizontalDrag
: AIR_VERTICAL_DRAG;
Vec3 finalLocalVelocity = new Vec3(
localVelocity.x * horizontalDrag,
localY * verticalDrag,
localVelocity.z * horizontalDrag
);
Vec3 finalWorldVelocity = frame.toWorldVelocity(finalLocalVelocity);
original.call(
self,
finalWorldVelocity.x,
finalWorldVelocity.y,
finalWorldVelocity.z
);
}
@Unique
private static double sablegravity$getHorizontalDrag(
LivingEntity self,
GravityFrame frame
) {
if (!self.onGround()) {
return MOVEMENT_DAMPING;
}
Vec3 worldFeet = frame instanceof SableSubLevelGravityFrame sableFrame
? SableOrientedBounds.worldFeet(
self,
SableFrameOrientation.getFrameOrientation(sableFrame.subLevel(), 1.0F)
)
: self.position();
Vec3 localPosition = frame.toLocalPosition(worldFeet);
BlockPos localBelow = BlockPos.containing(
localPosition.x,
localPosition.y - 0.5000001D,
localPosition.z
);
var blockState = self.level().getBlockState(localBelow);
float friction = blockState.getFriction(self.level(), localBelow, self);
if (!Float.isFinite(friction) || friction <= EPSILON) {
friction = 0.6F;
}
return friction * MOVEMENT_DAMPING;
}
}
@@ -0,0 +1,12 @@
package dev.nightly.sablegravity.mixin;
import net.minecraft.world.entity.LivingEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
/** Exposes the normalized jump input already copied by LocalPlayer.serverAiStep. */
@Mixin(LivingEntity.class)
public interface LivingEntityInputAccessor {
@Accessor("jumping")
boolean sablegravity$isJumpingInput();
}
@@ -0,0 +1,29 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.player.Player;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Gives Shift its crouch pose and dimensions while creative flight uses it for local descent. */
@Mixin(targets = "net.minecraft.client.player.LocalPlayer", priority = 4000)
public abstract class LocalPlayerFlightCrouchMixin {
@Inject(method = "isCrouching", at = @At("HEAD"), cancellable = true)
private void sablegravity$crouchWhileFlying(CallbackInfoReturnable<Boolean> cir) {
Player player = (Player) (Object) this;
if (!player.getAbilities().flying
|| !player.isShiftKeyDown()
|| !((SableGravityState) player).sablegravity$isEnabled()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (frame != null && !frame.isRemoved() && frame.getLevel() == player.level()) {
cir.setReturnValue(true);
}
}
}
@@ -1,12 +1,12 @@
package dev.nightly.sablegravity.mixin; package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState; import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalCollisionProbe;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics; import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.companion.math.Pose3dc; import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.MoverType; import net.minecraft.world.entity.MoverType;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
@@ -14,10 +14,7 @@ import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** /** Replaces world-axis edge sneaking with a tracked-frame local collision-shape query. */
* Replaces vanilla/Sable edge sneaking with a full ship-local query.
* The synthetic AABB exists only in hidden plot coordinates and is never written to Entity.bb.
*/
@Mixin(value = Player.class, priority = 4000) @Mixin(value = Player.class, priority = 4000)
public abstract class PlayerEdgeSneakMixin { public abstract class PlayerEdgeSneakMixin {
private static final double STEP = 0.05D; private static final double STEP = 0.05D;
@@ -36,7 +33,6 @@ public abstract class PlayerEdgeSneakMixin {
|| !state.sablegravity$isEnabled() || !state.sablegravity$isEnabled()
|| player.getAbilities().flying || player.getAbilities().flying
|| !player.isShiftKeyDown() || !player.isShiftKeyDown()
|| !SableLocalPlayerPhysics.isLocalGrounded(player)
|| (moverType != MoverType.SELF && moverType != MoverType.PLAYER)) { || (moverType != MoverType.SELF && moverType != MoverType.PLAYER)) {
return; return;
} }
@@ -46,64 +42,68 @@ public abstract class PlayerEdgeSneakMixin {
return; return;
} }
Pose3dc pose = frame.logicalPose(); // The player position still belongs to lastPose here, but the requested displacement was
Vec3 localMovement = pose.transformNormalInverse(movement); // encoded by the local travel kernel with logicalPose. Decode/encode the vector with that
// same basis while probing the body's start-of-sweep feet from lastPose.
Pose3dc movementPose = frame.logicalPose();
Vec3 localMovement = movementPose.transformNormalInverse(movement);
if (localMovement.y > 0.0D) { if (localMovement.y > 0.0D) {
cir.setReturnValue(movement); cir.setReturnValue(movement);
return; return;
} }
Vec3 localFeet = pose.transformPositionInverse(player.position()); Vec3 localFeet = SableLocalCollisionProbe.getLocalFeetAtCollisionStart(player, frame);
double halfWidth = player.getBbWidth() * 0.5D; if (!SableLocalPlayerPhysics.isLocalGrounded(player)
double height = player.getBbHeight(); && !SableLocalCollisionProbe.hasSupport(
AABB localBounds = new AABB( player,
localFeet.x - halfWidth, frame,
localFeet.y, SableLocalCollisionProbe.createFloorProbe(player, localFeet, 0.08D)
localFeet.z - halfWidth, )) {
localFeet.x + halfWidth, return;
localFeet.y + height, }
localFeet.z + halfWidth AABB localBounds = SableLocalCollisionProbe.createLocalBody(player, localFeet);
);
Level level = player.level();
double fallDistance = player.maxUpStep(); double fallDistance = player.maxUpStep();
double x = localMovement.x; double x = localMovement.x;
double z = localMovement.z; double z = localMovement.z;
while (x != 0.0D && wouldSlideOff(level, player, localBounds, x, 0.0D, fallDistance)) { while (x != 0.0D && wouldSlideOff(player, frame, localBounds, x, 0.0D, fallDistance)) {
x = reduceTowardsZero(x); x = reduceTowardsZero(x);
} }
while (z != 0.0D && wouldSlideOff(level, player, localBounds, 0.0D, z, fallDistance)) { while (z != 0.0D && wouldSlideOff(player, frame, localBounds, 0.0D, z, fallDistance)) {
z = reduceTowardsZero(z); z = reduceTowardsZero(z);
} }
while (x != 0.0D while (x != 0.0D
&& z != 0.0D && z != 0.0D
&& wouldSlideOff(level, player, localBounds, x, z, fallDistance)) { && wouldSlideOff(player, frame, localBounds, x, z, fallDistance)) {
x = reduceTowardsZero(x); x = reduceTowardsZero(x);
z = reduceTowardsZero(z); z = reduceTowardsZero(z);
} }
cir.setReturnValue(pose.transformNormal(new Vec3(x, localMovement.y, z))); cir.setReturnValue(
movementPose.transformNormal(new Vec3(x, localMovement.y, z))
);
} }
private static boolean wouldSlideOff( private static boolean wouldSlideOff(
Level level,
Player player, Player player,
SubLevel frame,
AABB localBounds, AABB localBounds,
double localX, double localX,
double localZ, double localZ,
double fallDistance double fallDistance
) { ) {
AABB future = localBounds.move(localX, 0.0D, localZ); AABB future = localBounds.move(localX, 0.0D, localZ);
double inset = Math.min(1.0E-5D, Math.max(0.0D, future.getXsize() * 0.25D));
AABB floorProbe = new AABB( AABB floorProbe = new AABB(
future.minX, future.minX + inset,
future.minY - fallDistance - FLOOR_EPSILON, future.minY - fallDistance - FLOOR_EPSILON,
future.minZ, future.minZ + inset,
future.maxX, future.maxX - inset,
future.minY, future.minY + FLOOR_EPSILON,
future.maxZ future.maxZ - inset
); );
return level.noCollision(player, floorProbe); return !SableLocalCollisionProbe.hasSupport(player, frame, floorProbe);
} }
private static double reduceTowardsZero(double value) { private static double reduceTowardsZero(double value) {
@@ -0,0 +1,38 @@
package dev.nightly.sablegravity.mixin;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.Sable;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.player.Player;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
/** Lets local-frame creative descent use the real crouch pose and 1.5-block body. */
@Mixin(Player.class)
public abstract class PlayerFlightCrouchPoseMixin {
@ModifyExpressionValue(
method = "updatePlayerPose",
at = @At(
value = "FIELD",
target = "Lnet/minecraft/world/entity/player/Abilities;flying:Z"
)
)
private boolean sablegravity$allowCrouchPoseDuringLocalFlight(boolean flying) {
if (!flying) {
return false;
}
Player player = (Player) (Object) this;
if (!player.isShiftKeyDown()
|| !((SableGravityState) player).sablegravity$isEnabled()) {
return true;
}
SubLevel frame = player.isLocalPlayer()
? SableLocalPlayerPhysics.getAttachedFrameIfPresent(player)
: Sable.HELPER.getTrackingSubLevel(player);
return frame == null || frame.isRemoved() || frame.getLevel() != player.level();
}
}
@@ -6,8 +6,6 @@ import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers; import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData; import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import java.util.UUID;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.At;
@@ -24,12 +22,6 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
private static final EntityDataAccessor<Float> SABLEGRAVITY_STRENGTH = private static final EntityDataAccessor<Float> SABLEGRAVITY_STRENGTH =
SynchedEntityData.defineId(Player.class, EntityDataSerializers.FLOAT); SynchedEntityData.defineId(Player.class, EntityDataSerializers.FLOAT);
@Unique private boolean sablegravity$inLocalSimulation;
@Unique private boolean sablegravity$localStateValid;
@Unique private Vec3 sablegravity$localPosition = Vec3.ZERO;
@Unique private Vec3 sablegravity$localVelocity = Vec3.ZERO;
@Unique private UUID sablegravity$frameId;
@Inject(method = "defineSynchedData", at = @At("TAIL")) @Inject(method = "defineSynchedData", at = @At("TAIL"))
private void sablegravity$defineData(SynchedEntityData.Builder builder, CallbackInfo ci) { private void sablegravity$defineData(SynchedEntityData.Builder builder, CallbackInfo ci) {
builder.define(SABLEGRAVITY_ENABLED, false); builder.define(SABLEGRAVITY_ENABLED, false);
@@ -44,8 +36,10 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
@Override @Override
public void sablegravity$setEnabled(boolean enabled) { public void sablegravity$setEnabled(boolean enabled) {
((Player) (Object) this).getEntityData().set(SABLEGRAVITY_ENABLED, enabled); ((Player) (Object) this).getEntityData().set(SABLEGRAVITY_ENABLED, enabled);
sablegravity$localStateValid = false; sablegravity$setLocalStateValid(false);
sablegravity$frameId = null; sablegravity$setFrameId(null);
sablegravity$setFrameProvider(null);
sablegravity$setFrameDimension(null);
} }
@Override @Override
@@ -58,14 +52,4 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
((Player) (Object) this).getEntityData().set(SABLEGRAVITY_STRENGTH, strength); ((Player) (Object) this).getEntityData().set(SABLEGRAVITY_STRENGTH, strength);
} }
@Override public boolean sablegravity$isInLocalSimulation() { return sablegravity$inLocalSimulation; }
@Override public void sablegravity$setInLocalSimulation(boolean value) { sablegravity$inLocalSimulation = value; }
@Override public boolean sablegravity$isLocalStateValid() { return sablegravity$localStateValid; }
@Override public void sablegravity$setLocalStateValid(boolean value) { sablegravity$localStateValid = value; }
@Override public Vec3 sablegravity$getLocalPosition() { return sablegravity$localPosition; }
@Override public void sablegravity$setLocalPosition(Vec3 value) { sablegravity$localPosition = value; }
@Override public Vec3 sablegravity$getLocalVelocity() { return sablegravity$localVelocity; }
@Override public void sablegravity$setLocalVelocity(Vec3 value) { sablegravity$localVelocity = value; }
@Override public UUID sablegravity$getFrameId() { return sablegravity$frameId; }
@Override public void sablegravity$setFrameId(UUID value) { sablegravity$frameId = value; }
} }
@@ -0,0 +1,51 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableFrameOrientation;
import dev.nightly.sablegravity.physics.SableLocalCollisionProbe;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.nightly.sablegravity.physics.SableOrientedBounds;
import dev.nightly.sablegravity.physics.SableParentWorldCollision;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniondc;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Uses the exact proposed OBB for crouch/stand clearance instead of an upright temporary AABB. */
@Mixin(value = Player.class, priority = 4500)
public abstract class PlayerPoseFitMixin {
@Inject(method = "canPlayerFitWithinBlocksAndEntitiesWhen", at = @At("HEAD"), cancellable = true)
private void sablegravity$canOrientedBodyFit(
Pose pose,
CallbackInfoReturnable<Boolean> cir
) {
Player player = (Player) (Object) this;
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || !player.isLocalPlayer()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (frame == null || frame.isRemoved() || frame.getLevel() != player.level()) {
return;
}
Quaterniondc orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F);
Vec3 physicalFeet = SableOrientedBounds.worldFeet(player, orientation);
EntityDimensions dimensions = player.getDimensions(pose);
boolean plotFit = SableLocalCollisionProbe.canFit(player, frame, dimensions);
boolean parentFit = SableParentWorldCollision.canFit(
player,
physicalFeet,
dimensions,
orientation
);
cir.setReturnValue(plotFit && parentFit);
}
}
@@ -0,0 +1,133 @@
package dev.nightly.sablegravity.mixin;
import dev.nightly.sablegravity.GravityEntityState;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.ActiveSableCompanion;
import dev.ryanhcode.sable.api.math.LevelReusedVectors;
import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniondc;
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;
/**
* Prevents Sable from stacking its residual world-Y hitbox yaw on top of our complete
* quaternion orientation.
*
* The vanilla Sable path computes getHitBoxYaw() from a reusable pose before that pose is
* updated for the current substep. That is harmless for ordinary upright entities, but a
* custom full-orientation OBB would receive both the complete frame quaternion and this
* stale extra yaw. For gravity players the custom quaternion is authoritative, so yaw=0.
*/
@Mixin(value = SubLevelEntityCollision.class, priority = 3500, remap = false)
public abstract class SableCollisionYawMixin {
@Redirect(
method = "collide",
at = @At(
value = "INVOKE",
target = "Ldev/ryanhcode/sable/sublevel/entity_collision/SubLevelEntityCollision;getHitBoxYaw(Ldev/ryanhcode/sable/companion/math/Pose3dc;)D"
),
remap = false
)
private static double sablegravity$useAuthoritativeCustomOrientation(
Pose3dc pose,
Entity entity,
Vec3 collisionMotion,
Vec3 velocityMotion,
LevelReusedVectors sink
) {
if (entity instanceof Player player) {
SableGravityState state = (SableGravityState) player;
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrameIfPresent(player);
if (state.sablegravity$isEnabled()
&& !state.sablegravity$isInLocalSimulation()
&& frame != null
&& !frame.isRemoved()) {
return 0.0D;
}
} else {
GravityEntityState state = (GravityEntityState) entity;
if (!state.sablegravity$isInLocalSimulation()
&& state.sablegravity$getFrameId() != null) {
return 0.0D;
}
}
return SubLevelEntityCollision.getHitBoxYaw(pose);
}
/** Rotates non-player bodies around their invariant raw feet position, never around the eye. */
@Inject(
method = "transformEntityBoundsCenter",
at = @At("HEAD"),
cancellable = true,
remap = false
)
private static void sablegravity$useFeetAnchoredBodyCenter(
LevelReusedVectors sink,
Quaterniondc orientation,
Entity entity,
Vector3d center,
CallbackInfo ci
) {
if (entity instanceof Player || orientation == null) {
return;
}
GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$isInLocalSimulation()
|| state.sablegravity$getFrameId() == null) {
return;
}
double halfHeight = entity.getBoundingBox().getYsize() * 0.5D;
Vector3d offset = orientation.transform(
new Vector3d(0.0D, halfHeight, 0.0D),
new Vector3d()
);
center.set(
entity.getX() + offset.x,
entity.getY() + offset.y,
entity.getZ() + offset.z
);
ci.cancel();
}
/** Keeps Sable's inherited-motion samples on the same feet-anchored convention. */
@Redirect(
method = "collide",
at = @At(
value = "INVOKE",
target = "Ldev/ryanhcode/sable/ActiveSableCompanion;getFeetPos(Lnet/minecraft/world/entity/Entity;FLorg/joml/Quaterniondc;)Lorg/joml/Vector3d;"
),
remap = false
)
private static Vector3d sablegravity$getFeetPosWithoutEyeOffset(
ActiveSableCompanion companion,
Entity entity,
float partialTicks,
Quaterniondc orientation
) {
if (!(entity instanceof Player) && orientation != null) {
GravityEntityState state = (GravityEntityState) entity;
if (!state.sablegravity$isInLocalSimulation()
&& state.sablegravity$getFrameId() != null) {
return new Vector3d(
entity.getX(),
entity.getY() - partialTicks,
entity.getZ()
);
}
}
return companion.getFeetPos(entity, partialTicks, orientation);
}
}
@@ -0,0 +1,31 @@
package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.GravityEntityState;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
/** Applies non-player gravity along the selected frame without changing coordinate spaces. */
public final class DirectionalGravity {
private DirectionalGravity() {}
public static boolean apply(Entity entity) {
GravityEntityState state = (GravityEntityState) entity;
if (entity instanceof Player || state.sablegravity$isInLocalSimulation()) {
return false;
}
var frame = GravityFrames.resolve(entity).orElse(null);
if (frame == null) {
return false;
}
double magnitude = frame.gravityPerTick(entity);
if (magnitude == 0.0D) {
return true;
}
Vec3 down = frame.down(1.0F);
entity.setDeltaMovement(entity.getDeltaMovement().add(down.scale(magnitude)));
return true;
}
}
@@ -0,0 +1,190 @@
package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.GravityEntityState;
import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.api.GravityFrame;
import dev.nightly.sablegravity.api.GravityFrameId;
import dev.nightly.sablegravity.api.GravityFrameProvider;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
/** Thread-safe provider registry and the only place where a gravity frame is selected. */
public final class GravityFrames {
private static final CopyOnWriteArrayList<Entry> PROVIDERS = new CopyOnWriteArrayList<>();
private static final AtomicLong NEXT_ORDER = new AtomicLong();
static {
register(new SableSubLevelFrameProvider(), 0);
}
private GravityFrames() {}
public static void register(GravityFrameProvider provider, int priority) {
if (provider == null) {
throw new IllegalArgumentException("provider cannot be null");
}
PROVIDERS.add(new Entry(provider, priority, NEXT_ORDER.getAndIncrement()));
PROVIDERS.sort(
Comparator.comparingInt(Entry::priority).reversed()
.thenComparingLong(Entry::order)
);
}
public static Optional<GravityFrame> resolve(Entity entity) {
if (!isEnabledFor(entity)) {
clearBinding(entity);
return Optional.empty();
}
GravityFrameId previous = previousId(entity);
GravityEntityState state = (GravityEntityState) entity;
if (previous != null
&& !previous.dimension().equals(entity.level().dimension().location())
&& state.sablegravity$isLocalStateValid()) {
markDimensionHandoff(entity);
}
for (Entry entry : PROVIDERS) {
try {
Optional<? extends GravityFrame> result =
entry.provider().resolve(entity, previous);
if (result.isPresent() && validFor(result.get(), entity.level())) {
GravityFrame frame = result.get();
bind(entity, frame.id());
return Optional.of(frame);
}
} catch (RuntimeException exception) {
SableGravityMod.LOGGER.warn(
"Gravity frame provider {} failed for {}",
entry.provider().getClass().getName(),
entity,
exception
);
}
}
clearBinding(entity);
return Optional.empty();
}
public static Optional<GravityFrame> resolveAt(
Level level,
Vec3 position,
GravityFrameId previous
) {
for (Entry entry : PROVIDERS) {
try {
Optional<? extends GravityFrame> result =
entry.provider().resolveAt(level, position, previous);
if (result.isPresent() && validFor(result.get(), level)) {
return Optional.of(result.get());
}
} catch (RuntimeException exception) {
SableGravityMod.LOGGER.warn(
"Gravity frame provider {} failed at {} in {}",
entry.provider().getClass().getName(),
position,
level.dimension().location(),
exception
);
}
}
return Optional.empty();
}
public static Optional<SableSubLevelGravityFrame> resolveSable(Entity entity) {
return resolve(entity)
.filter(SableSubLevelGravityFrame.class::isInstance)
.map(SableSubLevelGravityFrame.class::cast);
}
private static boolean isEnabledFor(Entity entity) {
if (entity instanceof Player player) {
return ((SableGravityState) player).sablegravity$isEnabled();
}
return true;
}
private static boolean validFor(GravityFrame frame, Level level) {
return frame.isValid() && frame.level() == level;
}
private static GravityFrameId previousId(Entity entity) {
GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$getFrameId() == null
|| state.sablegravity$getFrameProvider() == null
|| state.sablegravity$getFrameDimension() == null) {
return null;
}
return new GravityFrameId(
state.sablegravity$getFrameProvider(),
state.sablegravity$getFrameDimension(),
state.sablegravity$getFrameId()
);
}
private static void bind(Entity entity, GravityFrameId id) {
GravityEntityState state = (GravityEntityState) entity;
boolean changed = !id.localId().equals(state.sablegravity$getFrameId())
|| !id.provider().equals(state.sablegravity$getFrameProvider())
|| !id.dimension().equals(state.sablegravity$getFrameDimension());
state.sablegravity$setFrameId(id.localId());
state.sablegravity$setFrameProvider(id.provider());
state.sablegravity$setFrameDimension(id.dimension());
if (changed) {
state.sablegravity$setLocalStateValid(false);
}
}
public static void clearBinding(Entity entity) {
GravityEntityState state = (GravityEntityState) entity;
state.sablegravity$setFrameId(null);
state.sablegravity$setFrameProvider(null);
state.sablegravity$setFrameDimension(null);
state.sablegravity$setLocalStateValid(false);
}
public static void markDimensionHandoff(Entity entity) {
GravityEntityState state = (GravityEntityState) entity;
if (!state.sablegravity$isLocalStateValid()) {
return;
}
state.sablegravity$setFrameHandoffPending(true);
state.sablegravity$setFrameHandoffExpiryTick(entity.tickCount + 100);
}
public static boolean consumeDimensionHandoff(Entity entity, GravityFrame destination) {
GravityEntityState state = (GravityEntityState) entity;
if (!state.sablegravity$isFrameHandoffPending()) {
return false;
}
state.sablegravity$setFrameHandoffPending(false);
if (entity.tickCount > state.sablegravity$getFrameHandoffExpiryTick()) {
return false;
}
Vec3 localVelocity = state.sablegravity$getLocalVelocity();
if (localVelocity == null
|| !Double.isFinite(localVelocity.x)
|| !Double.isFinite(localVelocity.y)
|| !Double.isFinite(localVelocity.z)) {
return false;
}
entity.setDeltaMovement(destination.toWorldVelocity(localVelocity));
return true;
}
public static List<GravityFrameProvider> providers() {
return PROVIDERS.stream().map(Entry::provider).toList();
}
private record Entry(GravityFrameProvider provider, int priority, long order) {}
}
@@ -0,0 +1,27 @@
package dev.nightly.sablegravity.physics;
import dev.ryanhcode.sable.sublevel.SubLevel;
import org.joml.Quaterniond;
/**
* Produces the exact player-body orientation for each of Sable's collision substeps.
*
* Sable sweeps the local player in eight interpolated poses. Returning the logical-pose
* endpoint for every request makes the player OBB rotate ahead of the ship collision OBB,
* so SAT resolves a different penetration direction on every substep. The interpolation
* here keeps both bodies in the same rotating frame for arbitrary quaternions.
*/
public final class SableFrameOrientation {
private SableFrameOrientation() {}
public static Quaterniond getFrameOrientation(SubLevel frame, float partialTicks) {
double t = clamp(partialTicks, 0.0D, 1.0D);
return new Quaterniond(frame.lastPose().orientation())
.slerp(frame.logicalPose().orientation(), t)
.normalize();
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,156 @@
package dev.nightly.sablegravity.physics;
import dev.ryanhcode.sable.physics.impl.SubLevelEntityCollisionContext;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.util.LevelAccelerator;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.level.block.ScaffoldingBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** Collision probes expressed entirely in the tracked SubLevel's local block coordinates. */
public final class SableLocalCollisionProbe {
private static final double BLOCK_RANGE_EPSILON = 1.0E-7D;
private static final double FOOTPRINT_INSET = 1.0E-5D;
private static final double FLOOR_EPSILON = 1.0E-5D;
private SableLocalCollisionProbe() {}
/** Returns the physical oriented feet position at the current logical pose. */
public static Vec3 getLocalFeet(Player player, SubLevel frame) {
return SableOrientedBounds.localFeet(
player,
frame.logicalPose(),
SableFrameOrientation.getFrameOrientation(frame, 1.0F)
);
}
/** Returns physical feet at the start of Sable's next lastPose -> logicalPose sweep. */
public static Vec3 getLocalFeetAtCollisionStart(Player player, SubLevel frame) {
return SableOrientedBounds.localFeet(
player,
frame.lastPose(),
SableFrameOrientation.getFrameOrientation(frame, 0.0F)
);
}
public static AABB createLocalBody(Player player, Vec3 localFeet) {
double halfWidth = player.getBbWidth() * 0.5D;
return new AABB(
localFeet.x - halfWidth,
localFeet.y,
localFeet.z - halfWidth,
localFeet.x + halfWidth,
localFeet.y + player.getBbHeight(),
localFeet.z + halfWidth
);
}
/** Thin support slab directly beneath the body footprint, inset to reject adjacent walls. */
public static AABB createFloorProbe(Player player, Vec3 localFeet, double depth) {
double halfWidth = Math.max(
0.0D,
player.getBbWidth() * 0.5D - FOOTPRINT_INSET
);
return new AABB(
localFeet.x - halfWidth,
localFeet.y - Math.max(0.0D, depth) - FLOOR_EPSILON,
localFeet.z - halfWidth,
localFeet.x + halfWidth,
localFeet.y + FLOOR_EPSILON,
localFeet.z + halfWidth
);
}
public static boolean hasSupportAtCollisionStart(Player player, SubLevel frame) {
Vec3 localFeet = getLocalFeetAtCollisionStart(player, frame);
return hasSupport(player, frame, createFloorProbe(player, localFeet, 0.08D));
}
public static boolean hasSupportNow(Player player, SubLevel frame) {
Vec3 localFeet = getLocalFeet(player, frame);
return hasSupport(player, frame, createFloorProbe(player, localFeet, 0.08D));
}
/** Exact pose-fit test in plot coordinates; Entity.bb never participates. */
public static boolean canFit(Player player, SubLevel frame, EntityDimensions dimensions) {
return canFitAtLocalFeet(player, frame, dimensions, getLocalFeet(player, frame));
}
/** Exact pose-fit test at an explicit plot-local feet point. */
public static boolean canFitAtLocalFeet(
Player player,
SubLevel frame,
EntityDimensions dimensions,
Vec3 localFeet
) {
double halfWidth = Math.max(0.0D, dimensions.width() * 0.5D - FLOOR_EPSILON);
double height = Math.max(0.0D, dimensions.height() - FLOOR_EPSILON * 2.0D);
AABB localBody = new AABB(
localFeet.x - halfWidth,
localFeet.y + FLOOR_EPSILON,
localFeet.z - halfWidth,
localFeet.x + halfWidth,
localFeet.y + FLOOR_EPSILON + height,
localFeet.z + halfWidth
);
return !intersectsCollisionShape(player, frame, localBody);
}
/** True when at least one tracked-frame collision shape overlaps the local probe. */
public static boolean hasSupport(Player player, SubLevel frame, AABB localProbe) {
return intersectsCollisionShape(player, frame, localProbe);
}
private static boolean intersectsCollisionShape(
Player player,
SubLevel frame,
AABB localProbe
) {
LevelAccelerator level = new LevelAccelerator(player.level());
int minX = Mth.floor(localProbe.minX);
int minY = Mth.floor(localProbe.minY);
int minZ = Mth.floor(localProbe.minZ);
int maxX = Mth.floor(localProbe.maxX - BLOCK_RANGE_EPSILON);
int maxY = Mth.floor(localProbe.maxY - BLOCK_RANGE_EPSILON);
int maxZ = Mth.floor(localProbe.maxZ - BLOCK_RANGE_EPSILON);
if (maxX < minX || maxY < minY || maxZ < minZ) {
return false;
}
for (BlockPos pos : BlockPos.betweenClosed(minX, minY, minZ, maxX, maxY, maxZ)) {
BlockState state = level.getBlockState(pos);
if (state.isAir()) {
continue;
}
VoxelShape shape;
if (state.getBlock() instanceof ScaffoldingBlock) {
shape = state.getCollisionShape(
level,
pos,
new SubLevelEntityCollisionContext(player)
);
} else {
shape = state.getCollisionShape(level, pos, CollisionContext.of(player));
}
for (AABB box : shape.toAabbs()) {
if (localProbe.intersects(box.move(pos.getX(), pos.getY(), pos.getZ()))) {
return true;
}
}
}
return false;
}
}
@@ -2,42 +2,64 @@ package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.SableGravityMod; import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.SableGravityState; import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.mixin.LivingEntityInputAccessor;
import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.Sable;
import dev.ryanhcode.sable.companion.math.Pose3dc; import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.mixinterface.EntityExtension;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension; import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LivingEntityMovementExtension;
import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision; import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
import net.minecraft.world.entity.MoverType; import net.minecraft.world.entity.MoverType;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Abilities; import net.minecraft.world.entity.player.Abilities;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.joml.Vector3d;
import org.joml.Vector3dc;
import java.util.Collections; import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map; import java.util.Map;
import java.util.WeakHashMap;
/** /**
* Player movement in a SubLevel-local basis while the real entity remains in parent-world space. * Player movement in a SubLevel-local basis while the real entity remains in parent-world space.
* Sable remains the sole owner of OBB collision and inherited ship motion. * Sable owns the OBB sweep; this class owns the custom-orientation foot anchor and frame carry.
*/ */
public final class SableLocalPlayerPhysics { public final class SableLocalPlayerPhysics {
private static final double EPSILON = 1.0E-7D; private static final double EPSILON = 1.0E-7D;
private static final double COLLISION_EPSILON = 1.0E-5D; private static final double COLLISION_EPSILON = 1.0E-5D;
private static final double BLOCKED_MOTION_RATIO = 0.20D;
private static final double POSITION_CONSTRAINED_MOTION_RATIO = 0.80D;
private static final double VANILLA_GRAVITY_PER_TICK = 0.08D; private static final double VANILLA_GRAVITY_PER_TICK = 0.08D;
private static final double AIR_DRAG = 0.98D; private static final double AIR_DRAG = 0.98D;
private static final double AIR_HORIZONTAL_FRICTION = 0.91D; private static final double AIR_HORIZONTAL_FRICTION = 0.91D;
/**
* Normal-block horizontal damping used by vanilla: slipperiness 0.6 * movement damping 0.91.
*
* Do not feed arbitrary modded Block#getFriction() values directly into the vanilla
* acceleration compensation formula. Values below 0.4 create enormous input impulses and
* values above 1 / 0.91 make damping exceed one, which becomes exponential acceleration.
* Block-specific movement needs the complete vanilla speed-factor/slipperiness hook chain;
* until that chain is reproduced, the stable normal-block pair is the only safe contract.
*/
private static final double DEFAULT_GROUND_FRICTION = 0.6D * 0.91D; private static final double DEFAULT_GROUND_FRICTION = 0.6D * 0.91D;
private static final double FLIGHT_VERTICAL_DRAG = 0.6D; private static final double FLIGHT_VERTICAL_DRAG = 0.6D;
private static final double MAX_CAPTURED_FLIGHT_INPUT = 0.35D;
private static final double GROUND_STATIC_FRICTION_LIMIT = 0.03D; private static final double GROUND_STATIC_FRICTION_LIMIT = 0.03D;
private static final float AIR_ACCELERATION = 0.02F; private static final float AIR_ACCELERATION = 0.02F;
private static final Map<Player, SubLevel> ATTACHED_FRAMES = private static final Map<Player, SubLevel> ATTACHED_FRAMES =
Collections.synchronizedMap(new WeakHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
private static final Map<Player, Boolean> LOCAL_GROUNDED = private static final Map<Player, Boolean> LOCAL_GROUNDED =
Collections.synchronizedMap(new WeakHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
private static final Map<Player, Vec3> LAST_POST_TICK_VELOCITY = private static final Map<Player, Vec3> LAST_POST_TICK_VELOCITY =
Collections.synchronizedMap(new WeakHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
private static final Map<Player, Vec3> LAST_FRAME_MOTION =
Collections.synchronizedMap(new IdentityHashMap<>());
private static final Map<Player, Vec3> LAST_SABLE_INHERITED_MOTION =
Collections.synchronizedMap(new IdentityHashMap<>());
private static final Map<Player, Boolean> WAS_FLYING =
Collections.synchronizedMap(new IdentityHashMap<>());
private SableLocalPlayerPhysics() {} private SableLocalPlayerPhysics() {}
@@ -49,55 +71,155 @@ public final class SableLocalPlayerPhysics {
return null; return null;
} }
SubLevel latched = ATTACHED_FRAMES.get(player); // Read Sable's result before restoring our latch. Sable changes this field when the player
if (latched != null) { // lands on a different ship. The previous implementation immediately wrote the old frame
if (latched.isRemoved() || latched.getLevel() != player.level()) { // back and could therefore keep collision, gravity and Shift attached to the wrong ship.
detach(player); SubLevel tracked = validFrame(player, Sable.HELPER.getTrackingSubLevel(player));
latched = null; SubLevel rawLatched = ATTACHED_FRAMES.get(player);
} else if (state.sablegravity$getFrameId() != null SubLevel latched = validFrame(player, rawLatched);
&& state.sablegravity$getFrameId().equals(latched.getUniqueId())) {
((EntityMovementExtension) player).sable$setTrackingSubLevel(latched); if (latched == null && ATTACHED_FRAMES.containsKey(player)) {
return latched; if (rawLatched != null
} else { && rawLatched.getLevel() != player.level()
ATTACHED_FRAMES.remove(player); && state.sablegravity$isLocalStateValid()) {
latched = null; GravityFrames.markDimensionHandoff(player);
} }
detach(player);
} }
SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player); if (tracked != null && !sameFrame(tracked, latched)) {
if (tracked == null || tracked.isRemoved() || tracked.getLevel() != player.level()) { return attach(player, state, tracked);
}
if (latched != null) {
if (state.sablegravity$getFrameId() == null
|| !state.sablegravity$getFrameId().equals(latched.getUniqueId())) {
state.sablegravity$setFrameId(latched.getUniqueId());
state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID);
state.sablegravity$setFrameDimension(player.level().dimension().location());
state.sablegravity$setLocalStateValid(false);
}
((EntityMovementExtension) player).sable$setTrackingSubLevel(latched);
return latched;
}
if (tracked == null) {
return null; return null;
} }
return attach(player, state, tracked);
}
ATTACHED_FRAMES.put(player, tracked);
LOCAL_GROUNDED.put(player, player.onGround()); private static boolean sameFrame(SubLevel first, SubLevel second) {
state.sablegravity$setFrameId(tracked.getUniqueId()); return first != null
state.sablegravity$setLocalStateValid(false); && second != null
state.sablegravity$setLocalPosition(tracked.logicalPose().transformPositionInverse(player.position())); && first.getUniqueId().equals(second.getUniqueId());
((EntityMovementExtension) player).sable$setTrackingSubLevel(tracked); }
return tracked;
private static SubLevel validFrame(Player player, SubLevel frame) {
if (frame == null || frame.isRemoved() || frame.getLevel() != player.level()) {
return null;
}
return frame;
}
private static SubLevel attach(
Player player,
SableGravityState state,
SubLevel frame
) {
SubLevel previousFrame = validFrame(player, ATTACHED_FRAMES.get(player));
// Sable's custom-orientation OBB is rotated around the eye, while vanilla stores the raw
// entity position at the unrotated feet. Switching the quaternion without rebasing the raw
// position moves the physical OBB bottom by up to twice the eye height. That exact offset
// was then reported as inheritedMotion (2.32 blocks in the supplied log) and launched the
// player. Preserve the physical feet point and change only the raw eye-anchor position.
Vec3 physicalFeet;
if (previousFrame != null) {
physicalFeet = SableOrientedBounds.worldFeet(
player,
SableFrameOrientation.getFrameOrientation(previousFrame, 1.0F)
);
} else {
physicalFeet = player.position();
}
Vec3 rebasedRawPosition = SableOrientedBounds.rawPositionForFeet(
player,
physicalFeet,
SableFrameOrientation.getFrameOrientation(frame, 1.0F)
);
if (rebasedRawPosition.distanceToSqr(player.position()) > EPSILON) {
player.setPos(rebasedRawPosition);
}
ATTACHED_FRAMES.put(player, frame);
LAST_FRAME_MOTION.put(player, Vec3.ZERO);
LAST_SABLE_INHERITED_MOTION.remove(player);
LAST_POST_TICK_VELOCITY.remove(player);
WAS_FLYING.put(player, player.getAbilities().flying);
((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero();
Vec3 localFeet = frame.logicalPose().transformPositionInverse(physicalFeet);
boolean supported = SableLocalCollisionProbe.hasSupportNow(player, frame);
Vec3 handoffLocalVelocity = state.sablegravity$getLocalVelocity();
boolean consumedHandoff = GravityFrames.consumeDimensionHandoff(
player,
new SableSubLevelGravityFrame(frame)
);
Vec3 initialLocalVelocity = consumedHandoff
? handoffLocalVelocity
: supported
? Vec3.ZERO
: frame.logicalPose().transformNormalInverse(player.getDeltaMovement());
state.sablegravity$setFrameId(frame.getUniqueId());
state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID);
state.sablegravity$setFrameDimension(player.level().dimension().location());
state.sablegravity$setLocalPosition(localFeet);
state.sablegravity$setLocalVelocity(initialLocalVelocity);
state.sablegravity$setLocalStateValid(true);
player.setDeltaMovement(frame.logicalPose().transformNormal(initialLocalVelocity));
LOCAL_GROUNDED.put(player, supported);
((EntityMovementExtension) player).sable$setTrackingSubLevel(frame);
return frame;
} }
public static SubLevel getAttachedFrame(Player player) { public static SubLevel getAttachedFrame(Player player) {
return resolveFrame(player); return resolveFrame(player);
} }
/** Returns the already latched frame without querying or mutating Sable tracking state. */
public static SubLevel getAttachedFrameIfPresent(Player player) {
return ATTACHED_FRAMES.get(player);
}
public static boolean isLocalGrounded(Player player) { public static boolean isLocalGrounded(Player player) {
return Boolean.TRUE.equals(LOCAL_GROUNDED.get(player)); return Boolean.TRUE.equals(LOCAL_GROUNDED.get(player));
} }
/** Called at LocalPlayer.aiStep HEAD so vanilla cannot cancel flight from a stale ground flag. */ public static Vec3 getLastFrameMotion(Player player) {
return LAST_FRAME_MOTION.getOrDefault(player, Vec3.ZERO);
}
public static Vec3 getLastRejectedSableInheritedMotion(Player player) {
return LAST_SABLE_INHERITED_MOTION.get(player);
}
/** Restores the previous local support flag before vanilla processes jump/flight toggles. */
public static void beforeLocalPlayerTick(Player player) { public static void beforeLocalPlayerTick(Player player) {
if (!player.getAbilities().flying) {
return;
}
SableGravityState state = (SableGravityState) player; SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled()) { if (!state.sablegravity$isEnabled()) {
return; return;
} }
SubLevel frame = getAttachedFrame(player); SubLevel frame = getAttachedFrame(player);
if (frame != null && !frame.isRemoved()) { if (frame != null && !frame.isRemoved()) {
player.setOnGround(false); // LocalPlayer consumes onGround before travel for both normal jumping and creative
// flight toggles. Feed it the frame-local result from the previous sweep. In
// particular, do not force this false while flying: vanilla intentionally ends
// creative flight when the player touches the local floor.
player.setOnGround(isLocalGrounded(player));
} }
} }
@@ -108,20 +230,38 @@ public final class SableLocalPlayerPhysics {
LAST_POST_TICK_VELOCITY.remove(player); LAST_POST_TICK_VELOCITY.remove(player);
return; return;
} }
if (player.getAbilities().flying) {
player.setOnGround(false);
}
LAST_POST_TICK_VELOCITY.put(player, player.getDeltaMovement()); LAST_POST_TICK_VELOCITY.put(player, player.getDeltaMovement());
} }
public static void detach(Player player) { public static void detach(Player player) {
SubLevel attached = ATTACHED_FRAMES.get(player);
if (attached != null
&& !attached.isRemoved()
&& attached.getLevel() == player.level()) {
// Undo the eye-anchor raw-position rebase before vanilla resumes treating position()
// as the physical feet. Otherwise toggling gravity off visibly teleports the player.
Vec3 physicalFeet = SableOrientedBounds.worldFeet(
player,
SableFrameOrientation.getFrameOrientation(attached, 1.0F)
);
if (isFinite(physicalFeet)) {
player.setPos(physicalFeet);
}
}
ATTACHED_FRAMES.remove(player); ATTACHED_FRAMES.remove(player);
LOCAL_GROUNDED.remove(player); LOCAL_GROUNDED.remove(player);
LAST_POST_TICK_VELOCITY.remove(player); LAST_POST_TICK_VELOCITY.remove(player);
LAST_FRAME_MOTION.remove(player);
LAST_SABLE_INHERITED_MOTION.remove(player);
WAS_FLYING.remove(player);
((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero();
SableGravityState state = (SableGravityState) player; SableGravityState state = (SableGravityState) player;
state.sablegravity$setLocalStateValid(false); state.sablegravity$setLocalStateValid(false);
state.sablegravity$setLocalPosition(Vec3.ZERO); state.sablegravity$setLocalPosition(Vec3.ZERO);
state.sablegravity$setFrameId(null); state.sablegravity$setFrameId(null);
state.sablegravity$setFrameProvider(null);
state.sablegravity$setFrameDimension(null);
} }
public static boolean simulate(Player player, Vec3 input, SubLevel frame) { public static boolean simulate(Player player, Vec3 input, SubLevel frame) {
@@ -130,7 +270,11 @@ public final class SableLocalPlayerPhysics {
EntityMovementExtension movement = (EntityMovementExtension) player; EntityMovementExtension movement = (EntityMovementExtension) player;
Abilities abilities = player.getAbilities(); Abilities abilities = player.getAbilities();
boolean flying = abilities.flying; boolean flying = abilities.flying;
boolean wasFlying = Boolean.TRUE.equals(WAS_FLYING.get(player));
boolean groundedBeforeMove = isLocalGrounded(player); boolean groundedBeforeMove = isLocalGrounded(player);
double flightControlImpulse = flying
? getFlightControlImpulse(player, abilities)
: 0.0D;
Vec3 localVelocity; Vec3 localVelocity;
if (!state.sablegravity$isLocalStateValid() if (!state.sablegravity$isLocalStateValid()
@@ -140,47 +284,76 @@ public final class SableLocalPlayerPhysics {
state.sablegravity$setLocalVelocity(localVelocity); state.sablegravity$setLocalVelocity(localVelocity);
state.sablegravity$setLocalStateValid(true); state.sablegravity$setLocalStateValid(true);
state.sablegravity$setFrameId(frame.getUniqueId()); state.sablegravity$setFrameId(frame.getUniqueId());
state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position())); state.sablegravity$setFrameProvider(SableSubLevelGravityFrame.PROVIDER_ID);
state.sablegravity$setFrameDimension(player.level().dimension().location());
state.sablegravity$setLocalPosition(
SableLocalCollisionProbe.getLocalFeet(player, frame)
);
} else { } else {
localVelocity = state.sablegravity$getLocalVelocity(); localVelocity = state.sablegravity$getLocalVelocity();
if (flying) { // Import external world-space changes only for ordinary movement. During creative
// LocalPlayer adds creative ascend/descend to deltaMovement before travel. // flight LocalPlayer and Sable both rewrite deltaMovement as bookkeeping, and
// Compare against the exact final velocity captured at the previous aiStep TAIL, // hasImpulse also remains set across jump/pose transitions. Treating those writes as
// not against a velocity rotated by the current ship pose. This prevents ship // knockback creates a false local X/Z acceleration. Flight controls and the initial
// rotation from being mistaken for flight input and creating runaway acceleration. // jump are already written directly to local state by their dedicated paths.
Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player); Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player);
if (previousFinalWorld != null) { if (!flying && !wasFlying && previousFinalWorld != null) {
Vec3 preTravelChangeWorld = player.getDeltaMovement().subtract(previousFinalWorld); Vec3 externalWorldChange = player.getDeltaMovement().subtract(previousFinalWorld);
if (preTravelChangeWorld.lengthSqr() > EPSILON) { if (externalWorldChange.lengthSqr() > EPSILON) {
Vec3 preTravelChangeLocal = pose.transformNormalInverse(preTravelChangeWorld); Vec3 externalLocalChange = pose.transformNormalInverse(externalWorldChange);
double verticalInput = clamp( localVelocity = localVelocity.add(externalLocalChange);
preTravelChangeLocal.y,
-MAX_CAPTURED_FLIGHT_INPUT,
MAX_CAPTURED_FLIGHT_INPUT
);
if (Math.abs(verticalInput) > EPSILON) {
localVelocity = localVelocity.add(0.0D, verticalInput, 0.0D);
}
}
} }
} }
} }
if (flying && Math.abs(flightControlImpulse) > EPSILON) {
localVelocity = localVelocity.add(0.0D, flightControlImpulse, 0.0D);
}
Vec3 frameAnchorLocal = state.sablegravity$getLocalPosition();
Vec3 frameMotion = computeFrameCorrection(player, frame, frameAnchorLocal);
// Sable's inheritedVelocity is a separate release-momentum channel. Its built-in custom
// orientation feet calculation assumes a different raw-position convention and produced
// the 2.323654-block false impulse from the supplied log. Keep it out of this sweep; the
// corrected frame carry is reconstructed immediately below.
((LivingEntityMovementExtension) player).sable$getInheritedVelocity().zero();
// Reconstruct the frame anchor absolutely before applying this tick's local movement.
// Incrementally adding lastPose -> logicalPose deltas accumulated interpolation and
// client/server round-off as a small tangent displacement every tick; the player had zero
// local X/Z velocity but still slid off the deck. The local feet coordinate is the source
// of truth, so converge the raw eye-anchor position to its current logical-pose image.
Vec3 actualFrameMotion = applyCorrectedFrameMotion(player, frameMotion);
LAST_FRAME_MOTION.put(player, actualFrameMotion);
Vector3d inheritedVelocity =
((LivingEntityMovementExtension) player).sable$getInheritedVelocity();
inheritedVelocity.set(actualFrameMotion.x, actualFrameMotion.y, actualFrameMotion.z);
float acceleration; float acceleration;
if (flying) { if (flying) {
acceleration = abilities.getFlyingSpeed() * (player.isSprinting() ? 2.0F : 1.0F); acceleration = abilities.getFlyingSpeed() * (player.isSprinting() ? 2.0F : 1.0F);
} else { } else {
// On a normal 0.6-slipperiness block vanilla's 0.216 / friction^3 compensation is
// effectively 1.0, so getSpeed() is the correct ground impulse. test22 queried a raw
// modded friction value and reused it without vanilla's speed-factor hooks. A value
// below 0.4 produced a huge impulse; a value above 1/0.91 produced damping > 1 and
// therefore exponential acceleration. Keep the normal-block contract here.
acceleration = groundedBeforeMove ? player.getSpeed() : AIR_ACCELERATION; acceleration = groundedBeforeMove ? player.getSpeed() : AIR_ACCELERATION;
} }
// Creative vertical input has already been added to deltaMovement by LocalPlayer.aiStep. // Creative vertical input was removed from vanilla's world Y and re-applied to local Y
// Only horizontal input is handled here, otherwise ascend/descend would be applied twice. // above. Only horizontal travel input belongs in the ordinary acceleration path.
Vec3 travelInput = flying ? new Vec3(input.x, 0.0D, input.z) : input; Vec3 travelInput = flying ? new Vec3(input.x, 0.0D, input.z) : input;
localVelocity = localVelocity.add(getInputVector(travelInput, acceleration, player.getYRot())); localVelocity = localVelocity.add(getInputVector(travelInput, acceleration, player.getYRot()));
Vec3 requestedLocalVelocity = localVelocity; Vec3 requestedLocalVelocity = localVelocity;
movement.sable$setTrackingSubLevel(frame); movement.sable$setTrackingSubLevel(frame);
// Sable's arbitrary-orientation step-up checks Entity.onGround() inside its OBB sweep.
// Feed it the previous local ground state instead of the stale world-axis vanilla flag.
player.setOnGround(!flying && groundedBeforeMove);
Vec3 requestedWorldMotion = pose.transformNormal(requestedLocalVelocity); Vec3 requestedWorldMotion = pose.transformNormal(requestedLocalVelocity);
Vec3 positionBeforeMove = player.position(); Vec3 positionBeforeMove = player.position();
player.setDeltaMovement(requestedWorldMotion); player.setDeltaMovement(requestedWorldMotion);
@@ -188,20 +361,57 @@ public final class SableLocalPlayerPhysics {
SubLevelEntityCollision.CollisionInfo collision = movement.sable$getCollisionInfo(); SubLevelEntityCollision.CollisionInfo collision = movement.sable$getCollisionInfo();
Vec3 actualWorldMotion = player.position().subtract(positionBeforeMove); Vec3 actualWorldMotion = player.position().subtract(positionBeforeMove);
Vec3 actualLocalMotion = pose.transformNormalInverse(actualWorldMotion);
// Do not let Sable's LivingEntity.travel RETURN hook apply its custom-orientation
// inheritedMotion after our cancellable Player.travel injection returns. The value is
// based on SableCompanion#getFeetPos, whose custom-orientation branch treats raw position
// as an eye anchor; this mod keeps vanilla's raw-position field and rebases it explicitly.
Vec3 rejectedSableInherited = collision == null ? null : collision.inheritedMotion;
if (rejectedSableInherited == null) {
LAST_SABLE_INHERITED_MOTION.remove(player);
} else {
LAST_SABLE_INHERITED_MOTION.put(player, rejectedSableInherited);
collision.inheritedMotion = null;
}
Vec3 actualLocalMotion;
if (collision != null && collision.motion != null && collision.subLevelHorizontalCollision) {
// Sable's returned motion already contains every OBB MTV but excludes inherited ship
// movement. That makes it the stable source for wall/step displacement on a moving
// frame; comparing feet before Sable applies inheritedMotion creates a fake impulse.
actualLocalMotion = pose.transformNormalInverse(collision.motion);
} else {
// Parent-world vanilla collision is applied after collision.motion is stored, so use
// the real displacement when no blocking SubLevel wall participated.
actualLocalMotion = pose.transformNormalInverse(actualWorldMotion);
}
// Never import Sable's collision-mutated deltaMovement back into local state. At a tilted // Never import Sable's collision-mutated deltaMovement back into local state. At a tilted
// floor that mutation contains tiny tangent components and caused the persistent drift. // floor that mutation contains tiny tangent components and caused the persistent drift.
// Preserve the requested local velocity and only remove axes that were actually blocked. // Preserve the requested local velocity and only remove axes that were actually blocked.
localVelocity = resolveBlockedVelocity(requestedLocalVelocity, actualLocalMotion, collision); localVelocity = resolveBlockedVelocity(
requestedLocalVelocity,
actualLocalMotion,
collision,
pose
);
boolean localGrounded = collision != null && collision.verticalCollisionBelow; boolean localGrounded = collision != null
LOCAL_GROUNDED.put(player, !flying && localGrounded); && collision.verticalCollisionBelow
&& requestedLocalVelocity.y <= COLLISION_EPSILON;
if (!localGrounded && requestedLocalVelocity.y <= COLLISION_EPSILON) {
// Keep the ground latch stable when SAT reports no fresh penetration on an exact rest.
localGrounded = SableLocalCollisionProbe.hasSupportNow(player, frame);
}
LOCAL_GROUNDED.put(player, localGrounded);
// inheritedMotion/inheritedVelocity are intentionally left to Sable's travel RETURN mixin. // Local velocity is independent from frame carry. The corrected carry above is stored in
// Sable's inheritedVelocity only as release momentum and is cleared before the next sweep.
if (flying) { if (flying) {
player.fallDistance = 0.0F; player.fallDistance = 0.0F;
player.setOnGround(false); // Preserve local contact semantics so LocalPlayer.aiStep can turn flight off on
// landing, exactly like vanilla does on an ordinary world-axis floor.
player.setOnGround(localGrounded);
localVelocity = new Vec3( localVelocity = new Vec3(
localVelocity.x * AIR_HORIZONTAL_FRICTION, localVelocity.x * AIR_HORIZONTAL_FRICTION,
localVelocity.y * FLIGHT_VERTICAL_DRAG, localVelocity.y * FLIGHT_VERTICAL_DRAG,
@@ -210,8 +420,12 @@ public final class SableLocalPlayerPhysics {
} else { } else {
player.setOnGround(localGrounded); player.setOnGround(localGrounded);
if (!player.isNoGravity()) { if (!player.isNoGravity()) {
double gravity = VANILLA_GRAVITY_PER_TICK double gravity = Math.max(0.0D, player.getGravity())
* (Math.max(0.0F, state.sablegravity$getStrength()) / SableGravityMod.VANILLA_STRENGTH); * (Math.max(0.0F, state.sablegravity$getStrength())
/ SableGravityMod.VANILLA_STRENGTH);
if (localVelocity.y <= 0.0D && player.hasEffect(MobEffects.SLOW_FALLING)) {
gravity = Math.min(gravity, 0.01D);
}
localVelocity = localVelocity.add(0.0D, -gravity, 0.0D); localVelocity = localVelocity.add(0.0D, -gravity, 0.0D);
} }
@@ -236,47 +450,275 @@ public final class SableLocalPlayerPhysics {
} }
updateLocalWalkAnimation(player, actualLocalMotion); updateLocalWalkAnimation(player, actualLocalMotion);
state.sablegravity$setLocalPosition(pose.transformPositionInverse(player.position())); Vec3 measuredLocalFeet = SableLocalCollisionProbe.getLocalFeet(player, frame);
if (collision != null && collision.horizontalCollision) {
boolean constrainX = axisWasPositionConstrained(
requestedLocalVelocity.x,
actualLocalMotion.x
);
boolean constrainZ = axisWasPositionConstrained(
requestedLocalVelocity.z,
actualLocalMotion.z
);
if (constrainX || constrainZ) {
// Sable occasionally returns a small inward displacement at a rotated block seam
// even though it marks the axis as horizontally constrained. Keep constrained
// plot axes on their pre-sweep anchor and preserve the other axis verbatim. This
// is deterministic wall sliding and cannot turn a floor MTV into sideways drift.
measuredLocalFeet = new Vec3(
constrainX ? frameAnchorLocal.x : measuredLocalFeet.x,
measuredLocalFeet.y,
constrainZ ? frameAnchorLocal.z : measuredLocalFeet.z
);
localVelocity = new Vec3(
constrainX ? 0.0D : localVelocity.x,
localVelocity.y,
constrainZ ? 0.0D : localVelocity.z
);
Vec3 correctedWorldFeet = pose.transformPosition(measuredLocalFeet);
Vec3 correctedRaw = SableOrientedBounds.rawPositionForFeet(
player,
correctedWorldFeet,
SableFrameOrientation.getFrameOrientation(frame, 1.0F)
);
if (isFinite(correctedRaw)) {
player.setPos(correctedRaw);
}
}
}
if (shouldLockRestingTangents(
input,
requestedLocalVelocity,
collision,
SableParentWorldCollision.getLastResult(player)
)) {
// SAT may return a floor MTV with a microscopic tangent component when the frame is
// tilted or interpolated. Velocity clipping alone cannot fix that: the displacement
// has already happened, so its residue accumulated into a visible downhill slide
// while local X/Z velocity remained exactly zero. For a pure resting floor contact,
// the prior local feet X/Z are authoritative; retain only the measured vertical fit.
measuredLocalFeet = new Vec3(
frameAnchorLocal.x,
measuredLocalFeet.y,
frameAnchorLocal.z
);
Vec3 correctedWorldFeet = pose.transformPosition(measuredLocalFeet);
Vec3 correctedRaw = SableOrientedBounds.rawPositionForFeet(
player,
correctedWorldFeet,
SableFrameOrientation.getFrameOrientation(frame, 1.0F)
);
if (isFinite(correctedRaw)) {
player.setPos(correctedRaw);
}
}
state.sablegravity$setLocalPosition(measuredLocalFeet);
state.sablegravity$setLocalVelocity(localVelocity); state.sablegravity$setLocalVelocity(localVelocity);
player.setDeltaMovement(pose.transformNormal(localVelocity)); player.setDeltaMovement(pose.transformNormal(localVelocity));
// Entity.move may have selected another SubLevel after a floor contact. Do not overwrite
// that decision with the old latch on the next tick. Mark local velocity invalid so it is
// cleanly re-expressed in the new frame from the already-correct world velocity.
if (collision != null) {
SubLevel collisionFrame = validFrame(player, collision.trackingSubLevel);
if (collisionFrame != null && !sameFrame(collisionFrame, frame)) {
attach(player, state, collisionFrame);
}
}
WAS_FLYING.put(player, flying);
return true; return true;
} }
private static boolean shouldLockRestingTangents(
Vec3 input,
Vec3 requestedLocalVelocity,
SubLevelEntityCollision.CollisionInfo collision,
SableParentWorldCollision.CollisionResult parentCollision
) {
if (collision == null
|| !collision.verticalCollisionBelow
|| collision.subLevelHorizontalCollision
|| parentCollision.horizontalCollision()) {
return false;
}
double inputHorizontalSqr = input.x * input.x + input.z * input.z;
double velocityHorizontalSqr = requestedLocalVelocity.x * requestedLocalVelocity.x
+ requestedLocalVelocity.z * requestedLocalVelocity.z;
return inputHorizontalSqr <= EPSILON && velocityHorizontalSqr <= EPSILON;
}
private static Vec3 computeFrameCorrection(
Player player,
SubLevel frame,
Vec3 localPhysicalFeet
) {
if (localPhysicalFeet == null) {
return Vec3.ZERO;
}
var orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F);
Vec3 physicalFeet = frame.logicalPose().transformPosition(localPhysicalFeet);
Vec3 targetRaw = SableOrientedBounds.rawPositionForFeet(
player,
physicalFeet,
orientation
);
Vec3 motion = targetRaw.subtract(player.position());
if (!isFinite(motion)) {
return Vec3.ZERO;
}
return motion;
}
private static Vec3 applyCorrectedFrameMotion(Player player, Vec3 frameMotion) {
if (frameMotion == null || frameMotion.lengthSqr() <= EPSILON) {
return Vec3.ZERO;
}
Vec3 collidedMotion = ((EntityExtension) player).sable$vanillaCollide(frameMotion);
if (!isFinite(collidedMotion)) {
return Vec3.ZERO;
}
player.setPos(player.position().add(collidedMotion));
return collidedMotion;
}
private static boolean isFinite(Vec3 value) {
return Double.isFinite(value.x)
&& Double.isFinite(value.y)
&& Double.isFinite(value.z);
}
private static Vec3 resolveBlockedVelocity( private static Vec3 resolveBlockedVelocity(
Vec3 requested, Vec3 requested,
Vec3 actualMotion, Vec3 actualMotion,
SubLevelEntityCollision.CollisionInfo collision SubLevelEntityCollision.CollisionInfo collision,
Pose3dc pose
) { ) {
double x = requested.x; Vec3 resolved = requested;
double y = requested.y; boolean handledHorizontalContact = false;
double z = requested.z; boolean handledVerticalContact = false;
if (collision != null) { if (collision != null && collision.firstCollisions != null) {
if (collision.verticalCollisionBelow && y < 0.0D) { for (Object value : collision.firstCollisions.values()) {
y = 0.0D; if (!(value instanceof SubLevelEntityCollision.FirstCollisionInfo info)) {
} else if (collision.verticalCollision && y > 0.0D) { continue;
y = 0.0D; }
// Sable records a wall before trying step-up. If the step succeeded, that first
// wall is stale and must not consume any velocity.
if (info.horizontal() && !collision.subLevelHorizontalCollision) {
continue;
}
Vector3dc worldNormalJoml = info.globalDirection();
Vec3 worldNormal = new Vec3(
worldNormalJoml.x(),
worldNormalJoml.y(),
worldNormalJoml.z()
);
Vec3 localNormal = pose.transformNormalInverse(worldNormal);
double normalLengthSqr = localNormal.lengthSqr();
if (normalLengthSqr <= EPSILON) {
continue;
}
localNormal = localNormal.scale(1.0D / Math.sqrt(normalLengthSqr));
if (!info.horizontal()) {
// A floor/ceiling MTV is never allowed to shave horizontal deck velocity.
// On a rotating frame its sampled normal is not perfectly equal to final local
// +/-Y; full vector projection therefore removed a little X/Z every tick and
// made walking/sprinting speed collapse or pulse. Use the semantic contact
// type and clip only local vertical velocity.
if (localNormal.y > 0.0D && resolved.y < 0.0D) {
resolved = new Vec3(resolved.x, 0.0D, resolved.z);
} else if (localNormal.y < 0.0D && resolved.y > 0.0D) {
resolved = new Vec3(resolved.x, 0.0D, resolved.z);
}
handledVerticalContact = true;
continue;
}
// Wall response lives in the local floor plane. Drop numerical vertical residue
// from the SAT normal so a wall cannot eat jump/fall speed, then remove only the
// inward horizontal component and preserve the tangent exactly.
double horizontalNormalSqr =
localNormal.x * localNormal.x + localNormal.z * localNormal.z;
if (horizontalNormalSqr <= EPSILON) {
continue;
}
double invHorizontalLength = 1.0D / Math.sqrt(horizontalNormalSqr);
double nx = localNormal.x * invHorizontalLength;
double nz = localNormal.z * invHorizontalLength;
double inwardSpeed = resolved.x * nx + resolved.z * nz;
if (inwardSpeed < -COLLISION_EPSILON) {
resolved = new Vec3(
resolved.x - nx * inwardSpeed,
resolved.y,
resolved.z - nz * inwardSpeed
);
}
handledHorizontalContact = true;
} }
} }
if (axisWasBlocked(requested.x, actualMotion.x)) { double x = resolved.x;
x = 0.0D; double y = resolved.y;
} double z = resolved.z;
if (axisWasBlocked(requested.z, actualMotion.z)) {
z = 0.0D; if (collision != null) {
if (!handledVerticalContact) {
if (collision.verticalCollisionBelow && requested.y < 0.0D) {
y = 0.0D;
} else if (collision.verticalCollision
&& !collision.verticalCollisionBelow
&& requested.y > 0.0D) {
y = 0.0D;
}
}
if (collision.horizontalCollision && !handledHorizontalContact) {
if (axisWasBlocked(requested.x, actualMotion.x)) {
x = 0.0D;
}
if (axisWasBlocked(requested.z, actualMotion.z)) {
z = 0.0D;
}
}
} }
return new Vec3(x, y, z); return new Vec3(x, y, z);
} }
private static boolean axisWasBlocked(double requested, double actual) { private static boolean axisWasBlocked(double requested, double actual) {
if (Math.abs(requested) <= COLLISION_EPSILON) { double requestedAbs = Math.abs(requested);
if (requestedAbs <= COLLISION_EPSILON) {
return false; return false;
} }
if (Math.signum(requested) != Math.signum(actual) && Math.abs(actual) > COLLISION_EPSILON) { if (Math.signum(requested) != Math.signum(actual)
&& Math.abs(actual) > COLLISION_EPSILON) {
return true; return true;
} }
return Math.abs(actual) + COLLISION_EPSILON < Math.abs(requested); return Math.abs(actual) <= Math.max(
COLLISION_EPSILON,
requestedAbs * BLOCKED_MOTION_RATIO
);
}
private static boolean axisWasPositionConstrained(double requested, double actual) {
double requestedAbs = Math.abs(requested);
if (requestedAbs <= COLLISION_EPSILON) {
return false;
}
if (Math.signum(requested) != Math.signum(actual)
&& Math.abs(actual) > COLLISION_EPSILON) {
return true;
}
return Math.abs(actual) <= Math.max(
COLLISION_EPSILON,
requestedAbs * POSITION_CONSTRAINED_MOTION_RATIO
);
} }
private static void updateLocalWalkAnimation(Player player, Vec3 actualLocalMotion) { private static void updateLocalWalkAnimation(Player player, Vec3 actualLocalMotion) {
@@ -310,7 +752,13 @@ public final class SableLocalPlayerPhysics {
); );
} }
private static double clamp(double value, double min, double max) { private static double getFlightControlImpulse(Player player, Abilities abilities) {
return Math.max(min, Math.min(max, value)); int verticalInput = ((LivingEntityInputAccessor) player)
.sablegravity$isJumpingInput() ? 1 : 0;
if (player.isShiftKeyDown()) {
verticalInput--;
}
return verticalInput * abilities.getFlyingSpeed() * 3.0D;
} }
} }
@@ -0,0 +1,143 @@
package dev.nightly.sablegravity.physics;
import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
/**
* Canonical dimensions and the exact eye-anchored OBB used by Sable gravity entities.
*
* Minecraft's AABB type cannot encode an arbitrary orientation. The exact body is therefore
* represented by a center, half extents and quaternion; {@link #worldEnvelope(Entity, Quaterniondc)}
* is only the smallest world-axis AABB containing all eight OBB corners.
*/
public final class SableOrientedBounds {
private SableOrientedBounds() {}
public static AABB canonicalAabb(Entity entity) {
double halfWidth = entity.getBbWidth() * 0.5D;
double height = entity.getBbHeight();
Vec3 position = entity.position();
return new AABB(
position.x - halfWidth,
position.y,
position.z - halfWidth,
position.x + halfWidth,
position.y + height,
position.z + halfWidth
);
}
/** Exact world-space OBB center matching Sable's transformEntityBoundsCenter contract. */
public static Vec3 worldCenter(Entity entity, Quaterniondc orientation) {
double halfHeight = entity.getBbHeight() * 0.5D;
double anchorOffset = entity.getEyeHeight() - halfHeight;
Vector3d rotatedOffset = orientation.transform(
new Vector3d(0.0D, anchorOffset, 0.0D),
new Vector3d()
);
Vec3 position = entity.position();
return new Vec3(
position.x - rotatedOffset.x,
position.y + entity.getEyeHeight() - rotatedOffset.y,
position.z - rotatedOffset.z
);
}
/** Physical feet point after rotating the canonical body around the eye anchor. */
public static Vec3 worldFeet(Entity entity, Quaterniondc orientation) {
Vector3d rotatedEyeOffset = orientation.transform(
new Vector3d(0.0D, entity.getEyeHeight(), 0.0D),
new Vector3d()
);
Vec3 position = entity.position();
return new Vec3(
position.x - rotatedEyeOffset.x,
position.y + entity.getEyeHeight() - rotatedEyeOffset.y,
position.z - rotatedEyeOffset.z
);
}
/**
* Converts a physical world-space feet point back to Minecraft's raw entity position for
* Sable's eye-anchored custom orientation contract.
*
* <p>Sable rotates a custom entity OBB around the eye. Therefore {@code Entity.position()}
* is no longer the physical bottom-center of the OBB. Keeping the feet fixed while changing
* orientation requires moving the raw position by {@code Q * eyeOffset - worldEyeOffset}.</p>
*/
public static Vec3 rawPositionForFeet(
Entity entity,
Vec3 physicalFeet,
Quaterniondc orientation
) {
Vector3d rotatedEyeOffset = orientation.transform(
new Vector3d(0.0D, entity.getEyeHeight(), 0.0D),
new Vector3d()
);
return new Vec3(
physicalFeet.x + rotatedEyeOffset.x,
physicalFeet.y + rotatedEyeOffset.y - entity.getEyeHeight(),
physicalFeet.z + rotatedEyeOffset.z
);
}
public static Vec3 localFeet(
Entity entity,
Pose3dc pose,
Quaterniondc orientation
) {
return pose.transformPositionInverse(worldFeet(entity, orientation));
}
public static Vec3[] worldCorners(Entity entity, Quaterniondc orientation) {
Vec3 center = worldCenter(entity, orientation);
double halfX = entity.getBbWidth() * 0.5D;
double halfY = entity.getBbHeight() * 0.5D;
double halfZ = entity.getBbWidth() * 0.5D;
Vec3[] corners = new Vec3[8];
int index = 0;
for (int xSign : new int[]{-1, 1}) {
for (int ySign : new int[]{-1, 1}) {
for (int zSign : new int[]{-1, 1}) {
Vector3d offset = orientation.transform(
new Vector3d(xSign * halfX, ySign * halfY, zSign * halfZ),
new Vector3d()
);
corners[index++] = center.add(offset.x, offset.y, offset.z);
}
}
}
return corners;
}
/** Smallest world-axis AABB containing the exact rotated OBB. Not used as Sable's body. */
public static AABB worldEnvelope(Entity entity, Quaterniondc orientation) {
Vec3[] corners = worldCorners(entity, orientation);
double minX = Double.POSITIVE_INFINITY;
double minY = Double.POSITIVE_INFINITY;
double minZ = Double.POSITIVE_INFINITY;
double maxX = Double.NEGATIVE_INFINITY;
double maxY = Double.NEGATIVE_INFINITY;
double maxZ = Double.NEGATIVE_INFINITY;
for (Vec3 corner : corners) {
minX = Math.min(minX, corner.x);
minY = Math.min(minY, corner.y);
minZ = Math.min(minZ, corner.z);
maxX = Math.max(maxX, corner.x);
maxY = Math.max(maxY, corner.y);
maxZ = Math.max(maxZ, corner.z);
}
return new AABB(minX, minY, minZ, maxX, maxY, maxZ);
}
public static Quaterniondc logicalOrientation(SubLevel frame) {
return SableFrameOrientation.getFrameOrientation(frame, 1.0F);
}
}
@@ -0,0 +1,442 @@
package dev.nightly.sablegravity.physics;
import dev.ryanhcode.sable.api.math.LevelReusedVectors;
import dev.ryanhcode.sable.api.math.OrientedBoundingBox3d;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.EntityMovementExtension;
import dev.ryanhcode.sable.mixinterface.entity.entity_sublevel_collision.LevelExtension;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
import java.util.Collections;
import java.util.Map;
import java.util.WeakHashMap;
/**
* Replaces vanilla's second upright-AABB collision pass with the same quaternion body that is
* used for Sable plot collision. Parent-world blocks remain solid without becoming fake walls
* merely because the player's local up direction is tilted.
*/
public final class SableParentWorldCollision {
private static final double EPSILON = 1.0E-7D;
private static final double SAT_EPSILON_SQR = 1.0E-14D;
private static final double MAX_SUBSTEP = 0.125D;
private static final int MAX_SUBSTEPS = 64;
private static final int MAX_RESOLUTION_ITERATIONS = 8;
private static final ThreadLocal<CollisionPass> ACTIVE_PASS = new ThreadLocal<>();
private static final Map<Player, CollisionResult> LAST_RESULTS =
Collections.synchronizedMap(new WeakHashMap<>());
private SableParentWorldCollision() {}
/** Called from the redirected vanilla collision invocation inside Sable's merged move hook. */
public static Vec3 collideOrTrust(
Player player,
Vec3 requestedMotion,
SubLevel frame,
boolean captureSablePass
) {
SubLevelEntityCollision.CollisionInfo info =
((EntityMovementExtension) player).sable$getCollisionInfo();
CollisionPass pass = captureSablePass ? CollisionPass.capture(info) : null;
// ServerPlayer receives an already OBB-resolved position from the authoritative local
// client. Running an upright server AABB through collide() here only creates corrections.
if (!player.isLocalPlayer() || frame == null || frame.isRemoved()) {
CollisionResult result = CollisionResult.clear(requestedMotion);
if (pass != null) {
ACTIVE_PASS.set(pass.withResult(result));
}
LAST_RESULTS.put(player, result);
return requestedMotion;
}
Quaterniondc orientation = SableFrameOrientation.getFrameOrientation(frame, 1.0F);
CollisionResult result = resolve(player, requestedMotion, orientation);
if (pass != null) {
ACTIVE_PASS.set(pass.withResult(result));
}
LAST_RESULTS.put(player, result);
return result.motion();
}
/** Restores semantic local-up flags after Sable compares the motions as world X/Y/Z axes. */
public static void restoreSableFlags(Player player) {
CollisionPass pass = ACTIVE_PASS.get();
ACTIVE_PASS.remove();
if (pass == null) {
return;
}
EntityMovementExtension movement = (EntityMovementExtension) player;
SubLevelEntityCollision.CollisionInfo info = movement.sable$getCollisionInfo();
if (info == null) {
return;
}
CollisionResult parent = pass.parentResult();
info.horizontalCollision = pass.subLevelHorizontalCollision()
|| pass.horizontalCollision()
|| parent.horizontalCollision();
info.verticalCollision = pass.verticalCollision() || parent.verticalCollision();
info.verticalCollisionBelow = pass.verticalCollisionBelow()
|| parent.verticalCollisionBelow();
info.minorHorizontalCollision = pass.minorHorizontalCollision();
info.trackingSubLevel = pass.trackingSubLevel();
movement.sable$setTrackingSubLevel(pass.trackingSubLevel());
}
public static CollisionResult getLastResult(Player player) {
return LAST_RESULTS.getOrDefault(player, CollisionResult.clear(Vec3.ZERO));
}
/** Exact parent-world fit test for crouch/stand pose selection. */
public static boolean canFit(
Player player,
Vec3 physicalFeet,
EntityDimensions dimensions,
Quaterniondc orientation
) {
double width = Math.max(0.0D, dimensions.width() - EPSILON * 2.0D);
double height = Math.max(0.0D, dimensions.height() - EPSILON * 2.0D);
Vector3d up = orientation.transform(new Vector3d(0.0D, 1.0D, 0.0D));
Vector3d center = new Vector3d(
physicalFeet.x + up.x * (height * 0.5D + EPSILON),
physicalFeet.y + up.y * (height * 0.5D + EPSILON),
physicalFeet.z + up.z * (height * 0.5D + EPSILON)
);
return !hasCollision(player, center, width, height, orientation);
}
private static CollisionResult resolve(
Player player,
Vec3 requestedMotion,
Quaterniondc orientation
) {
if (!isFinite(requestedMotion)) {
return CollisionResult.clear(Vec3.ZERO);
}
Vector3d startCenter = toJoml(SableOrientedBounds.worldCenter(player, orientation));
Vector3d resolvedCenter = new Vector3d(startCenter);
Vector3d step = toJoml(requestedMotion);
int substeps = Math.min(
MAX_SUBSTEPS,
Math.max(1, (int) Math.ceil(step.length() / MAX_SUBSTEP))
);
step.div(substeps);
Vector3d up = orientation.transform(new Vector3d(0.0D, 1.0D, 0.0D)).normalize();
MutableCollision flags = new MutableCollision();
for (int substep = 0; substep < substeps; substep++) {
resolvedCenter.add(step);
resolvePenetration(player, resolvedCenter, step, up, orientation, flags);
}
Vector3d resolved = resolvedCenter.sub(startCenter);
Vec3 motion = new Vec3(resolved.x, resolved.y, resolved.z);
if (!isFinite(motion)) {
motion = Vec3.ZERO;
}
return new CollisionResult(
requestedMotion,
motion,
flags.horizontal,
flags.vertical,
flags.below,
flags.contacts,
flags.maxCorrection
);
}
private static void resolvePenetration(
Player player,
Vector3d center,
Vector3d requestedStep,
Vector3d up,
Quaterniondc orientation,
MutableCollision flags
) {
LevelReusedVectors sink = ((LevelExtension) player.level()).sable$getJOMLSink();
OrientedBoundingBox3d entity = new OrientedBoundingBox3d(
center,
new Vector3d(player.getBbWidth(), player.getBbHeight(), player.getBbWidth()),
orientation,
sink
);
OrientedBoundingBox3d block = new OrientedBoundingBox3d(sink);
block.getOrientation().identity();
Vector3d mtv = new Vector3d();
Vector3d largestMtv = new Vector3d();
for (int iteration = 0; iteration < MAX_RESOLUTION_ITERATIONS; iteration++) {
largestMtv.zero();
double[] largestLengthSqr = {0.0D};
AABB envelope = envelope(center, player.getBbWidth(), player.getBbHeight(), orientation)
.inflate(EPSILON);
forEachCollisionBox(player, envelope, box -> {
setBlockObb(block, box);
mtv.set(Double.MAX_VALUE);
OrientedBoundingBox3d.sat(entity, block, mtv);
double lengthSqr = validMtv(mtv) ? mtv.lengthSquared() : 0.0D;
if (lengthSqr > largestLengthSqr[0]) {
largestLengthSqr[0] = lengthSqr;
largestMtv.set(mtv);
}
});
if (largestLengthSqr[0] <= SAT_EPSILON_SQR) {
return;
}
Vector3d normal = new Vector3d(largestMtv).normalize();
double upDot = normal.dot(up);
boolean vertical = Math.abs(upDot) > 0.6D;
if (!vertical
&& player.onGround()
&& requestedStep.dot(normal) < 0.0D
&& tryStepUp(player, center, up, orientation)) {
entity.setPosition(center);
continue;
}
center.add(largestMtv);
entity.setPosition(center);
flags.contacts++;
flags.maxCorrection = Math.max(
flags.maxCorrection,
Math.sqrt(largestLengthSqr[0])
);
if (vertical) {
flags.vertical = true;
if (upDot > 0.0D) {
flags.below = true;
}
} else {
flags.horizontal = true;
}
}
}
private static boolean tryStepUp(
Player player,
Vector3d center,
Vector3d up,
Quaterniondc orientation
) {
double maxStep = player.maxUpStep();
if (maxStep <= EPSILON) {
return false;
}
Vector3d candidate = new Vector3d();
for (double amount = 0.0625D; amount <= maxStep + EPSILON; amount += 0.0625D) {
candidate.set(center).fma(amount, up);
if (!hasCollision(
player,
candidate,
player.getBbWidth(),
player.getBbHeight(),
orientation
)) {
center.set(candidate);
return true;
}
}
return false;
}
private static boolean hasCollision(
Player player,
Vector3d center,
double width,
double height,
Quaterniondc orientation
) {
LevelReusedVectors sink = ((LevelExtension) player.level()).sable$getJOMLSink();
OrientedBoundingBox3d entity = new OrientedBoundingBox3d(
center,
new Vector3d(width, height, width),
orientation,
sink
);
OrientedBoundingBox3d block = new OrientedBoundingBox3d(sink);
block.getOrientation().identity();
Vector3d mtv = new Vector3d();
boolean[] collision = {false};
AABB envelope = envelope(center, width, height, orientation).inflate(EPSILON);
forEachCollisionBox(player, envelope, box -> {
if (collision[0]) {
return;
}
setBlockObb(block, box);
mtv.set(Double.MAX_VALUE);
OrientedBoundingBox3d.sat(entity, block, mtv);
collision[0] = validMtv(mtv);
});
return collision[0];
}
private static void forEachCollisionBox(
Player player,
AABB envelope,
java.util.function.Consumer<AABB> consumer
) {
Level level = player.level();
CollisionContext context = CollisionContext.of(player);
int minX = Mth.floor(envelope.minX);
int minY = Math.max(level.getMinBuildHeight(), Mth.floor(envelope.minY));
int minZ = Mth.floor(envelope.minZ);
int maxX = Mth.floor(envelope.maxX - EPSILON);
int maxY = Math.min(level.getMaxBuildHeight() - 1, Mth.floor(envelope.maxY - EPSILON));
int maxZ = Mth.floor(envelope.maxZ - EPSILON);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int x = minX; x <= maxX; x++) {
for (int z = minZ; z <= maxZ; z++) {
pos.set(x, minY, z);
if (!level.hasChunkAt(pos)) {
continue;
}
for (int y = minY; y <= maxY; y++) {
pos.set(x, y, z);
BlockState state = level.getBlockState(pos);
if (state.isAir()) {
continue;
}
VoxelShape shape = state.getCollisionShape(level, pos, context);
for (AABB localBox : shape.toAabbs()) {
consumer.accept(localBox.move(x, y, z));
}
}
}
}
}
private static void setBlockObb(OrientedBoundingBox3d block, AABB box) {
block.getPosition().set(
(box.minX + box.maxX) * 0.5D,
(box.minY + box.maxY) * 0.5D,
(box.minZ + box.maxZ) * 0.5D
);
block.getDimensions().set(box.getXsize(), box.getYsize(), box.getZsize());
block.getOrientation().identity();
}
private static boolean validMtv(Vector3d mtv) {
return Double.isFinite(mtv.x)
&& Double.isFinite(mtv.y)
&& Double.isFinite(mtv.z)
&& mtv.x != Double.MAX_VALUE
&& mtv.y != Double.MAX_VALUE
&& mtv.z != Double.MAX_VALUE
&& mtv.lengthSquared() > SAT_EPSILON_SQR;
}
private static AABB envelope(
Vector3d center,
double width,
double height,
Quaterniondc orientation
) {
double halfWidth = width * 0.5D;
double halfHeight = height * 0.5D;
Vector3d right = orientation.transform(new Vector3d(halfWidth, 0.0D, 0.0D));
Vector3d up = orientation.transform(new Vector3d(0.0D, halfHeight, 0.0D));
Vector3d forward = orientation.transform(new Vector3d(0.0D, 0.0D, halfWidth));
double extentX = Math.abs(right.x) + Math.abs(up.x) + Math.abs(forward.x);
double extentY = Math.abs(right.y) + Math.abs(up.y) + Math.abs(forward.y);
double extentZ = Math.abs(right.z) + Math.abs(up.z) + Math.abs(forward.z);
return new AABB(
center.x - extentX,
center.y - extentY,
center.z - extentZ,
center.x + extentX,
center.y + extentY,
center.z + extentZ
);
}
private static Vector3d toJoml(Vec3 value) {
return new Vector3d(value.x, value.y, value.z);
}
private static boolean isFinite(Vec3 value) {
return Double.isFinite(value.x)
&& Double.isFinite(value.y)
&& Double.isFinite(value.z);
}
private static final class MutableCollision {
private boolean horizontal;
private boolean vertical;
private boolean below;
private int contacts;
private double maxCorrection;
}
private record CollisionPass(
boolean subLevelHorizontalCollision,
boolean horizontalCollision,
boolean verticalCollision,
boolean verticalCollisionBelow,
boolean minorHorizontalCollision,
SubLevel trackingSubLevel,
CollisionResult parentResult
) {
private static CollisionPass capture(SubLevelEntityCollision.CollisionInfo info) {
if (info == null) {
return new CollisionPass(false, false, false, false, false, null, null);
}
return new CollisionPass(
info.subLevelHorizontalCollision,
info.horizontalCollision,
info.verticalCollision,
info.verticalCollisionBelow,
info.minorHorizontalCollision,
info.trackingSubLevel,
null
);
}
private CollisionPass withResult(CollisionResult result) {
return new CollisionPass(
subLevelHorizontalCollision,
horizontalCollision,
verticalCollision,
verticalCollisionBelow,
minorHorizontalCollision,
trackingSubLevel,
result
);
}
}
public record CollisionResult(
Vec3 requestedMotion,
Vec3 motion,
boolean horizontalCollision,
boolean verticalCollision,
boolean verticalCollisionBelow,
int contacts,
double maxCorrection
) {
private static CollisionResult clear(Vec3 motion) {
return new CollisionResult(motion, motion, false, false, false, 0, 0.0D);
}
}
}
@@ -0,0 +1,126 @@
package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.api.GravityFrameId;
import dev.nightly.sablegravity.api.GravityFrameProvider;
import dev.ryanhcode.sable.Sable;
import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
import dev.ryanhcode.sable.companion.math.BoundingBox3d;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import java.util.Optional;
/** Built-in provider for physical Sable ship/contraption frames. */
final class SableSubLevelFrameProvider implements GravityFrameProvider {
/** Captures entities emitted just outside a hull before world gravity can steal them. */
private static final double CAPTURE_MARGIN = 8.0D;
/** Wider release radius prevents one-tick frame flapping at a moving/rotating hull boundary. */
private static final double RELEASE_MARGIN = 12.0D;
@Override
public Optional<SableSubLevelGravityFrame> resolve(
Entity entity,
GravityFrameId previousFrame
) {
Level level = entity.level();
// Sable's collision result is authoritative when it has just selected another ship.
SubLevel tracked = valid(level, Sable.HELPER.getTrackingSubLevel(entity));
if (tracked != null && withinLocalBounds(tracked, entity.position(), RELEASE_MARGIN)) {
return Optional.of(new SableSubLevelGravityFrame(tracked));
}
SubLevel previous = fromId(level, previousFrame);
if (previous != null && withinLocalBounds(previous, entity.position(), RELEASE_MARGIN)) {
return Optional.of(new SableSubLevelGravityFrame(previous));
}
SubLevel lastTracked = valid(level, Sable.HELPER.getLastTrackingSubLevel(entity));
if (lastTracked != null
&& withinLocalBounds(lastTracked, entity.position(), RELEASE_MARGIN)) {
return Optional.of(new SableSubLevelGravityFrame(lastTracked));
}
return findAt(level, entity.position(), entity.getBoundingBox());
}
@Override
public Optional<SableSubLevelGravityFrame> resolveAt(
Level level,
Vec3 worldPosition,
GravityFrameId previousFrame
) {
SubLevel previous = fromId(level, previousFrame);
if (previous != null && withinLocalBounds(previous, worldPosition, RELEASE_MARGIN)) {
return Optional.of(new SableSubLevelGravityFrame(previous));
}
AABB pointBounds = new AABB(worldPosition, worldPosition).inflate(CAPTURE_MARGIN);
return findAt(level, worldPosition, pointBounds);
}
private static Optional<SableSubLevelGravityFrame> findAt(
Level level,
Vec3 worldPosition,
AABB searchBounds
) {
SubLevel best = null;
double bestDistance = Double.POSITIVE_INFINITY;
for (SubLevel candidate : Sable.HELPER.getAllIntersecting(
level,
new BoundingBox3d(searchBounds.inflate(CAPTURE_MARGIN))
)) {
candidate = valid(level, candidate);
if (candidate == null) {
continue;
}
double distance = distanceToLocalBounds(candidate, worldPosition);
if (distance <= CAPTURE_MARGIN * CAPTURE_MARGIN && distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
return best == null
? Optional.empty()
: Optional.of(new SableSubLevelGravityFrame(best));
}
static SubLevel fromId(Level level, GravityFrameId id) {
if (id == null
|| !SableSubLevelGravityFrame.PROVIDER_ID.equals(id.provider())
|| !id.dimension().equals(level.dimension().location())) {
return null;
}
SubLevelContainer container = SubLevelContainer.getContainer(level);
return container == null ? null : valid(level, container.getSubLevel(id.localId()));
}
private static SubLevel valid(Level level, SubLevel frame) {
return frame != null && !frame.isRemoved() && frame.getLevel() == level ? frame : null;
}
private static boolean withinLocalBounds(SubLevel frame, Vec3 worldPosition, double margin) {
return distanceToLocalBounds(frame, worldPosition) <= margin * margin;
}
private static double distanceToLocalBounds(SubLevel frame, Vec3 worldPosition) {
Vec3 local = frame.logicalPose().transformPositionInverse(worldPosition);
var bounds = frame.getPlot().getBoundingBox();
double dx = axisDistance(local.x, bounds.minX(), bounds.maxX() + 1.0D);
double dy = axisDistance(local.y, bounds.minY(), bounds.maxY() + 1.0D);
double dz = axisDistance(local.z, bounds.minZ(), bounds.maxZ() + 1.0D);
return dx * dx + dy * dy + dz * dz;
}
private static double axisDistance(double value, double min, double max) {
if (value < min) {
return min - value;
}
if (value > max) {
return value - max;
}
return 0.0D;
}
}
@@ -0,0 +1,80 @@
package dev.nightly.sablegravity.physics;
import dev.nightly.sablegravity.SableGravityMod;
import dev.nightly.sablegravity.api.GravityFrame;
import dev.nightly.sablegravity.api.GravityFrameId;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaterniondc;
/** Gravity frame backed by a live Sable SubLevel pose. */
public final class SableSubLevelGravityFrame implements GravityFrame {
public static final net.minecraft.resources.ResourceLocation PROVIDER_ID =
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath(
SableGravityMod.MOD_ID,
"sable_sublevel"
);
private final SubLevel subLevel;
private final GravityFrameId id;
public SableSubLevelGravityFrame(SubLevel subLevel) {
this.subLevel = subLevel;
this.id = new GravityFrameId(
PROVIDER_ID,
subLevel.getLevel().dimension().location(),
subLevel.getUniqueId()
);
}
public SubLevel subLevel() {
return subLevel;
}
@Override
public GravityFrameId id() {
return id;
}
@Override
public Level level() {
return subLevel.getLevel();
}
@Override
public boolean isValid() {
return !subLevel.isRemoved();
}
@Override
public Vec3 toLocalPosition(Vec3 worldPosition) {
return subLevel.logicalPose().transformPositionInverse(worldPosition);
}
@Override
public Vec3 toWorldPosition(Vec3 localPosition) {
return subLevel.logicalPose().transformPosition(localPosition);
}
@Override
public Vec3 toLocalVelocity(Vec3 worldVelocity) {
return subLevel.logicalPose().transformNormalInverse(worldVelocity);
}
@Override
public Vec3 toWorldVelocity(Vec3 localVelocity) {
return subLevel.logicalPose().transformNormal(localVelocity);
}
@Override
public Quaterniondc orientation(float partialTick) {
return SableFrameOrientation.getFrameOrientation(subLevel, partialTick);
}
@Override
public Vec3 frameVelocityAt(Vec3 worldPosition) {
Vec3 previousLocal = subLevel.lastPose().transformPositionInverse(worldPosition);
return subLevel.logicalPose().transformPosition(previousLocal).subtract(worldPosition);
}
}
@@ -4,10 +4,10 @@ license = "All Rights Reserved"
[[mods]] [[mods]]
modId = "sablegravity" modId = "sablegravity"
version = "0.6.0-test16-ground-flight-sneak" version = "0.7.4-player-recovery"
displayName = "Sable Gravity Local Physics" displayName = "Sable Gravity Local Physics"
authors = "Nightly" authors = "Nightly"
description = '''Adds SubLevel-local gravity with Sable OBB collision, stable local grounding, creative flight, local-space animation and ship-local edge sneaking.''' description = '''Sable ship-local player gravity and collision. Non-player entities remain on Sable's native lifecycle while the universal frame layer is rebuilt safely.'''
[[mixins]] [[mixins]]
config = "sablegravity.mixins.json" config = "sablegravity.mixins.json"
+12 -3
View File
@@ -4,14 +4,23 @@
"compatibilityLevel": "JAVA_21", "compatibilityLevel": "JAVA_21",
"minVersion": "0.8", "minVersion": "0.8",
"mixins": [ "mixins": [
"EntityGravityStateMixin",
"PlayerGravityDataMixin", "PlayerGravityDataMixin",
"EntityDirectionalGravityMixin",
"LivingEntityDirectionalGravityMixin",
"PlayerLocalPhysicsMixin", "PlayerLocalPhysicsMixin",
"EntityCustomOrientationMixin", "LivingEntityInputAccessor",
"LivingEntityJumpMixin", "LivingEntityJumpMixin",
"PlayerEdgeSneakMixin" "PlayerPoseFitMixin",
"EntityCustomOrientationMixin",
"EntityAabbIsolationMixin",
"SableCollisionYawMixin",
"PlayerEdgeSneakMixin",
"PlayerFlightCrouchPoseMixin"
], ],
"client": [ "client": [
"LocalPlayerLifecycleMixin" "LocalPlayerLifecycleMixin",
"LocalPlayerFlightCrouchMixin"
], ],
"injectors": { "injectors": {
"defaultRequire": 1 "defaultRequire": 1
@@ -0,0 +1,17 @@
package dev.devce.rocketnautics.shaded.orekit.compiler.plugin;
import com.sun.source.util.JavacTask;
import com.sun.source.util.Plugin;
/** Dev-runtime service shim for the provider omitted from Cosmonautics 26.07.270. */
public final class DefaultDataContextPlugin implements Plugin {
@Override
public String getName() {
return "DefaultDataContextPlugin";
}
@Override
public void init(JavacTask task, String... arguments) {
// Java-eval snippets do not compile Orekit @DefaultDataContext annotated sources.
}
}