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

@@ -7,31 +7,25 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Central registry for player animation state tracking.
* Client-side animation state tracking + world-unload cleanup facade.
*
* <p>Holds per-player state maps that were previously scattered across
* AnimationTickHandler. Provides a single clearAll() entry point for
* world unload cleanup.
* <p>Holds {@link #lastTiedState} (the per-player edge-detector used by
* {@link com.tiedup.remake.client.animation.tick.AnimationTickHandler} to
* spot the "just untied" transition) and chains cleanup via
* {@link #clearAll()} across every animation-related cache on world unload.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationStateRegistry {
/** Track last tied state per player */
/** Track last tied state per player (edge-detect on untie transition). */
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
/** Track last animation ID per player to avoid redundant updates */
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
private AnimationStateRegistry() {}
public static Map<UUID, Boolean> getLastTiedState() {
return lastTiedState;
}
public static Map<UUID, String> getLastAnimId() {
return lastAnimId;
}
/**
* Clear all animation-related state in one call.
* Called on world unload to prevent memory leaks and stale data.
@@ -39,7 +33,6 @@ public final class AnimationStateRegistry {
public static void clearAll() {
// Animation state tracking
lastTiedState.clear();
lastAnimId.clear();
// Animation managers
BondageAnimationManager.clearAll();
@@ -50,6 +43,9 @@ public final class AnimationStateRegistry {
// Render state
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
com.tiedup.remake.client.animation.render.PetBedRenderHandler.clearAll();
com.tiedup.remake.client.animation.render.HeldItemHideHandler.clearAll();
com.tiedup.remake.client.animation.render.PlayerArmHideEventHandler.clearAll();
// NPC animation state
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();

View File

@@ -353,8 +353,18 @@ public class BondageAnimationManager {
return null;
}
/** Per-player dedup set so we log the factory-access failure at most once per UUID. */
private static final java.util.Set<UUID> layerFailureLogged =
java.util.concurrent.ConcurrentHashMap.newKeySet();
/**
* Get the animation layer for a player from PlayerAnimationAccess.
*
* <p>Throws during the factory-race window for remote players (the factory
* hasn't yet initialized their associated data). This is the expected path
* for the {@link PendingAnimationManager} retry loop, so we log at DEBUG
* and at most once per UUID — a per-tick log would flood during busy
* multiplayer.</p>
*/
@SuppressWarnings("unchecked")
private static ModifierLayer<IAnimation> getPlayerLayer(
@@ -367,11 +377,13 @@ public class BondageAnimationManager {
FACTORY_ID
);
} catch (Exception e) {
LOGGER.error(
"Failed to get animation layer for player: {}",
player.getName().getString(),
e
);
if (layerFailureLogged.add(player.getUUID())) {
LOGGER.debug(
"Animation layer not yet available for player {} (will retry): {}",
player.getName().getString(),
e.toString()
);
}
return null;
}
}
@@ -521,7 +533,7 @@ public class BondageAnimationManager {
return false;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
ModifierLayer<IAnimation> layer = getOrCreateFurnitureLayer(player);
if (layer != null) {
layer.setAnimation(new KeyframeAnimationPlayer(animation));
// Reset grace ticks since we just started/refreshed the animation
@@ -577,9 +589,11 @@ public class BondageAnimationManager {
}
/**
* Get the furniture ModifierLayer for a player.
* Get the furniture ModifierLayer for a player (READ-ONLY).
* Uses PlayerAnimationAccess for local/factory-registered players,
* falls back to NPC cache for remote players.
* falls back to NPC cache for remote players. Returns null if no layer
* has been created yet — callers that need to guarantee a layer should use
* {@link #getOrCreateFurnitureLayer}.
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
@@ -606,6 +620,61 @@ public class BondageAnimationManager {
return npcFurnitureLayers.get(player.getUUID());
}
/**
* Get or create the furniture ModifierLayer for a player. Mirrors
* {@link #getOrCreateLayer} but for the FURNITURE layer priority.
*
* <p>For the local player (factory-registered), returns the factory layer.
* For remote players, creates a new layer on first call and caches it in
* {@link #npcFurnitureLayers} — remote players don't own a factory layer,
* so without a fallback they can't receive any furniture seat pose.</p>
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getOrCreateFurnitureLayer(
Player player
) {
if (player instanceof AbstractClientPlayer clientPlayer) {
try {
ModifierLayer<IAnimation> layer = (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(
clientPlayer
).get(FURNITURE_FACTORY_ID);
if (layer != null) {
return layer;
}
} catch (Exception e) {
// Fall through to fallback-create below.
}
// Remote players: fallback-create via the animation stack.
if (clientPlayer instanceof IAnimatedPlayer animated) {
return npcFurnitureLayers.computeIfAbsent(
clientPlayer.getUUID(),
k -> {
ModifierLayer<IAnimation> newLayer =
new ModifierLayer<>();
animated
.getAnimationStack()
.addAnimLayer(FURNITURE_LAYER_PRIORITY, newLayer);
LOGGER.debug(
"Created furniture animation layer for remote player via stack: {}",
clientPlayer.getName().getString()
);
return newLayer;
}
);
}
return npcFurnitureLayers.get(clientPlayer.getUUID());
}
// Non-player entities: use NPC cache (read-only; NPC furniture animation
// is not currently produced by this codebase).
return npcFurnitureLayers.get(player.getUUID());
}
/**
* Safety tick for furniture animations. Call once per client tick per player.
*
@@ -731,6 +800,7 @@ public class BondageAnimationManager {
}
}
furnitureGraceTicks.remove(entityId);
layerFailureLogged.remove(entityId);
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
}
@@ -744,6 +814,7 @@ public class BondageAnimationManager {
cache.clear();
}
furnitureGraceTicks.clear();
layerFailureLogged.clear();
LOGGER.info("Cleared all NPC animation layers");
}
}

View File

@@ -6,8 +6,9 @@ import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.util.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
@@ -35,11 +36,13 @@ import net.minecraftforge.fml.common.Mod;
public class DogPoseRenderHandler {
/**
* DOG pose state tracking per player.
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
* DOG pose state per player, keyed by UUID (stable across dimension
* change, unlike the int entity id which gets reassigned when the
* entity re-enters the level). Stores: [0: smoothedTarget, 1: currentRot,
* 2: appliedDelta, 3: isMoving (0/1)]
*/
private static final Int2ObjectMap<float[]> dogPoseState =
new Int2ObjectOpenHashMap<>();
private static final Map<UUID, float[]> dogPoseState =
new ConcurrentHashMap<>();
// Array indices for dogPoseState
private static final int IDX_TARGET = 0;
@@ -51,16 +54,16 @@ public class DogPoseRenderHandler {
* Get the rotation delta applied to a player's render for DOG pose.
* Used by MixinPlayerModel to compensate head rotation.
*/
public static float getAppliedRotationDelta(int playerId) {
float[] state = dogPoseState.get(playerId);
public static float getAppliedRotationDelta(UUID playerUuid) {
float[] state = dogPoseState.get(playerUuid);
return state != null ? state[IDX_DELTA] : 0f;
}
/**
* Check if a player is currently moving in DOG pose.
*/
public static boolean isDogPoseMoving(int playerId) {
float[] state = dogPoseState.get(playerId);
public static boolean isDogPoseMoving(UUID playerUuid) {
float[] state = dogPoseState.get(playerUuid);
return state != null && state[IDX_MOVING] > 0.5f;
}
@@ -72,6 +75,13 @@ public class DogPoseRenderHandler {
dogPoseState.clear();
}
/**
* Drop the state for a single entity leaving the level.
*/
public static void onEntityLeave(UUID entityUuid) {
dogPoseState.remove(entityUuid);
}
/**
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
* HIGH priority ensures this runs before arm/item hiding handlers.
@@ -115,15 +125,15 @@ public class DogPoseRenderHandler {
.getPoseStack()
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
int playerId = player.getId();
UUID playerUuid = player.getUUID();
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
// Get or create state - initialize to current body rotation
float[] s = dogPoseState.get(playerId);
float[] s = dogPoseState.get(playerUuid);
if (s == null) {
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
dogPoseState.put(playerId, s);
dogPoseState.put(playerUuid, s);
}
// Human chair: lock rotation state — body must not turn

View File

@@ -2,6 +2,8 @@ package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraftforge.api.distmarker.Dist;
@@ -13,9 +15,20 @@ import net.minecraftforge.fml.common.Mod;
/**
* Hide first-person hand/item rendering based on bondage state.
*
* Behavior:
* - Tied up: Hide hands completely (hands are behind back)
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
* <p>Behavior:</p>
* <ul>
* <li><b>Tied up</b> (legacy V1 state): hide hands completely — hands are behind back</li>
* <li><b>Mittens</b> (legacy V1 item): hide hands + items (Forge limitation: RenderHandEvent
* controls hand + item together)</li>
* <li><b>V2 item in HANDS or ARMS region</b>: hide hands + items. An armbinder, handcuffs,
* gloves, or any item whose {@link com.tiedup.remake.v2.bondage.IV2BondageItem} declares
* HANDS/ARMS as an occupied or blocked region triggers this. Artists don't need to do
* anything special — declaring the region in the item JSON is enough.</li>
* </ul>
*
* <p>This is the pragmatic alternative to rendering the full GLB item in first-person
* (audit P1-05): the user decided that a player whose arms are restrained shouldn't see
* their arms at all, matching the third-person silhouette where the arms are bound.</p>
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
@@ -38,13 +51,22 @@ public class FirstPersonHandHideHandler {
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
if (state != null && (state.isTiedUp() || state.hasMittens())) {
// Legacy V1 state or item.
event.setCanceled(true);
return;
}
// Tied or Mittens: hide hands completely
// (Forge limitation: RenderHandEvent controls hand + item together)
if (state.isTiedUp() || state.hasMittens()) {
// V2: any item occupying or blocking HANDS/ARMS hides both arms in first-person.
// isRegionBlocked includes the blocked-regions whitelist from equipped items,
// so an armbinder on ARMS that also blocks HANDS hides both even if the HANDS
// slot itself is empty.
if (
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS) ||
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS) ||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.HANDS) ||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.ARMS)
) {
event.setCanceled(true);
}
}

View File

@@ -11,6 +11,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
@@ -37,8 +38,17 @@ public class HeldItemHideHandler {
private static final Int2ObjectMap<ItemStack[]> storedItems =
new Int2ObjectOpenHashMap<>();
@SubscribeEvent
// LOW priority + isCanceled guard: skip mutation when any earlier-
// priority canceller fired. Paired Post uses receiveCanceled = true
// and the storedItems map as a sentinel so held items still get
// restored even when Forge would otherwise skip Post on a cancelled
// Pre.
@SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
@@ -77,7 +87,7 @@ public class HeldItemHideHandler {
}
}
@SubscribeEvent
@SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
@@ -90,4 +100,14 @@ public class HeldItemHideHandler {
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
}
}
/** Drop tracked state for an entity leaving the level. */
public static void onEntityLeave(int entityId) {
storedItems.remove(entityId);
}
/** Drop all tracked state; called on world unload. */
public static void clearAll() {
storedItems.clear();
}
}

View File

@@ -7,6 +7,9 @@ import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.util.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
@@ -21,7 +24,10 @@ import net.minecraftforge.fml.common.Mod;
* Handles pet bed render adjustments (SIT and SLEEP modes).
*
* <p>Applies vertical offset and forced standing pose for pet bed states.
* Runs at HIGH priority alongside DogPoseRenderHandler.
* Runs at LOW priority — observes earlier cancellations from HIGH/NORMAL/LOW
* mods but precedes LOWEST-tier cancellers. The co-ordering with
* DogPoseRenderHandler is state-based (checking {@code isDogOrChairPose}),
* not priority-based.
*
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
*/
@@ -34,10 +40,31 @@ import net.minecraftforge.fml.common.Mod;
public class PetBedRenderHandler {
/**
* Before player render: Apply vertical offset and forced pose for pet bed.
* Players whose forced pose we mutated in {@link #onRenderPlayerPre}.
* {@link #onRenderPlayerPost} only restores the pose for players in this
* set, keeping the mutation/restore pair atomic even when another mod
* cancels Pre (so our Pre returned early without mutating) — otherwise
* Post would null-out a forced pose we never set, potentially clobbering
* state owned by another mod.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
private static final Set<UUID> FORCED_POSE_PLAYERS =
ConcurrentHashMap.newKeySet();
/**
* Before player render: Apply vertical offset and forced pose for pet bed.
*
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
* earlier-priority canceller fired. The paired Post uses
* {@code receiveCanceled = true} + {@link #FORCED_POSE_PLAYERS} so
* mutations still get restored even if a LOWEST-tier canceller runs
* after our Pre.</p>
*/
@SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
@@ -47,7 +74,7 @@ public class PetBedRenderHandler {
return;
}
java.util.UUID petBedUuid = player.getUUID();
UUID petBedUuid = player.getUUID();
byte petBedMode = PetBedClientState.get(petBedUuid);
if (petBedMode == 1 || petBedMode == 2) {
@@ -62,6 +89,7 @@ public class PetBedRenderHandler {
if (petBedMode == 2) {
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
FORCED_POSE_PLAYERS.add(petBedUuid);
// Compensate for vanilla sleeping Y offset
player
@@ -93,17 +121,44 @@ public class PetBedRenderHandler {
/**
* After player render: Restore forced pose for pet bed SLEEP mode.
*
* <p>Only restores when Pre actually mutated the pose (tracked via
* {@link #FORCED_POSE_PLAYERS}). If Pre was cancelled upstream or
* mode flipped between Pre and Post, we never touched this player's
* forced pose — so nulling it out here would clobber another mod's
* state. Symmetric with the LOWEST priority + cancel guard on Pre.</p>
*/
@SubscribeEvent
@SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
byte petBedMode = PetBedClientState.get(player.getUUID());
if (petBedMode == 2) {
UUID playerUuid = player.getUUID();
if (FORCED_POSE_PLAYERS.remove(playerUuid)) {
player.setForcedPose(null);
}
}
/**
* Drain tracked state for an entity leaving the level.
* Called from {@code EntityCleanupHandler} to prevent stale UUIDs from
* lingering when players disconnect mid-render-cycle. Fires for every
* departing entity — non-player UUIDs are simply absent from the set,
* so {@code remove} is a cheap no-op.
*/
public static void onEntityLeave(UUID entityUuid) {
FORCED_POSE_PLAYERS.remove(entityUuid);
}
/**
* Drain all tracked state. Called from
* {@link com.tiedup.remake.client.animation.AnimationStateRegistry#clearAll}
* on world unload so a UUID added between Pre and a world-unload event
* doesn't linger into the next world.
*/
public static void clearAll() {
FORCED_POSE_PLAYERS.clear();
}
}

View File

@@ -9,6 +9,8 @@ import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
@@ -16,6 +18,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
@@ -40,18 +43,36 @@ public class PlayerArmHideEventHandler {
/**
* Stored layer visibility to restore after rendering.
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants].
* Presence in the map is also the sentinel for "Post must restore layers".
*/
private static final Int2ObjectMap<boolean[]> storedLayers =
new Int2ObjectOpenHashMap<>();
/**
* Entity ids whose arm visibility we hid in Pre, so Post only restores
* what we touched. Unconditional restore would clobber arm-hide state
* set by other mods on the shared {@link PlayerModel}.
*/
private static final IntSet hiddenArmEntities = new IntOpenHashSet();
/**
* Before player render:
* - Hide arms for wrap/latex_sack poses
* - Hide outer layers based on clothes settings
*
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
* earlier-priority canceller fired. Paired Post uses
* {@code receiveCanceled = true} + sentinel maps so mutations get
* restored even if a downstream canceller skips the normal Post path
* (Forge gates Post firing on the final canceled state).</p>
*/
@SubscribeEvent
@SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
return;
@@ -82,6 +103,7 @@ public class PlayerArmHideEventHandler {
model.rightArm.visible = false;
model.leftSleeve.visible = false;
model.rightSleeve.visible = false;
hiddenArmEntities.add(player.getId());
}
}
}
@@ -107,9 +129,12 @@ public class PlayerArmHideEventHandler {
}
/**
* After player render: Restore arm visibility and layer visibility.
* After player render: restore visibility only for state we actually
* mutated. {@code receiveCanceled=true} so we fire even when a
* downstream canceller cancelled the paired Pre — otherwise mutations
* stay applied forever.
*/
@SubscribeEvent
@SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
@@ -118,11 +143,13 @@ public class PlayerArmHideEventHandler {
PlayerModel<?> model = event.getRenderer().getModel();
// === RESTORE ARM VISIBILITY ===
model.leftArm.visible = true;
model.rightArm.visible = true;
model.leftSleeve.visible = true;
model.rightSleeve.visible = true;
// === RESTORE ARM VISIBILITY (only if we hid them) ===
if (hiddenArmEntities.remove(player.getId())) {
model.leftArm.visible = true;
model.rightArm.visible = true;
model.leftSleeve.visible = true;
model.rightSleeve.visible = true;
}
// === RESTORE WEARER LAYERS ===
boolean[] savedLayers = storedLayers.remove(player.getId());
@@ -130,4 +157,16 @@ public class PlayerArmHideEventHandler {
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
}
}
/** Drop tracked state for an entity leaving the level. */
public static void onEntityLeave(int entityId) {
storedLayers.remove(entityId);
hiddenArmEntities.remove(entityId);
}
/** Drop all tracked state; called on world unload. */
public static void clearAll() {
storedLayers.clear();
hiddenArmEntities.clear();
}
}

View File

@@ -4,10 +4,8 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Centralizes magic numbers used across render handlers.
*
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
* that were previously scattered as unnamed literals.
* Magic numbers shared across render handlers: DOG pose rotation
* smoothing, head clamp limits, vertical offsets.
*/
@OnlyIn(Dist.CLIENT)
public final class RenderConstants {

View File

@@ -21,6 +21,7 @@ import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
@@ -58,6 +59,29 @@ public class AnimationTickHandler {
/** Tick counter for periodic cleanup tasks */
private static int cleanupTickCounter = 0;
/**
* Per-player retry counter for the cold-cache furniture animation loop.
* A GLB without a {@code Player_*} armature (legacy V1-only furniture)
* can never yield a seat animation, so
* {@link BondageAnimationManager#hasFurnitureAnimation} stays false
* forever and the retry would spam at 20 Hz until dismount. Cap at
* {@link #MAX_FURNITURE_RETRIES}; reset on successful apply or on
* dismount so the next mount starts fresh.
*/
private static final Map<UUID, Integer> furnitureRetryCounters =
new ConcurrentHashMap<>();
private static final int MAX_FURNITURE_RETRIES = 60; // ~3 seconds at 20 Hz — covers slow-disk GLB load
/**
* Drain the retry counter for a specific entity leaving the level.
* Called from {@code EntityCleanupHandler.onEntityLeaveLevel} so a
* remote player getting unloaded (chunk unload, dimension change,
* kicked) doesn't leak a counter until the next world unload.
*/
public static void removeFurnitureRetry(UUID uuid) {
furnitureRetryCounters.remove(uuid);
}
/**
* Client tick event - called every tick on the client.
* Updates animations for all players when their bondage state changes.
@@ -89,6 +113,47 @@ public class AnimationTickHandler {
}
// Safety: remove stale furniture animations for players no longer on seats
BondageAnimationManager.tickFurnitureSafety(player);
// Cold-cache retry: if the player is seated on furniture but has no
// active pose (GLB was not yet loaded at mount time, or the GLB cache
// entry was a transient failure), retry until the cache warms.
// FurnitureGltfCache memoizes failures via Optional.empty(), so
// retries after a genuine parse failure return instantly with no
// reparse. Bounded at MAX_FURNITURE_RETRIES so a legacy V1-only
// GLB (no Player_* armature → seatSkeleton==null → no animation
// ever possible) doesn't spam retries at 20 Hz forever.
// Single read of getVehicle() — avoids a re-read where the
// vehicle could change between instanceof and cast.
com.tiedup.remake.v2.furniture.EntityFurniture furniture =
player.getVehicle() instanceof
com.tiedup.remake.v2.furniture.EntityFurniture f ? f : null;
boolean hasAnim = BondageAnimationManager.hasFurnitureAnimation(
player
);
UUID playerUuid = player.getUUID();
if (furniture != null && !hasAnim) {
int retries = furnitureRetryCounters.getOrDefault(
playerUuid,
0
);
if (retries < MAX_FURNITURE_RETRIES) {
furnitureRetryCounters.put(playerUuid, retries + 1);
com.tiedup.remake.v2.furniture.EntityFurniture.startFurnitureAnimationClient(
furniture,
player
);
if (retries + 1 == MAX_FURNITURE_RETRIES) {
LOGGER.debug(
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
player.getName().getString(),
MAX_FURNITURE_RETRIES
);
}
}
} else {
// Dismounted or successfully applied — drop the counter so a
// later re-mount starts fresh.
furnitureRetryCounters.remove(playerUuid);
}
}
}
@@ -201,11 +266,15 @@ public class AnimationTickHandler {
context,
allOwnedParts
);
// Clear legacy tracking so transition back works
AnimationStateRegistry.getLastAnimId().remove(uuid);
} else if (GltfAnimationApplier.hasActiveState(player)) {
// Clear any residual V2 composite animation when the player
// is still isTiedUp() but has no GLB-bearing items — e.g.
// a non-GLB item keeps the tied state, or a GLB item was
// removed while another V2 item remains on a non-animated
// region. Leaving the composite in place locks the arms in
// the pose of an item the player no longer wears.
GltfAnimationApplier.clearV2Animation(player);
}
// No V2 items with GLB models — nothing to animate.
// Items without data-driven GLB definitions are not animated.
} else if (wasTied) {
// Was tied, now free - stop all animations
if (GltfAnimationApplier.hasActiveState(player)) {
@@ -213,9 +282,9 @@ public class AnimationTickHandler {
} else {
BondageAnimationManager.stopAnimation(player);
}
AnimationStateRegistry.getLastAnimId().remove(uuid);
}
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
}
@@ -227,9 +296,9 @@ public class AnimationTickHandler {
if (event.getEntity().level().isClientSide()) {
UUID uuid = event.getEntity().getUUID();
AnimationStateRegistry.getLastTiedState().remove(uuid);
AnimationStateRegistry.getLastAnimId().remove(uuid);
BondageAnimationManager.cleanup(uuid);
GltfAnimationApplier.removeTracking(uuid);
furnitureRetryCounters.remove(uuid);
}
}
@@ -246,6 +315,7 @@ public class AnimationTickHandler {
// DogPoseRenderHandler, MCAAnimationTickCache)
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
AnimationStateRegistry.clearAll();
furnitureRetryCounters.clear();
// Non-animation client-side caches
PetBedClientState.clearAll();

View File

@@ -1,8 +1,8 @@
package com.tiedup.remake.client.animation.tick;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
@@ -12,12 +12,17 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* multiple times per game tick.
*
* <p>This is extracted from the mixin so it can be cleared on world unload
* to prevent memory leaks.
* to prevent memory leaks. Uses {@link ConcurrentHashMap} because reads
* happen on the render thread (from the mixin's {@code setupAnim} tail
* injection) while {@link #clear} is called from the main thread on world
* unload — a plain {@code HashMap} could observe a torn state or throw
* {@link java.util.ConcurrentModificationException} during the race.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class MCAAnimationTickCache {
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
private static final Map<UUID, Integer> lastTickMap =
new ConcurrentHashMap<>();
private MCAAnimationTickCache() {
// Utility class
@@ -48,4 +53,9 @@ public final class MCAAnimationTickCache {
public static void clear() {
lastTickMap.clear();
}
/** Drop the tick-dedup entry for an entity leaving the level. */
public static void remove(UUID entityUuid) {
lastTickMap.remove(entityUuid);
}
}

View File

@@ -50,7 +50,33 @@ public class NpcAnimationTickHandler {
new ConcurrentHashMap<>();
/**
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
* NPCs currently in a posed state (tied / sitting / kneeling). Values hold
* the live entity reference so the per-tick fast path doesn't need to
* resolve UUID → Entity (the client level doesn't expose a UUID index).
* Populated by {@link #fullSweep}; cleared by {@link #updateNpcAnimation}
* on pose exit, and by {@link #remove} on {@code EntityLeaveLevelEvent}.
*/
private static final Map<UUID, AbstractTiedUpNpc> ACTIVE_NPCS =
new ConcurrentHashMap<>();
/**
* Tick-count gate for the periodic full-entity sweep. A low-frequency
* O(N) fallback catches NPCs that entered the posed state via paths the
* fast path hasn't seen yet (e.g. just-spawned, just-loaded-into-chunk,
* state flipped by a packet we didn't mirror into ACTIVE_NPCS). 20 ticks
* ≈ 1 second — latency is invisible in practice.
*/
private static int sweepCounter = 0;
private static final int FULL_SWEEP_INTERVAL_TICKS = 20;
/**
* Client tick: update animations for posed NPCs.
*
* <p>Fast path (19 of every 20 ticks): iterate only {@link #ACTIVE_NPCS}
* — typically 15 entries — so the cost is O(|active|) instead of
* O(|all client entities|). Full sweep (every 20th tick): re-scan
* {@code entitiesForRendering()} to discover NPCs that entered the pose
* via an untracked path.</p>
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
@@ -63,6 +89,39 @@ public class NpcAnimationTickHandler {
return;
}
sweepCounter++;
if (sweepCounter >= FULL_SWEEP_INTERVAL_TICKS) {
sweepCounter = 0;
fullSweep(mc);
} else {
fastTick();
}
}
/**
* Fast path: iterate only tracked posed NPCs. Entities that have died or
* been removed from the level are dropped from the set here so stale
* references don't linger between full sweeps.
*/
private static void fastTick() {
// ConcurrentHashMap.values() iterator is weakly consistent, so
// concurrent remove() during iteration (from updateNpcAnimation) is
// explicitly supported by the JDK contract.
for (AbstractTiedUpNpc npc : ACTIVE_NPCS.values()) {
if (!npc.isAlive() || npc.isRemoved()) {
ACTIVE_NPCS.remove(npc.getUUID());
continue;
}
updateNpcAnimation(npc);
}
}
/**
* Fallback sweep: O(N) over all rendered entities. Adds newly-posed NPCs
* to {@link #ACTIVE_NPCS} via {@link #updateNpcAnimation}; also runs the
* same logic for already-tracked NPCs, which is idempotent.
*/
private static void fullSweep(Minecraft mc) {
for (Entity entity : mc.level.entitiesForRendering()) {
if (
entity instanceof AbstractTiedUpNpc damsel &&
@@ -84,6 +143,15 @@ public class NpcAnimationTickHandler {
*
* <p>Legacy fallback: if no GLB model is found, falls back to JSON-based
* PlayerAnimator animations via {@link BondageAnimationManager}.
*
* <p><b>For future contributors</b>: this method is the sole writer of the
* {@link #ACTIVE_NPCS} fast-path set. Any new code that flips an NPC into a
* posed state (new packet handler, new AI transition, etc.) should call
* this method directly — otherwise the NPC will not be animated until the
* next 1 Hz full sweep picks it up (~1 s visible latency). If the worst-
* case latency matters for your use case, call
* {@link #updateNpcAnimation(AbstractTiedUpNpc)} yourself to register the
* NPC immediately.</p>
*/
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
boolean inPose =
@@ -91,6 +159,16 @@ public class NpcAnimationTickHandler {
UUID uuid = entity.getUUID();
// Track/untrack in ACTIVE_NPCS so the fast-tick path sees state
// transitions as soon as they're observed here. Idempotent put/remove
// — no double-tracking and no missed removal even if two code paths
// race to the same update.
if (inPose) {
ACTIVE_NPCS.put(uuid, entity);
} else {
ACTIVE_NPCS.remove(uuid);
}
if (inPose) {
// Resolve V2 equipment map
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
@@ -215,5 +293,17 @@ public class NpcAnimationTickHandler {
*/
public static void clearAll() {
lastNpcAnimId.clear();
ACTIVE_NPCS.clear();
}
/**
* Remove an individual NPC's animation state.
* Called from {@link com.tiedup.remake.client.events.EntityCleanupHandler}
* on {@code EntityLeaveLevelEvent} so the per-UUID map doesn't accumulate
* stale entries from dead/unloaded NPCs between world unloads.
*/
public static void remove(java.util.UUID uuid) {
lastNpcAnimId.remove(uuid);
ACTIVE_NPCS.remove(uuid);
}
}