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/
### 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:
- never imports Sable-mutated world deltaMovement back into local velocity; removes tilted-floor drift;
- uses Sable local floor contact as the authoritative ground state;
- keeps creative flight independent from stale world-axis onGround state;
- captures flight ascend/descend against the exact previous post-tick velocity, so ship rotation cannot become fake acceleration;
- updates walk animation directly from the actual local displacement because cancelled Player.travel never reaches vanilla calculateEntityAnimation;
- replaces Sable/vanilla sneak edge backoff with a full local plot-space floor query;
- leaves Entity.bb axis-aligned only for Minecraft broadphase. It is no longer used as the source of local ground, flight or sneak behaviour.
## Physics model
- Players use an oriented body and plot-local feet anchor. Crouching, standing, jumping,
creative flight, edge sneaking and wall sliding are resolved in the ship frame without using
the parent-world AABB as the source of truth.
- Entities always remain in physical parent-world coordinates. Sable is the only owner of their
OBB collision, tracking and packet conversion; Sable Gravity rotates gravity, locomotion and
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)
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 {
mavenCentral()
maven {
@@ -61,7 +68,12 @@ dependencies {
// Development client mod set. Aeronautics requires Create and Sable.
runtimeOnly "maven.modrinth:create:${project.create_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}"
// 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 {
+2 -1
View File
@@ -2,11 +2,12 @@ org.gradle.jvmargs=-Xmx2G
org.gradle.daemon=false
org.gradle.parallel=true
mod_id=sablegravity
mod_version=0.6.0-test16-ground-flight-sneak
mod_version=0.7.4-player-recovery
minecraft_version=1.21.1
neo_version=21.1.228
sable_version=2.0.3+mc1.21.1
sable_companion_version=1.6.0
create_version=6.0.10+mc1.21.1
aeronautics_version=1.3.0+mc1.21.1
cosmonautics_version=c6IOumoH
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.StringArgumentType;
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.Commands;
import net.minecraft.commands.arguments.EntityArgument;
import net.minecraft.network.chat.Component;
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.fml.ModContainer;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.common.NeoForge;
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.List;
@@ -23,12 +31,16 @@ import java.util.Map;
@Mod(SableGravityMod.MOD_ID)
public final class SableGravityMod {
public static final String MOD_ID = "sablegravity";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public static final float VANILLA_STRENGTH = 9.8F;
private static volatile float globalStrength = VANILLA_STRENGTH;
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 =
@@ -185,7 +197,11 @@ public final class SableGravityMod {
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;
}
@@ -213,37 +229,103 @@ public final class SableGravityMod {
}
}
private static void axisTestB(CommandSourceStack source) {
try {
ServerPlayer player = source.getPlayer();
if (player == null) {
source.sendFailure(Component.literal("This test must be executed by a player"));
return;
}
player.getBoundingBox();
// Тут вызываешь свою void-функцию:
//
// 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 void testState(CommandSourceStack source) throws Exception {
ServerPlayer player = source.getPlayerOrException();
SableGravityState state = (SableGravityState) player;
SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player);
source.sendSuccess(
() -> Component.literal(
"server-state: enabled=" + state.sablegravity$isEnabled()
+ ", strength=" + format(state.sablegravity$getStrength())
+ ", trackedFrame=" + (tracked == null ? "null" : tracked.getUniqueId())
+ ". Local physics lives on LocalPlayer; run /sablegravityclient all"
),
false
);
}
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) {
return Long.toString((long) value);
}
return Float.toString(value);
return String.format(Locale.ROOT, "%.6f", value);
}
@FunctionalInterface
private interface TestFunction {
void run(CommandSourceStack source) throws Exception;
}
}
}
@@ -1,23 +1,8 @@
package dev.nightly.sablegravity;
import net.minecraft.world.phys.Vec3;
import java.util.UUID;
public interface SableGravityState {
public interface SableGravityState extends GravityEntityState {
boolean sablegravity$isEnabled();
void sablegravity$setEnabled(boolean enabled);
float sablegravity$getStrength();
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;
import dev.nightly.sablegravity.GravityEntityState;
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.ryanhcode.sable.api.entity.EntitySubLevelUtil;
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.callback.CallbackInfoReturnable;
/**
* 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.
*/
/** Supplies Sable with a substep-interpolated full quaternion OBB orientation. */
@Mixin(value = EntitySubLevelUtil.class, priority = 3000, remap = false)
public abstract class EntityCustomOrientationMixin {
@Inject(
@@ -33,21 +29,19 @@ public abstract class EntityCustomOrientationMixin {
float partialTicks,
CallbackInfoReturnable<Quaterniondc> cir
) {
if (!(entity instanceof Player player)) {
GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$isInLocalSimulation()) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
SubLevel frame = resolveFrame(entity);
if (frame == null || frame.isRemoved()) {
return;
}
cir.setReturnValue(frame.logicalPose().orientation());
cir.setReturnValue(
SableFrameOrientation.getFrameOrientation(frame, partialTicks)
);
}
@Inject(
@@ -60,20 +54,38 @@ public abstract class EntityCustomOrientationMixin {
Entity entity,
CallbackInfoReturnable<Boolean> cir
) {
if (!(entity instanceof Player player)) {
GravityEntityState state = (GravityEntityState) entity;
if (state.sablegravity$isInLocalSimulation()) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled() || state.sablegravity$isInLocalSimulation()) {
return;
}
SubLevel frame = SableLocalPlayerPhysics.getAttachedFrame(player);
SubLevel frame = resolveFrame(entity);
if (frame == null || frame.isRemoved()) {
return;
}
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;
import dev.nightly.sablegravity.SableGravityState;
import dev.nightly.sablegravity.physics.SableLocalCollisionProbe;
import dev.nightly.sablegravity.physics.SableLocalPlayerPhysics;
import dev.ryanhcode.sable.companion.math.Pose3dc;
import dev.ryanhcode.sable.sublevel.SubLevel;
import net.minecraft.world.entity.MoverType;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
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.callback.CallbackInfoReturnable;
/**
* 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.
*/
/** Replaces world-axis edge sneaking with a tracked-frame local collision-shape query. */
@Mixin(value = Player.class, priority = 4000)
public abstract class PlayerEdgeSneakMixin {
private static final double STEP = 0.05D;
@@ -36,7 +33,6 @@ public abstract class PlayerEdgeSneakMixin {
|| !state.sablegravity$isEnabled()
|| player.getAbilities().flying
|| !player.isShiftKeyDown()
|| !SableLocalPlayerPhysics.isLocalGrounded(player)
|| (moverType != MoverType.SELF && moverType != MoverType.PLAYER)) {
return;
}
@@ -46,64 +42,68 @@ public abstract class PlayerEdgeSneakMixin {
return;
}
Pose3dc pose = frame.logicalPose();
Vec3 localMovement = pose.transformNormalInverse(movement);
// The player position still belongs to lastPose here, but the requested displacement was
// 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) {
cir.setReturnValue(movement);
return;
}
Vec3 localFeet = pose.transformPositionInverse(player.position());
double halfWidth = player.getBbWidth() * 0.5D;
double height = player.getBbHeight();
AABB localBounds = new AABB(
localFeet.x - halfWidth,
localFeet.y,
localFeet.z - halfWidth,
localFeet.x + halfWidth,
localFeet.y + height,
localFeet.z + halfWidth
);
Vec3 localFeet = SableLocalCollisionProbe.getLocalFeetAtCollisionStart(player, frame);
if (!SableLocalPlayerPhysics.isLocalGrounded(player)
&& !SableLocalCollisionProbe.hasSupport(
player,
frame,
SableLocalCollisionProbe.createFloorProbe(player, localFeet, 0.08D)
)) {
return;
}
AABB localBounds = SableLocalCollisionProbe.createLocalBody(player, localFeet);
Level level = player.level();
double fallDistance = player.maxUpStep();
double x = localMovement.x;
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);
}
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);
}
while (x != 0.0D
&& z != 0.0D
&& wouldSlideOff(level, player, localBounds, x, z, fallDistance)) {
&& wouldSlideOff(player, frame, localBounds, x, z, fallDistance)) {
x = reduceTowardsZero(x);
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(
Level level,
Player player,
SubLevel frame,
AABB localBounds,
double localX,
double localZ,
double fallDistance
) {
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(
future.minX,
future.minX + inset,
future.minY - fallDistance - FLOOR_EPSILON,
future.minZ,
future.maxX,
future.minY,
future.maxZ
future.minZ + inset,
future.maxX - inset,
future.minY + FLOOR_EPSILON,
future.maxZ - inset
);
return level.noCollision(player, floorProbe);
return !SableLocalCollisionProbe.hasSupport(player, frame, floorProbe);
}
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.SynchedEntityData;
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.Unique;
import org.spongepowered.asm.mixin.injection.At;
@@ -24,12 +22,6 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
private static final EntityDataAccessor<Float> SABLEGRAVITY_STRENGTH =
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"))
private void sablegravity$defineData(SynchedEntityData.Builder builder, CallbackInfo ci) {
builder.define(SABLEGRAVITY_ENABLED, false);
@@ -44,8 +36,10 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
@Override
public void sablegravity$setEnabled(boolean enabled) {
((Player) (Object) this).getEntityData().set(SABLEGRAVITY_ENABLED, enabled);
sablegravity$localStateValid = false;
sablegravity$frameId = null;
sablegravity$setLocalStateValid(false);
sablegravity$setFrameId(null);
sablegravity$setFrameProvider(null);
sablegravity$setFrameDimension(null);
}
@Override
@@ -58,14 +52,4 @@ public abstract class PlayerGravityDataMixin implements SableGravityState {
((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.SableGravityState;
import dev.nightly.sablegravity.mixin.LivingEntityInputAccessor;
import dev.ryanhcode.sable.Sable;
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.LivingEntityMovementExtension;
import dev.ryanhcode.sable.sublevel.SubLevel;
import dev.ryanhcode.sable.sublevel.entity_collision.SubLevelEntityCollision;
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.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3d;
import org.joml.Vector3dc;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.WeakHashMap;
/**
* 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 {
private static final double EPSILON = 1.0E-7D;
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 AIR_DRAG = 0.98D;
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 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 float AIR_ACCELERATION = 0.02F;
private static final Map<Player, SubLevel> ATTACHED_FRAMES =
Collections.synchronizedMap(new WeakHashMap<>());
Collections.synchronizedMap(new IdentityHashMap<>());
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 =
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() {}
@@ -49,55 +71,155 @@ public final class SableLocalPlayerPhysics {
return null;
}
SubLevel latched = ATTACHED_FRAMES.get(player);
if (latched != null) {
if (latched.isRemoved() || latched.getLevel() != player.level()) {
detach(player);
latched = null;
} else if (state.sablegravity$getFrameId() != null
&& state.sablegravity$getFrameId().equals(latched.getUniqueId())) {
((EntityMovementExtension) player).sable$setTrackingSubLevel(latched);
return latched;
} else {
ATTACHED_FRAMES.remove(player);
latched = null;
// Read Sable's result before restoring our latch. Sable changes this field when the player
// lands on a different ship. The previous implementation immediately wrote the old frame
// back and could therefore keep collision, gravity and Shift attached to the wrong ship.
SubLevel tracked = validFrame(player, Sable.HELPER.getTrackingSubLevel(player));
SubLevel rawLatched = ATTACHED_FRAMES.get(player);
SubLevel latched = validFrame(player, rawLatched);
if (latched == null && ATTACHED_FRAMES.containsKey(player)) {
if (rawLatched != null
&& rawLatched.getLevel() != player.level()
&& state.sablegravity$isLocalStateValid()) {
GravityFrames.markDimensionHandoff(player);
}
detach(player);
}
SubLevel tracked = Sable.HELPER.getTrackingSubLevel(player);
if (tracked == null || tracked.isRemoved() || tracked.getLevel() != player.level()) {
if (tracked != null && !sameFrame(tracked, latched)) {
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 attach(player, state, tracked);
}
ATTACHED_FRAMES.put(player, tracked);
LOCAL_GROUNDED.put(player, player.onGround());
state.sablegravity$setFrameId(tracked.getUniqueId());
state.sablegravity$setLocalStateValid(false);
state.sablegravity$setLocalPosition(tracked.logicalPose().transformPositionInverse(player.position()));
((EntityMovementExtension) player).sable$setTrackingSubLevel(tracked);
return tracked;
private static boolean sameFrame(SubLevel first, SubLevel second) {
return first != null
&& second != null
&& first.getUniqueId().equals(second.getUniqueId());
}
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) {
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) {
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) {
if (!player.getAbilities().flying) {
return;
}
SableGravityState state = (SableGravityState) player;
if (!state.sablegravity$isEnabled()) {
return;
}
SubLevel frame = getAttachedFrame(player);
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);
return;
}
if (player.getAbilities().flying) {
player.setOnGround(false);
}
LAST_POST_TICK_VELOCITY.put(player, player.getDeltaMovement());
}
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);
LOCAL_GROUNDED.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;
state.sablegravity$setLocalStateValid(false);
state.sablegravity$setLocalPosition(Vec3.ZERO);
state.sablegravity$setFrameId(null);
state.sablegravity$setFrameProvider(null);
state.sablegravity$setFrameDimension(null);
}
public static boolean simulate(Player player, Vec3 input, SubLevel frame) {
@@ -130,7 +270,11 @@ public final class SableLocalPlayerPhysics {
EntityMovementExtension movement = (EntityMovementExtension) player;
Abilities abilities = player.getAbilities();
boolean flying = abilities.flying;
boolean wasFlying = Boolean.TRUE.equals(WAS_FLYING.get(player));
boolean groundedBeforeMove = isLocalGrounded(player);
double flightControlImpulse = flying
? getFlightControlImpulse(player, abilities)
: 0.0D;
Vec3 localVelocity;
if (!state.sablegravity$isLocalStateValid()
@@ -140,47 +284,76 @@ public final class SableLocalPlayerPhysics {
state.sablegravity$setLocalVelocity(localVelocity);
state.sablegravity$setLocalStateValid(true);
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 {
localVelocity = state.sablegravity$getLocalVelocity();
if (flying) {
// LocalPlayer adds creative ascend/descend to deltaMovement before travel.
// Compare against the exact final velocity captured at the previous aiStep TAIL,
// not against a velocity rotated by the current ship pose. This prevents ship
// rotation from being mistaken for flight input and creating runaway acceleration.
Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player);
if (previousFinalWorld != null) {
Vec3 preTravelChangeWorld = player.getDeltaMovement().subtract(previousFinalWorld);
if (preTravelChangeWorld.lengthSqr() > EPSILON) {
Vec3 preTravelChangeLocal = pose.transformNormalInverse(preTravelChangeWorld);
double verticalInput = clamp(
preTravelChangeLocal.y,
-MAX_CAPTURED_FLIGHT_INPUT,
MAX_CAPTURED_FLIGHT_INPUT
);
if (Math.abs(verticalInput) > EPSILON) {
localVelocity = localVelocity.add(0.0D, verticalInput, 0.0D);
}
}
// Import external world-space changes only for ordinary movement. During creative
// flight LocalPlayer and Sable both rewrite deltaMovement as bookkeeping, and
// hasImpulse also remains set across jump/pose transitions. Treating those writes as
// knockback creates a false local X/Z acceleration. Flight controls and the initial
// jump are already written directly to local state by their dedicated paths.
Vec3 previousFinalWorld = LAST_POST_TICK_VELOCITY.get(player);
if (!flying && !wasFlying && previousFinalWorld != null) {
Vec3 externalWorldChange = player.getDeltaMovement().subtract(previousFinalWorld);
if (externalWorldChange.lengthSqr() > EPSILON) {
Vec3 externalLocalChange = pose.transformNormalInverse(externalWorldChange);
localVelocity = localVelocity.add(externalLocalChange);
}
}
}
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;
if (flying) {
acceleration = abilities.getFlyingSpeed() * (player.isSprinting() ? 2.0F : 1.0F);
} 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;
}
// Creative vertical input has already been added to deltaMovement by LocalPlayer.aiStep.
// Only horizontal input is handled here, otherwise ascend/descend would be applied twice.
// Creative vertical input was removed from vanilla's world Y and re-applied to local Y
// above. Only horizontal travel input belongs in the ordinary acceleration path.
Vec3 travelInput = flying ? new Vec3(input.x, 0.0D, input.z) : input;
localVelocity = localVelocity.add(getInputVector(travelInput, acceleration, player.getYRot()));
Vec3 requestedLocalVelocity = localVelocity;
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 positionBeforeMove = player.position();
player.setDeltaMovement(requestedWorldMotion);
@@ -188,20 +361,57 @@ public final class SableLocalPlayerPhysics {
SubLevelEntityCollision.CollisionInfo collision = movement.sable$getCollisionInfo();
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
// 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.
localVelocity = resolveBlockedVelocity(requestedLocalVelocity, actualLocalMotion, collision);
localVelocity = resolveBlockedVelocity(
requestedLocalVelocity,
actualLocalMotion,
collision,
pose
);
boolean localGrounded = collision != null && collision.verticalCollisionBelow;
LOCAL_GROUNDED.put(player, !flying && localGrounded);
boolean localGrounded = collision != null
&& 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) {
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.x * AIR_HORIZONTAL_FRICTION,
localVelocity.y * FLIGHT_VERTICAL_DRAG,
@@ -210,8 +420,12 @@ public final class SableLocalPlayerPhysics {
} else {
player.setOnGround(localGrounded);
if (!player.isNoGravity()) {
double gravity = VANILLA_GRAVITY_PER_TICK
* (Math.max(0.0F, state.sablegravity$getStrength()) / SableGravityMod.VANILLA_STRENGTH);
double gravity = Math.max(0.0D, player.getGravity())
* (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);
}
@@ -236,47 +450,275 @@ public final class SableLocalPlayerPhysics {
}
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);
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;
}
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(
Vec3 requested,
Vec3 actualMotion,
SubLevelEntityCollision.CollisionInfo collision
SubLevelEntityCollision.CollisionInfo collision,
Pose3dc pose
) {
double x = requested.x;
double y = requested.y;
double z = requested.z;
Vec3 resolved = requested;
boolean handledHorizontalContact = false;
boolean handledVerticalContact = false;
if (collision != null) {
if (collision.verticalCollisionBelow && y < 0.0D) {
y = 0.0D;
} else if (collision.verticalCollision && y > 0.0D) {
y = 0.0D;
if (collision != null && collision.firstCollisions != null) {
for (Object value : collision.firstCollisions.values()) {
if (!(value instanceof SubLevelEntityCollision.FirstCollisionInfo info)) {
continue;
}
// 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)) {
x = 0.0D;
}
if (axisWasBlocked(requested.z, actualMotion.z)) {
z = 0.0D;
double x = resolved.x;
double y = resolved.y;
double z = resolved.z;
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);
}
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;
}
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 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) {
@@ -310,7 +752,13 @@ public final class SableLocalPlayerPhysics {
);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
private static double getFlightControlImpulse(Player player, Abilities abilities) {
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]]
modId = "sablegravity"
version = "0.6.0-test16-ground-flight-sneak"
version = "0.7.4-player-recovery"
displayName = "Sable Gravity Local Physics"
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]]
config = "sablegravity.mixins.json"
+12 -3
View File
@@ -4,14 +4,23 @@
"compatibilityLevel": "JAVA_21",
"minVersion": "0.8",
"mixins": [
"EntityGravityStateMixin",
"PlayerGravityDataMixin",
"EntityDirectionalGravityMixin",
"LivingEntityDirectionalGravityMixin",
"PlayerLocalPhysicsMixin",
"EntityCustomOrientationMixin",
"LivingEntityInputAccessor",
"LivingEntityJumpMixin",
"PlayerEdgeSneakMixin"
"PlayerPoseFitMixin",
"EntityCustomOrientationMixin",
"EntityAabbIsolationMixin",
"SableCollisionYawMixin",
"PlayerEdgeSneakMixin",
"PlayerFlightCrouchPoseMixin"
],
"client": [
"LocalPlayerLifecycleMixin"
"LocalPlayerLifecycleMixin",
"LocalPlayerFlightCrouchMixin"
],
"injectors": {
"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.
}
}