Refactor V2 animation, furniture, and GLTF rendering

Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.

Parsing and rendering
  - Shared GLB parsing helpers consolidated into GlbParserUtils
    (accessor reads, weight normalization, joint-index clamping,
    coordinate-space conversion, animation parse, primitive loop).
  - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
    GltfLiveBoneReader — removes per-frame joint-matrix allocation
    from the render hot path.
  - emitVertex helper dedups three parallel loops in GltfMeshRenderer.
  - TintColorResolver.resolve has a zero-alloc path when the item
    declares no tint channels.
  - itemAnimCache bounded to 256 entries (access-order LRU) with
    atomic get-or-compute under the map's monitor.

Animation correctness
  - First-in-joint-order wins when body and torso both map to the
    same PlayerAnimator slot; duplicate writes log a single WARN.
  - Multi-item composites honor the FullX / FullHeadX opt-in that
    the single-item path already recognized.
  - Seat transforms converted to Minecraft model-def space so
    asymmetric furniture renders passengers at the correct offset.
  - GlbValidator: IBM count / type / presence, JOINTS_0 presence,
    animation channel target validation, multi-skin support.

Furniture correctness and anti-exploit
  - Seat assignment synced via SynchedEntityData (server is
    authoritative; eliminates client-server divergence on multi-seat).
  - Force-mount authorization requires same dimension and a free
    seat; cross-dimension distance checks rejected.
  - Reconnection on login checks for seat takeover before re-mount
    and force-loads the target chunk for cross-dimension cases.
  - tiedup_furniture_lockpick_ctx carries a session UUID nonce so
    stale context can't misroute a body-item lockpick.
  - tiedup_locked_furniture survives death without keepInventory
    (Forge 1.20.1 does not auto-copy persistent data on respawn).

Lifecycle and memory
  - EntityCleanupHandler fans EntityLeaveLevelEvent out to every
    per-entity state map on the client.
  - DogPoseRenderHandler re-keyed by UUID (stable across dimension
    change; entity int ids are recycled).
  - PetBedRenderHandler, PlayerArmHideEventHandler, and
    HeldItemHideHandler use receiveCanceled + sentinel sets so
    Pre-time mutations are restored even when a downstream handler
    cancels the render.

Tests
  - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
    FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
NotEvil
2026-04-18 17:34:03 +02:00
parent 37da2c1716
commit 11188bc621
63 changed files with 4965 additions and 2226 deletions

View File

