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 17815873ac
commit 355e2936c9
63 changed files with 4965 additions and 2226 deletions

View File

@@ -103,9 +103,10 @@ public class NetworkEventHandler {
ModNetwork.sendToPlayer(enslavementPacket, tracker);
}
// FIX MC-262715: Explicitly sync riding state and position
// This fixes the "frozen player" bug when tracker reconnects after
// the tracked player was freed from a vehicle
// Workaround for MC-262715: vanilla doesn't resync riding state or
// position when a tracker reconnects, so a tracked player who was
// freed from a vehicle between the two connect events renders
// frozen until they next move.
if (trackedPlayer instanceof ServerPlayer trackedServerPlayer) {
syncRidingStateAndPosition(trackedServerPlayer, tracker);
}
@@ -328,6 +329,16 @@ public class NetworkEventHandler {
return;
}
// Force-load the target chunk before the search so
// cross-dimension reconnects don't silently fail on an
// unloaded chunk — that was a "disconnect then cross-dim
// travel to escape" hole where the null-furniture path
// below cleared the tag.
targetLevel.getChunk(
furniturePos.getX() >> 4,
furniturePos.getZ() >> 4
);
// Search for the furniture entity near the stored position
Entity furniture = findFurnitureEntity(
targetLevel,
@@ -384,8 +395,27 @@ public class NetworkEventHandler {
);
}
// Re-mount the player
boolean mounted = player.startRiding(furniture, true);
// Re-mount the player.
// If the original seat was taken (e.g. force-mounted by
// another player while this one was offline), assignSeat
// would overwrite the mapping and orphan the current
// occupant. Teleport to the furniture position instead
// and drop the tag — keeping either player in a
// deterministic seat is better than a double-mount.
// Uses ISeatProvider.findPassengerInSeat so future
// non-EntityFurniture seat providers also benefit.
boolean mounted = false;
if (provider.findPassengerInSeat(seatId) != null) {
TiedUpMod.LOGGER.warn(
"[Network] Seat '{}' on furniture {} is now occupied by another entity; skipping reconnection for {}.",
seatId,
furnitureUuidStr,
player.getName().getString()
);
} else {
mounted = player.startRiding(furniture, true);
}
if (mounted) {
provider.assignSeat(player, seatId);
TiedUpMod.LOGGER.info(

View File

@@ -53,9 +53,9 @@ public class PacketEndConversationC2S {
damsel = d;
}
// Always clean up conversation state — this is a teardown packet.
// Distance check removed: blocking cleanup causes permanent state leak
// in ConversationManager.activeConversations (reviewer H18 BUG-001).
// Teardown must not gate on distance — the damsel may have
// moved out of range while the screen was open, and blocking
// cleanup would leak the conversation entry permanently.
ConversationManager.endConversation(sender, damsel);
});
ctx.get().setPacketHandled(true);

View File

@@ -80,9 +80,9 @@ public class PacketCloseMerchantScreen {
return;
}
// Always clean up trading state — this is a teardown packet.
// Distance check removed: blocking cleanup causes permanent state leak
// in tradingPlayers map (reviewer H18 BUG-002).
// Teardown must not gate on distance — the merchant may have moved
// out of range while the screen was open, and blocking cleanup
// would permanently leak the player's entry in tradingPlayers.
merchant.stopTrading(player.getUUID());
}
}

View File

@@ -115,15 +115,22 @@ public class PacketLockpickAttempt {
ServerPlayer player,
LockpickMiniGameState session
) {
// Check for furniture lockpick context FIRST — if present, this is a
// furniture seat lockpick, not a body item lockpick. The context tag is
// written by PacketFurnitureEscape.handleLockpick() when starting the session.
// Furniture seat lockpick path: presence of furniture_id AND a
// session_id matching the current session. A ctx without the nonce
// (or with a foreign nonce) is rejected — this is the branch a
// stale-ctx bug could otherwise mis-route into.
CompoundTag furnitureCtx = player
.getPersistentData()
.getCompound("tiedup_furniture_lockpick_ctx");
if (furnitureCtx != null && furnitureCtx.contains("furniture_id")) {
// H18: Distance check BEFORE ending session — prevents consuming session
// without reward if player moved away (reviewer H18 RISK-001)
boolean ctxValid =
furnitureCtx != null
&& furnitureCtx.contains("furniture_id")
&& furnitureCtx.hasUUID("session_id")
&& furnitureCtx.getUUID("session_id").equals(session.getSessionId());
if (ctxValid) {
// Distance check BEFORE endLockpickSession — consuming a
// session without applying the reward (player walked away)
// would burn the session with no visible effect.
int furnitureId = furnitureCtx.getInt("furniture_id");
Entity furnitureEntity = player.level().getEntity(furnitureId);
if (
@@ -320,13 +327,16 @@ public class PacketLockpickAttempt {
}
session.setRemainingUses(remainingUses);
// Check for JAM (5% chance on miss) — only applies to body item lockpick sessions.
// Furniture seat locks do not have a jam mechanic (there is no ILockable item to jam).
// Jam mechanic (5%) only applies to body-item sessions — seat locks
// have no ILockable stack to jam.
boolean jammed = false;
boolean isFurnitureSession = player
CompoundTag sessionCtx = player
.getPersistentData()
.getCompound("tiedup_furniture_lockpick_ctx")
.contains("furniture_id");
.getCompound("tiedup_furniture_lockpick_ctx");
boolean isFurnitureSession =
sessionCtx.contains("furniture_id")
&& sessionCtx.hasUUID("session_id")
&& sessionCtx.getUUID("session_id").equals(session.getSessionId());
if (!isFurnitureSession && player.getRandom().nextFloat() < 0.05f) {
int targetSlot = session.getTargetSlot();