@@ -90,6 +90,23 @@ public class EntityFurniture
EntityDataSerializers.BYTE
);
/**
* Passenger UUID → seat id, serialized as
* {@code "uuid;seatId|uuid;seatId|..."} (empty string = no assignments).
* Seat id itself must not contain {@code |} or {@code ;} — furniture
* definitions use lowercase snake_case which is safe.
* <p>
* Server updates this string alongside every {@link #seatAssignments}
* mutation so clients see the authoritative mapping. Without this, each
* side independently ran {@code findNearestAvailableSeat} and could
* diverge on multi-seat furniture (wrong render offset, wrong anim).
*/
private static final EntityDataAccessor<String> SEAT_ASSIGNMENTS_SYNC =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.STRING
);
// ========== Animation State Constants ==========
public static final byte STATE_IDLE = 0;
@@ -140,6 +157,7 @@ public class EntityFurniture
this.entityData.define(FURNITURE_ID, "");
this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
this.entityData.define(ANIM_STATE, STATE_IDLE);
this.entityData.define(SEAT_ASSIGNMENTS_SYNC, "");
}
// ========== IEntityAdditionalSpawnData ==========
@@ -205,11 +223,51 @@ public class EntityFurniture
@Override
public void assignSeat(Entity passenger, String seatId) {
seatAssignments.put(passenger.getUUID(), seatId);
syncSeatAssignmentsIfServer();
}
@Override
public void releaseSeat(Entity passenger) {
seatAssignments.remove(passenger.getUUID());
syncSeatAssignmentsIfServer();
}
/**
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
* so tracking clients see the authoritative mapping. No-op on client.
*/
private void syncSeatAssignmentsIfServer() {
if (this.level().isClientSide) return;
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
boolean first = true;
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
if (!first) sb.append('|');
sb.append(entry.getKey()).append(';').append(entry.getValue());
first = false;
}
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
}
/**
* Parse {@link #SEAT_ASSIGNMENTS_SYNC} back into {@link #seatAssignments}.
* Called on the client when the server's broadcast arrives. Malformed
* entries (bad UUID, empty seat id) are skipped silently; we don't want
* to throw on a packet from a future protocol version.
*/
private void applySyncedSeatAssignments(String serialized) {
seatAssignments.clear();
if (serialized.isEmpty()) return;
for (String entry : serialized.split("\\|")) {
int sep = entry.indexOf(';');
if (sep <= 0 || sep == entry.length() - 1) continue;
try {
UUID uuid = UUID.fromString(entry.substring(0, sep));
String seatId = entry.substring(sep + 1);
seatAssignments.put(uuid, seatId);
} catch (IllegalArgumentException ignored) {
// Corrupt UUID — skip this entry, preserve the rest.
}
}
}
@Override
@@ -376,17 +434,14 @@ public class EntityFurniture
int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0;
if (seatIdx < 0) seatIdx = 0;
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
double offset =
seatCount == 1 ? 0.0 : (seatIdx - (seatCount - 1) / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
double offset = FurnitureSeatGeometry.seatOffset(seatIdx, seatCount);
moveFunction.accept(
passenger,
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
}
@@ -399,9 +454,16 @@ public class EntityFurniture
protected void addPassenger(Entity passenger) {
super.addPassenger(passenger);
SeatDefinition nearest = findNearestAvailableSeat(passenger);
if (nearest != null) {
assignSeat(passenger, nearest.id());
// Seat selection is server-authoritative. The client waits for the
// SEAT_ASSIGNMENTS_SYNC broadcast (~1 tick later) before knowing
// which seat this passenger is in. During that gap positionRider
// falls back to entity center — 1 frame of imperceptible glitch
// on mount, in exchange for deterministic multi-seat correctness.
if (!this.level().isClientSide) {
SeatDefinition nearest = findNearestAvailableSeat(passenger);
if (nearest != null) {
assignSeat(passenger, nearest.id());
}
}
// Play entering transition: furniture shows "Occupied" clip while player settles in.
@@ -411,6 +473,125 @@ public class EntityFurniture
this.transitionTicksLeft = 20;
this.transitionTargetState = STATE_OCCUPIED;
}
// Note: the previous client-side startFurnitureAnimationClient call is
// no longer needed here — the animation is kicked off by
// onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
// after mount). The cold-cache retry in AnimationTickHandler still
// covers the case where the GLB hasn't parsed yet.
}
/**
* Client-only: (re)start the seat pose animation for a player mounting this
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
* the seat has no authored clip, or the animation context rejects the setup.
*
* <p>Called from three sites:</p>
* <ul>
* <li>{@link #addPassenger} on mount</li>
* <li>{@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes</li>
* <li>The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)</li>
* </ul>
*
* <p>Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
* for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
* → first available clip. This lets artists author optional state-specific poses
* ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.</p>
*/
public static void startFurnitureAnimationClient(
EntityFurniture furniture,
Player player
) {
if (!furniture.level().isClientSide) return;
SeatDefinition seat = furniture.getSeatForPassenger(player);
if (seat == null) return;
FurnitureDefinition def = furniture.getDefinition();
if (def == null) return;
com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
def.modelLocation()
);
if (gltfData == null) return;
Map<
String,
com.tiedup.remake.client.gltf.GltfData.AnimationClip
> seatClips = gltfData.seatAnimations().get(seat.id());
if (seatClips == null || seatClips.isEmpty()) return;
com.tiedup.remake.client.gltf.GltfData seatSkeleton =
gltfData.seatSkeletons().get(seat.id());
// State-driven clip selection for the player seat armature. Names match
// the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
// author matching clips. The fallback chain handles missing clips
// (state-specific → "Occupied" → first available), so artists only need
// to author what they want to customize.
String stateClipName = switch (furniture.getAnimState()) {
case STATE_OCCUPIED -> "Occupied";
case STATE_STRUGGLE -> "Struggle";
case STATE_ENTERING -> "Enter";
case STATE_EXITING -> "Exit";
case STATE_LOCKING -> "LockClose";
case STATE_UNLOCKING -> "LockOpen";
default -> "Idle";
};
com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
seatClips.get(stateClipName);
if (clip == null) clip = seatClips.get("Occupied");
if (clip == null) clip = seatClips.values().iterator().next();
if (clip == null) return;
dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
clip,
seatSkeleton,
seat.blockedRegions()
);
if (anim != null) {
com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
player,
anim
);
}
}
/**
* Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
* pose for each seated player so the authored state-specific clip kicks in.
* Without this, a server-side transition (mount entering → occupied, lock
* close, struggle start) never propagates to the player's pose.
*/
@Override
public void onSyncedDataUpdated(
net.minecraft.network.syncher.EntityDataAccessor<?> key
) {
super.onSyncedDataUpdated(key);
if (!this.level().isClientSide) return;
if (ANIM_STATE.equals(key)) {
for (Entity passenger : this.getPassengers()) {
if (passenger instanceof Player player) {
startFurnitureAnimationClient(this, player);
}
}
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
applySyncedSeatAssignments(
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
);
// Re-play animations for passengers whose seat id just changed.
// Without this the client could keep rendering a passenger with
// the previous seat's blockedRegions until some other trigger.
for (Entity passenger : this.getPassengers()) {
if (passenger instanceof Player player) {
startFurnitureAnimationClient(this, player);
}
}
}
}
/**
@@ -432,11 +613,7 @@ public class EntityFurniture
Vec3 passengerPos = passenger.getEyePosition();
Vec3 lookDir = passenger.getLookAngle();
float yawRad = (float) Math.toRadians(this.getYRot());
// Entity-local right axis (perpendicular to facing direction in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -452,11 +629,11 @@ public class EntityFurniture
// Approximate seat world position: entity origin + offset along right axis.
// For a single seat, it's at center. For multiple, spread evenly.
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
// Score: angle between passenger look direction and direction to seat.
@@ -501,10 +678,7 @@ public class EntityFurniture
) {
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -520,11 +694,11 @@ public class EntityFurniture
!seat.lockable() || !seatAssignments.containsValue(seat.id())
) continue;
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);
@@ -698,27 +872,16 @@ public class EntityFurniture
for (IBondageState captive : captorManager.getCaptives()) {
LivingEntity captiveEntity = captive.asLivingEntity();
// Skip captives that are already riding something
if (captiveEntity.isPassenger()) continue;
// Must be tied (leashed) and alive
if (!captive.isTiedUp()) continue;
if (!captiveEntity.isAlive()) continue;
// Must be within 5 blocks of the furniture
if (captiveEntity.distanceTo(this) > 5.0) continue;
// Verify collar ownership
if (!captive.hasCollar()) continue;
ItemStack collarStack = captive.getEquipment(
BodyRegionV2.NECK
);
// Unified authorization via shared predicate.
if (
collarStack.isEmpty() ||
!CollarHelper.isCollar(collarStack)
) continue;
if (
!CollarHelper.isOwner(collarStack, serverPlayer) &&
!serverPlayer.hasPermissions(2)
) continue;
!FurnitureAuthPredicate.canForceMount(
serverPlayer,
this,
captiveEntity
)
) {
continue;
}
// Detach leash only (drop the lead, keep tied-up status)
captive.free(false);
@@ -761,13 +924,22 @@ public class EntityFurniture
// Priority 2: Key + occupied seat -> lock/unlock
// Use look direction to pick the nearest occupied, lockable seat.
// Authorization is enforced via FurnitureAuthPredicate so the in-world
// path cannot drift from the packet path.
ItemStack heldItem = player.getItemInHand(hand);
if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) {
if (
isKeyItem(heldItem) &&
!this.getPassengers().isEmpty() &&
player instanceof ServerPlayer sp
) {
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
player,
def
);
if (targetSeat != null) {
if (
targetSeat != null &&
FurnitureAuthPredicate.canLockUnlock(sp, this, targetSeat.id())
) {
boolean wasLocked = isSeatLocked(targetSeat.id());
setSeatLocked(targetSeat.id(), !wasLocked);
@@ -838,7 +1010,9 @@ public class EntityFurniture
/**
* Check if the given item is a key that can lock/unlock furniture seats.
* Currently only {@link ItemMasterKey} qualifies.
* Hardcoded to {@link ItemMasterKey}: the seat-lock mechanic is single-key
* by design (no per-seat keys). A future second key item would need an
* {@code ILockKey} interface; until then {@code instanceof} is cheapest.
*/
private boolean isKeyItem(ItemStack stack) {
return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
@@ -970,6 +1144,7 @@ public class EntityFurniture
"[EntityFurniture] Cleaned up stale seat assignments on {}",
getFurnitureId()
);
syncSeatAssignmentsIfServer();
updateAnimState();
}
}
@@ -1036,6 +1211,9 @@ public class EntityFurniture
}
}
}
// Push the restored map into the synced field so clients that start
// tracking the entity after load get the right assignments too.
syncSeatAssignmentsIfServer();
this.refreshDimensions();
}
@@ -1110,6 +1288,19 @@ public class EntityFurniture
this.entityData.set(ANIM_STATE, state);
}
/**
* Set a transient animation state that auto-reverts to {@code target}
* after {@code ticks} ticks. Callers of transitional states
* (LOCKING/UNLOCKING/ENTERING/EXITING) MUST use this rather than
* {@link #setAnimState} so the tick-decrement path has the right
* target to revert to.
*/
public void setTransitionState(byte state, int ticks, byte target) {
this.entityData.set(ANIM_STATE, state);
this.transitionTicksLeft = ticks;
this.transitionTargetState = target;
}
// ========== Entity Behavior Overrides ==========
/** Furniture can be targeted by the crosshair (for interaction and attack). */