Merge pull request 'Refactor V2 animation, furniture, and GLTF rendering' (#21) from refactor/v2-animation-hardening into develop

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-04-18 22:26:58 +00:00
63 changed files with 4965 additions and 2226 deletions

View File

@@ -231,11 +231,30 @@ dependencies {
// The group id is ignored when searching -- in this case, it is "blank"
// implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}")
// Unit tests (pure-logic, no Minecraft runtime).
// Do NOT add Forge/Minecraft dependencies here — the test classpath is intentionally
// kept minimal so tests run fast and are isolated from the mod environment.
// Tests that need MC runtime should use the Forge GameTest framework instead.
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.mockito:mockito-core:5.11.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2'
// For more info:
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
// http://www.gradle.org/docs/current/userguide/dependency_management.html
}
// JUnit 5 test task configuration.
// ForgeGradle's default `test` task does not enable JUnit Platform by default — we
// must opt-in explicitly for the Jupiter engine to discover @Test methods.
tasks.named('test', Test).configure {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = false
}
}
// This block of code expands all declared replace properties in the specified resource targets.
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments.

View File

@@ -108,7 +108,7 @@ PlayerArmature ← armature root object (never keyframe this)
| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. Also available as free bones in `Full` animations. |
| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. Also available as free bones in `Full` animations. |
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive.
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive. If your GLB contains **both** `body` and `torso` bones, the runtime will use only the first one encountered in the joint array and emit a WARN in the log: `"Bone 'torso' maps to PlayerAnimator part 'body' already written by an earlier bone — ignoring."` To avoid this, rig with one or the other, never both.
---
@@ -319,9 +319,32 @@ To animate free bones (body, legs not owned by another item), use the `Full` pre
**Frame 0 is the base pose** — the minimum every animation must have. The mod always has a valid pose to display from frame 0.
**Multi-frame animations** (loops, transitions) are supported by the GLB parser — all frames are parsed and stored. However, **multi-frame playback is not yet implemented** in the item animation converter (`GltfPoseConverter`). Currently only frame 0 is read at runtime. Multi-frame playback is a high-priority feature — see `docs/TODO.md`.
**Multi-frame animations play fully** — struggle thrashing, walk cycles, breathing idles all animate at runtime. The converter iterates every keyframe and emits them at the correct MC tick.
**What this means for artists now:** Design your animations with full multi-frame loops (struggle thrashing, walk cycles, breathing idles). Put the most representative pose at frame 0. When multi-frame playback ships, your animations will work immediately without re-exporting.
#### Authoring at 20 FPS (strongly recommended)
Minecraft ticks at **20 Hz**. Authoring at 20 FPS gives you a 1:1 mapping — every source frame becomes one MC tick.
If you author at a higher rate (24 / 30 / 60 FPS — Blender defaults), the converter quantizes timestamps to MC ticks via rounding. Multiple source frames that round to the same tick are **deduplicated** — only the first is kept, the rest are skipped. Practical impact:
| Source FPS | Frames kept per second | Lost per second |
|---|---|---|
| 20 | 20 | 0 |
| 24 | ~20 | ~4 |
| 30 | 20 | ~10 |
| 60 | 20 | ~40 |
For smooth motion at any rate, set Blender's scene FPS to 20 and author accordingly. If you must author at 24+, put critical keyframes on integer multiples of `1/20s = 50ms` to ensure they land on unique ticks.
#### Timeline start
The converter **normalizes the timeline** so the first keyframe plays at tick 0, even if your Blender action's first keyframe is at a non-zero time (NLA strips, trimmed clips). You don't need to pre-shift your timelines.
#### What the converter reads
- **Rotations** per joint (full multi-frame). **This is the primary driver.**
- **Translations** are parsed but not yet applied to the player animation — use rotations for all motion. (Bone translations are used for the furniture seat skeleton anchor, not the player pose.)
- **Ease**: linear interpolation between keyframes. Blender's default F-Curve interpolation (Bezier) is sampled by the exporter at the authored framerate — if you need smooth motion, add keyframes at the sample rate, don't rely on curve-side smoothing.
### Optional Animations
@@ -618,6 +641,8 @@ In your JSON definition, separate the mesh from the animations:
### Export Settings
**Set your Blender scene FPS to 20** (Scene Properties > Frame Rate > Custom = 20) before authoring animations. Minecraft ticks at 20 Hz; any source frame rate above 20 FPS will have frames silently deduplicated at load. See [Animation Frames](#animation-frames).
**File > Export > glTF 2.0 (.glb)**
| Setting | Value | Why |
@@ -634,11 +659,12 @@ In your JSON definition, separate the mesh from the animations:
### Pre-Export Checklist
- [ ] Scene FPS set to **20** (Blender default is 24 — change it)
- [ ] Armature is named `PlayerArmature`
- [ ] All 11 bones have correct names (case-sensitive)
- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc.
- [ ] Mesh is weight-painted to skeleton bones only
- [ ] Weights are normalized
- [ ] Weights are normalized (the mod re-normalizes at load as a safety net, but authoring-normalized weights give the most predictable result)
- [ ] Custom bones (if any) are parented to a standard bone in the hierarchy
- [ ] Your item mesh is named `Item` in Blender (recommended — ensures the mod picks the correct mesh if your file has multiple objects)
- [ ] Materials/textures are applied (the GLB bakes them in)
@@ -756,7 +782,6 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `display_name` | string | Yes | Name shown in-game |
| `model` | string | Yes | ResourceLocation of the GLB mesh |
| `slim_model` | string | No | GLB for Alex-model players (3px arms) |
| `texture` | string | No | Override texture (if not baked in GLB) |
| `animation_source` | string | No | GLB to read animations from (defaults to `model`) |
| `regions` | string[] | Yes | Body regions this item occupies |
| `blocked_regions` | string[] | No | Regions blocked for other items (defaults to `regions`) |
@@ -1039,6 +1064,17 @@ The validation runs automatically on every resource reload (F3+T). Check your ga
If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The mod prioritizes a mesh named `Item` over other meshes. If no `Item` mesh is found, the last non-`Player` mesh is used (backward compatible, but may pick the wrong one in multi-mesh files).
### Parse-Time Warnings (watch the log)
Beyond the toast-based validator, the parser emits WARN-level log lines on load for specific malformations. Grep your `logs/latest.log` for `[GltfPipeline]` / `[FurnitureGltf]` to catch these:
| WARN message | Meaning | What to do |
|---|---|---|
| `Clamped N out-of-range joint indices in '<file>'` | The mesh references joint indices ≥ bone count. Clamped to joint 0 (root) to avoid a crash — affected vertices render at the root position, usually visibly wrong. | In Blender, select the mesh, `Weights > Limit Total` (set to 4), then re-normalize and re-export. |
| `WEIGHTS_0 array length N is not a multiple of 4` | Malformed skin data (not per-glTF-spec VEC4). Trailing orphan weights are ignored. | Re-export. If it persists, check your mesh for non-mesh attribute overrides in Blender's Object Data properties. |
| `GLB size X exceeds cap 52428800` | File too large (>50 MB cap). Parsing is refused; the asset won't render. | Decimate mesh, downsize textures, or split the model. Furniture meshes rarely need to exceed 200 KB. |
| `Accessor would read past BIN chunk` / `Accessor count * components overflows int` | Malformed or hostile GLB accessor declaring impossible sizes. Parse refused. | Re-export from Blender (not an authoring mistake — likely a corrupted export). |
---
## Common Mistakes
@@ -1048,6 +1084,7 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
| Mistake | Symptom | Fix |
|---------|---------|-----|
| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | WARN in log with suggestion: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
| Both `body` and `torso` bones present | WARN in log: "maps to PlayerAnimator part 'body' already written" — only the first bone in the joint array drives the pose, the other is ignored | Use one or the other. Prefer `body`. Delete the redundant bone from the rig. |
| Extra bones in the armature | Custom bones follow their parent in rest pose | Intentional custom bones are fine (chains, decorations). Unintentional ones add file size — delete them. |
| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` |
| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects |
@@ -1060,7 +1097,8 @@ If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The
| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` |
| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps |
| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions |
| Multi-frame animations play as static pose | Multi-frame playback not yet implemented — only frame 0 is used | Design full animations now (they'll work when playback ships). Ensure frame 0 is a good base pose. |
| Bone rotated a quarter-turn around its vertical axis (yaw ≈ ±90°) | Jitter or sudden flip in pitch/roll at the boundary | Gimbal-lock in the Euler ZYX decomposition used to feed PlayerAnimator. Keep bone yaw within ±85° of forward; if you need a 90° yaw, add a few degrees of pitch or roll. |
| Animation looks choppy or loses keyframes | Source FPS > 20 — multiple source frames round to the same MC tick and all but the first are deduplicated | Set Blender's scene FPS to 20 and re-export. See [Animation Frames](#animation-frames) for the mapping table. |
### Weight Painting Issues
@@ -1265,21 +1303,29 @@ Furniture_Armature|Shake ← whole frame vibrates
#### Player Seat Animations
Target the `Player_*` armatures. Blender exports them as `Player_main|AnimName`.
The mod resolves them as `{seatId}:{AnimName}`.
The mod resolves them per seat ID (e.g., `Player_main|Idle` → seat `main`, clip `Idle`).
| Animation Name | When Played | Required? |
|---------------|------------|-----------|
| `Idle` | Default seated pose | **Yes** (no fallback) |
| `Struggle` | Player struggling to escape | Optional (stays in Idle) |
| `Enter` | Mount transition (one-shot, 1 second) | Optional (snaps to Idle if absent) |
| `Exit` | Dismount transition (one-shot, 1 second) | Optional (snaps to vanilla if absent) |
| `Idle` | Default seated pose (STATE_IDLE) | **Yes** — canonical fallback |
| `Occupied` | At least one passenger is seated (STATE_OCCUPIED) | Optional (falls back to Idle) |
| `Struggle` | Player struggling to escape (STATE_STRUGGLE) | Optional (falls back to Occupied → Idle) |
| `Enter` | Mount transition (STATE_ENTERING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
| `Exit` | Dismount transition (STATE_EXITING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
| `LockClose` | Seat is being locked (STATE_LOCKING) | Optional (falls back to Occupied → Idle) |
| `LockOpen` | Seat is being unlocked (STATE_UNLOCKING) | Optional (falls back to Occupied → Idle) |
The mod plays the state-specific clip if authored. When a state transitions server-side, the pose updates automatically on all clients — no packet work required.
**Fallback chain:** state-specific clip → `Occupied` → first authored clip. This means: if you only author `Idle`, the player holds it for every state. Adding `Struggle` and `Enter` gets you polish on those states without breaking anything if you skip the rest.
Example in Blender's Action Editor:
```
Player_main|Idle → resolved as "main:Idle" ← arms spread, legs apart
Player_main|Struggle → resolved as "main:Struggle" ← pulling against restraints
Player_left|Idle → resolved as "left:Idle" ← head and arms through pillory
Player_right|Idle → resolved as "right:Idle" ← same pose, other side
Player_main|Idle → seat "main" clip "Idle" ← arms spread, legs apart
Player_main|Struggle → seat "main" clip "Struggle" ← pulling against restraints
Player_main|Enter → seat "main" clip "Enter" ← one-shot mount transition
Player_left|Idle seat "left" clip "Idle" head and arms through pillory
Player_right|Idle → seat "right" clip "Idle" ← same pose, other side
```
**Key difference from body items:** Furniture player animations control **ALL 11 bones**, not just region-owned bones. The furniture overrides the player's entire pose for the blocked regions, and the remaining regions still show body item effects (gag, blindfold, etc.).

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);
}
}

View File

@@ -1,8 +1,19 @@
package com.tiedup.remake.client.events;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.client.animation.AnimationStateRegistry;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.PendingAnimationManager;
import com.tiedup.remake.client.animation.render.DogPoseRenderHandler;
import com.tiedup.remake.client.animation.render.HeldItemHideHandler;
import com.tiedup.remake.client.animation.render.PetBedRenderHandler;
import com.tiedup.remake.client.animation.render.PlayerArmHideEventHandler;
import com.tiedup.remake.client.animation.tick.AnimationTickHandler;
import com.tiedup.remake.client.animation.tick.MCAAnimationTickCache;
import com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler;
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.client.state.MovementStyleClientState;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
@@ -11,16 +22,10 @@ import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger;
/**
* Automatic cleanup handler for entity-related resources.
*
* <p>This handler automatically cleans up animation layers and pending animations
* when entities leave the world, preventing memory leaks from stale cache entries.
*
* <p>Phase: Performance & Memory Management
*
* <p>Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)},
* which was error-prone and could lead to memory leaks if forgotten.
* This handler ensures cleanup happens automatically on entity removal.
* Fans out {@link EntityLeaveLevelEvent} to every per-entity state map on
* the client — the single source of truth for "entity is gone, drop its
* tracked state". Each target owns its own static map; this handler
* ensures none of them leak entries for dead/unloaded entities.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
@@ -56,15 +61,25 @@ public class EntityCleanupHandler {
return;
}
// Clean up animation layers
BondageAnimationManager.cleanup(event.getEntity().getUUID());
java.util.UUID uuid = event.getEntity().getUUID();
// Clean up pending animation queue
PendingAnimationManager.remove(event.getEntity().getUUID());
BondageAnimationManager.cleanup(uuid);
PendingAnimationManager.remove(uuid);
GltfAnimationApplier.removeTracking(uuid);
NpcAnimationTickHandler.remove(uuid);
MovementStyleClientState.clear(uuid);
PetBedClientState.clear(uuid);
PetBedRenderHandler.onEntityLeave(uuid);
AnimationTickHandler.removeFurnitureRetry(uuid);
AnimationStateRegistry.getLastTiedState().remove(uuid);
DogPoseRenderHandler.onEntityLeave(uuid);
PlayerArmHideEventHandler.onEntityLeave(event.getEntity().getId());
HeldItemHideHandler.onEntityLeave(event.getEntity().getId());
MCAAnimationTickCache.remove(uuid);
LOGGER.debug(
"Auto-cleaned animation resources for entity: {} (type: {})",
event.getEntity().getUUID(),
uuid,
event.getEntity().getClass().getSimpleName()
);
}

View File

@@ -22,51 +22,37 @@ import org.joml.Vector3f;
/**
* Parser for binary .glb (glTF 2.0) files.
* Extracts mesh geometry, skinning data, bone hierarchy, and animations.
* Filters out meshes named "Player".
* Filters out any mesh whose name starts with {@code "Player"} (the seat
* armature convention) — see {@link GlbParserUtils#isPlayerMesh}.
*/
public final class GlbParser {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParser() {}
/**
* Parse a .glb file from an InputStream.
* Parse a .glb file from an InputStream. Validates header, version, and total
* length (capped at {@link GlbParserUtils#MAX_GLB_SIZE}) before allocating chunk
* buffers.
*
* @param input the input stream (will be fully read)
* @param debugName name for log messages
* @return parsed GltfData
* @throws IOException if the file is malformed or I/O fails
* @throws IOException if the file is malformed, oversized, or truncated
*/
public static GltfData parse(InputStream input, String debugName)
throws IOException {
byte[] allBytes = input.readAllBytes();
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
ByteOrder.LITTLE_ENDIAN
);
// -- Header --
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
int totalLength = buf.getInt();
ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);
// -- JSON chunk --
int jsonChunkLength = buf.getInt();
int jsonChunkLength = GlbParserUtils.readChunkLength(
buf,
"JSON",
debugName
);
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
if (jsonChunkType != GlbParserUtils.CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName);
}
byte[] jsonBytes = new byte[jsonChunkLength];
@@ -77,9 +63,13 @@ public final class GlbParser {
// -- BIN chunk --
ByteBuffer binData = null;
if (buf.hasRemaining()) {
int binChunkLength = buf.getInt();
int binChunkLength = GlbParserUtils.readChunkLength(
buf,
"BIN",
debugName
);
int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) {
if (binChunkType != GlbParserUtils.CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName);
}
byte[] binBytes = new byte[binChunkLength];
@@ -108,13 +98,10 @@ public final class GlbParser {
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix (e.g., "MyRig|body" -> "body")
if (name.contains("|")) {
name = name.substring(name.lastIndexOf('|') + 1);
}
String name = GlbParserUtils.stripArmaturePrefix(rawName);
// Log info for non-MC bones
if (!GltfBoneMapper.isKnownBone(name)) {
String suggestion = GltfBoneMapper.suggestBoneName(name);
@@ -135,7 +122,6 @@ public final class GlbParser {
int jointCount = allJointNodes.size();
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -147,8 +133,7 @@ public final class GlbParser {
nodeToJoint[nodeIdx] = j;
}
// Read joint names, rest pose, and build parent mapping
java.util.Arrays.fill(parentJointIndices, -1);
// Read joint names + rest pose
for (int j = 0; j < jointCount; j++) {
int nodeIdx = allJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
@@ -156,52 +141,17 @@ public final class GlbParser {
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix consistently
jointNames[j] = rawName.contains("|")
? rawName.substring(rawName.lastIndexOf('|') + 1)
: rawName;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawName);
// Rest rotation
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf(); // identity
}
// Rest translation
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
restRotations[j] = GlbParserUtils.readRestRotation(node);
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
jointCount
);
// -- Inverse Bind Matrices --
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
@@ -239,7 +189,7 @@ public final class GlbParser {
selectedMeshName = meshName;
break; // Convention match — use it
}
if (!"Player".equals(meshName)) {
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
selectedMeshName = meshName;
nonPlayerCount++;
@@ -270,150 +220,25 @@ public final class GlbParser {
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
// -- Accumulate vertex data from ALL primitives --
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
List<int[]> allJoints = new ArrayList<>();
List<float[]> allWeights = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
// -- Read this primitive's vertex data --
float[] primPositions = GlbParserUtils.readFloatAccessor(
GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
mesh,
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
jointCount,
/* readSkinning */true,
materialNames,
debugName
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
// -- Read this primitive's indices (offset by cumulative vertex count) --
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
// Non-indexed: generate sequential indices
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
i;
}
// Offset indices by cumulative vertex count from prior primitives
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// -- Read skinning attributes for this primitive --
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
// No remap needed — all joints are kept, indices match directly.
// Guard against out-of-range joint indices.
for (int i = 0; i < primJoints.length; i++) {
if (primJoints[i] < 0 || primJoints[i] >= jointCount) {
primJoints[i] = 0;
}
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// -- Resolve material name and tint channel --
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
// -- Flatten accumulated data into single arrays --
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
// Build union of all primitive indices (for backward-compat indices() accessor)
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
positions = r.positions;
normals = r.normals;
texCoords = r.texCoords;
indices = r.indices;
meshJoints = r.joints;
weights = r.weights;
vertexCount = r.vertexCount;
parsedPrimitives.addAll(r.primitives);
} else {
// Animation-only GLB: no mesh data
LOGGER.info(
@@ -438,13 +263,8 @@ public final class GlbParser {
String animName = anim.has("name")
? anim.get("name").getAsString()
: "animation_" + ai;
// Strip the "ArmatureName|" prefix if present (Blender convention)
if (animName.contains("|")) {
animName = animName.substring(
animName.lastIndexOf('|') + 1
);
}
GltfData.AnimationClip clip = parseAnimation(
animName = GlbParserUtils.stripArmaturePrefix(animName);
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
anim,
accessors,
bufferViews,
@@ -548,21 +368,18 @@ public final class GlbParser {
? null
: rawAllClips.values().iterator().next();
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
// This is a 180° rotation around Y: negate X and Z for all spatial data
// Convert ALL animation clips to MC space
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z).
// 180° rotation around Z: negate X and Y for all spatial data.
for (GltfData.AnimationClip clip : allClips.values()) {
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
}
convertToMinecraftSpace(
GlbParserUtils.convertMeshToMinecraftSpace(
positions,
normals,
restTranslations,
restRotations,
inverseBindMatrices,
null,
jointCount
); // pass null — clips already converted above
inverseBindMatrices
);
LOGGER.debug(
"[GltfPipeline] Converted all data to Minecraft coordinate space"
);
@@ -590,216 +407,4 @@ public final class GlbParser {
);
}
// ---- Animation parsing ----
private static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
// Collect rotation and translation channels
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (
nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0
) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
// Use the first available channel's timestamps as reference
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
// Build per-joint rotation arrays (null if no animation for that joint)
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
// Build per-joint translation arrays (null if no animation for that joint)
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
// Log translation channels found
if (!transJoints.isEmpty()) {
LOGGER.debug(
"[GltfPipeline] Animation has {} translation channel(s)",
transJoints.size()
);
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// ---- Coordinate system conversion ----
/**
* Convert all spatial data from glTF space to MC model-def space.
* The Blender-exported character faces -Z in glTF, same as MC model-def.
* Only X (right→left) and Y (up→down) differ between the two spaces.
* Equivalent to a 180° rotation around Z: negate X and Y components.
*
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
*/
private static void convertToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
GltfData.AnimationClip animClip,
int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i]; // X
positions[i + 1] = -positions[i + 1]; // Y
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180° Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
// Animation quaternions: same conjugation
if (animClip != null) {
Quaternionf[][] rotations = animClip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
// Animation translations: negate X and Y (same as rest translations)
Vector3f[][] translations = animClip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}
}

View File

@@ -1,9 +1,15 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
@@ -27,8 +33,161 @@ public final class GlbParserUtils {
public static final int UNSIGNED_INT = 5125;
public static final int FLOAT = 5126;
/** Maximum allowed GLB file size to prevent OOM from malformed/hostile assets. */
public static final int MAX_GLB_SIZE = 50 * 1024 * 1024; // 50 MB
// GLB binary format constants (shared between parsers and validator).
public static final int GLB_MAGIC = 0x46546C67; // "glTF"
public static final int GLB_VERSION = 2;
public static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
public static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParserUtils() {}
/**
* Safely read a GLB stream into a little-endian ByteBuffer positioned past the
* 12-byte header, after validating magic, version, and total length.
*
* <p>Protects downstream parsers from OOM and negative-length crashes on malformed
* or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.</p>
*
* @param input the input stream (will be read)
* @param debugName name included in diagnostic messages
* @return a buffer positioned at the start of the first chunk, with
* remaining bytes exactly equal to {@code totalLength - 12}
* @throws IOException on bad header, size cap exceeded, or truncation
*/
public static ByteBuffer readGlbSafely(InputStream input, String debugName)
throws IOException {
byte[] header = input.readNBytes(12);
if (header.length < 12) {
throw new IOException("GLB truncated in header: " + debugName);
}
ByteBuffer hdr = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
int magic = hdr.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = hdr.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
int totalLength = hdr.getInt();
if (totalLength < 12) {
throw new IOException(
"GLB total length " + totalLength + " too small in " + debugName
);
}
if (totalLength > MAX_GLB_SIZE) {
throw new IOException(
"GLB size " +
totalLength +
" exceeds cap " +
MAX_GLB_SIZE +
" in " +
debugName
);
}
int bodyLen = totalLength - 12;
byte[] body = input.readNBytes(bodyLen);
if (body.length < bodyLen) {
throw new IOException(
"GLB truncated: expected " +
bodyLen +
" body bytes, got " +
body.length +
" in " +
debugName
);
}
return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
}
/**
* Normalize per-vertex skinning weights so each 4-tuple sums to 1.0.
*
* <p>Blender auto-weights + float quantization often produce sums slightly ≠ 1
* (commonly 0.981.02). LBS without normalization scales the vertex by that
* factor — tiny error per vertex, visible drift over a full mesh.
* Tuples that sum to effectively zero (no influence) are left alone so
* downstream code can treat them as "un-skinned" if needed.</p>
*
* <p>Modifies the array in place. Call once at parse time; zero per-frame cost.</p>
*/
public static void normalizeWeights(float[] weights) {
if (weights == null) return;
if (weights.length % 4 != 0) {
// WEIGHTS_0 is VEC4 per glTF spec; a non-multiple-of-4 array is malformed.
// We still process the well-formed prefix.
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] WEIGHTS_0 array length {} is not a multiple of 4 (malformed); trailing {} values ignored",
weights.length,
weights.length % 4
);
}
for (int i = 0; i + 3 < weights.length; i += 4) {
float sum =
weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3];
if (sum > 1.0e-6f && Math.abs(sum - 1.0f) > 1.0e-4f) {
float inv = 1.0f / sum;
weights[i] *= inv;
weights[i + 1] *= inv;
weights[i + 2] *= inv;
weights[i + 3] *= inv;
}
}
}
/**
* Clamp joint indices into the valid range [0, jointCount), remapping
* out-of-range indices to 0 (root). <b>Mutates {@code joints} in place.</b>
* Returns the number of clamps performed so the caller can log a single
* warning when a file is malformed.
*/
public static int clampJointIndices(int[] joints, int jointCount) {
if (joints == null) return 0;
int clamped = 0;
for (int i = 0; i < joints.length; i++) {
if (joints[i] < 0 || joints[i] >= jointCount) {
joints[i] = 0;
clamped++;
}
}
return clamped;
}
/**
* Read a chunk length from the buffer and validate it fits within remaining bytes.
* @throws IOException if length is negative or exceeds remaining
*/
public static int readChunkLength(
ByteBuffer buf,
String chunkName,
String debugName
) throws IOException {
int len = buf.getInt();
if (len < 0) {
throw new IOException(
"Negative " + chunkName + " chunk length in " + debugName
);
}
if (len > buf.remaining() - 4) {
// -4 for the chunk-type field that follows
throw new IOException(
chunkName +
" chunk length " +
len +
" exceeds remaining bytes in " +
debugName
);
}
return len;
}
// ---- Material name parsing ----
/**
@@ -99,16 +258,25 @@ public final class GlbParserUtils {
? bv.get("byteStride").getAsInt()
: 0;
int totalElements = count * components;
float[] result = new float[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
float[] result = new float[totalElements];
// Seek once per element; the sequential reads in readComponentAsFloat
// advance the buffer through the components. Explicit per-component
// seeks are redundant because component c+1 is already at the right
// offset after reading c.
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsFloat(
binData,
componentType
@@ -142,16 +310,22 @@ public final class GlbParserUtils {
? bv.get("byteStride").getAsInt()
: 0;
int totalElements = count * components;
int[] result = new int[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
int[] result = new int[totalElements];
// Seek once per element — see readFloatAccessor comment.
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsInt(
binData,
componentType
@@ -162,6 +336,77 @@ public final class GlbParserUtils {
return result;
}
/**
* Reject malformed/hostile accessors before allocating. Prevents OOM from a
* JSON declaring e.g. {@code count=5e8, type=MAT4} (~8 GB) within a legitimately
* sized GLB. Checks (all must hold):
* <ul>
* <li>{@code count >= 0} and {@code components >= 1}</li>
* <li>{@code count * components} doesn't overflow int</li>
* <li>{@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}</li>
* </ul>
*
* @return {@code count * components} (the allocation size)
*/
private static int validateAccessorBounds(
int count,
int components,
int componentSize,
int byteOffset,
int stride,
int binCapacity
) {
if (count < 0) {
throw new IllegalArgumentException(
"Accessor count must be non-negative: " + count
);
}
if (components < 1) {
throw new IllegalArgumentException(
"Accessor components must be >= 1: " + components
);
}
if (byteOffset < 0 || stride < 0 || componentSize < 1) {
throw new IllegalArgumentException(
"Accessor has negative byteOffset/stride or zero componentSize"
);
}
int totalElements;
try {
totalElements = Math.multiplyExact(count, components);
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException(
"Accessor count * components overflows int: " +
count +
" * " +
components
);
}
if (count == 0) {
return 0;
}
// Bytes required: byteOffset + stride*(count-1) + components*componentSize
long lastElementStart =
(long) byteOffset + (long) stride * (long) (count - 1);
long elementBytes = (long) components * (long) componentSize;
long required = lastElementStart + elementBytes;
if (required > binCapacity) {
throw new IllegalArgumentException(
"Accessor would read past BIN chunk: needs " +
required +
" bytes, buffer has " +
binCapacity
);
}
return totalElements;
}
/**
* Read one component as a normalized float in [1, 1] (signed) or [0, 1]
* (unsigned). {@code UNSIGNED_INT} is defensive — glTF 2.0 §3.6.2.3 only
* lists BYTE/UBYTE/SHORT/USHORT as valid normalized types; an out-of-spec
* exporter hitting that branch gets a best-effort divide by 0xFFFFFFFF.
*/
public static float readComponentAsFloat(
ByteBuffer buf,
int componentType
@@ -261,8 +506,543 @@ public final class GlbParserUtils {
);
}
// ---- Naming conventions ----
/**
* True when a mesh name follows the {@code Player*} convention — typically
* a player-armature mesh or seat-armature mesh that the item/furniture
* pipelines must skip. Null-safe.
*
* <p>Historically {@link GlbParser} and {@link
* com.tiedup.remake.client.gltf.diagnostic.GlbValidator GlbValidator}
* used {@code "Player".equals(name)} while
* {@code FurnitureGlbParser} used {@code startsWith("Player")}, so a mesh
* named {@code "Player_foo"} was accepted by the item pipeline but
* rejected by the furniture pipeline. Consolidated on the more defensive
* {@code startsWith} variant — matches the artist guide's
* {@code Player_*} seat armature naming convention.</p>
*/
public static boolean isPlayerMesh(@Nullable String meshName) {
return meshName != null && meshName.startsWith("Player");
}
/**
* Strip a Blender-style armature prefix from a bone or animation name.
* {@code "ArmatureName|bone"} → {@code "bone"}. Keeps everything after
* the last {@code |}. Returns the input unchanged if it has no pipe,
* and returns null if the input is null.
*
* <p>All joint/animation/validator reads must go through this helper —
* a site that stores the raw prefixed name silently breaks artists
* with non-default armature names.</p>
*/
public static String stripArmaturePrefix(@Nullable String name) {
if (name == null) return null;
int pipeIdx = name.lastIndexOf('|');
return pipeIdx >= 0 ? name.substring(pipeIdx + 1) : name;
}
// ---- Node rest pose extraction ----
/**
* Read a node's rest rotation from its glTF JSON representation. Returns
* identity quaternion (0, 0, 0, 1) when no {@code rotation} field is present.
*
* <p>Extracted from 3 call sites in {@link GlbParser} /
* {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser
* FurnitureGlbParser} that all had identical logic. Each parser's per-joint
* loop now delegates here to avoid silent drift.</p>
*/
public static Quaternionf readRestRotation(JsonObject node) {
if (node != null && node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
return new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
}
return new Quaternionf(); // identity
}
/**
* Read a node's rest translation. Returns the zero vector when no
* {@code translation} field is present. See {@link #readRestRotation}.
*/
public static Vector3f readRestTranslation(JsonObject node) {
if (node != null && node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
return new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
}
return new Vector3f();
}
// ---- Bone hierarchy ----
/**
* Build the parent-joint index array by traversing the glTF node children.
*
* @param nodes the top-level {@code nodes} array from the glTF JSON
* @param nodeToJoint mapping: {@code nodeIdx → jointIdx} (use {@code -1}
* for nodes that are not part of the skin)
* @param jointCount the size of the resulting array
* @return array of size {@code jointCount} where index {@code j} holds the
* parent joint index, or {@code -1} for roots
*/
public static int[] buildParentJointIndices(
JsonArray nodes,
int[] nodeToJoint,
int jointCount
) {
int[] parentJointIndices = new int[jointCount];
for (int j = 0; j < jointCount; j++) parentJointIndices[j] = -1;
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (!node.has("children")) continue;
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
// Malformed GLBs may declare a child index outside `nodes`;
// silently skip rather than AIOOBE.
if (childNodeIdx < 0 || childNodeIdx >= nodeToJoint.length) {
continue;
}
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
return parentJointIndices;
}
// ---- Animation parsing ----
/**
* Parse a single {@code glTF animation} JSON object into an
* {@link GltfData.AnimationClip}. Returns {@code null} when the animation
* has no channels that map to the current skin.
*
* <p>Skin-specific filtering is encoded in {@code nodeToJoint}: channels
* targeting a node with a {@code -1} mapping are skipped.</p>
*
* @param animation a single entry from the root {@code animations} array
* @param accessors the root {@code accessors} array
* @param bufferViews the root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param nodeToJoint mapping from glTF node index to joint index; use
* {@code -1} for nodes not in the skin
* @param jointCount size of the skin
* @return parsed clip, or {@code null} if no channels matched the skin
*/
@Nullable
public static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
java.util.List<Integer> rotJoints = new java.util.ArrayList<>();
java.util.List<float[]> rotTimestamps = new java.util.ArrayList<>();
java.util.List<Quaternionf[]> rotValues = new java.util.ArrayList<>();
java.util.List<Integer> transJoints = new java.util.ArrayList<>();
java.util.List<float[]> transTimestamps = new java.util.ArrayList<>();
java.util.List<Vector3f[]> transValues = new java.util.ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// ---- Primitive mesh parsing ----
/**
* Result of parsing a mesh's primitives: flat per-attribute arrays plus
* per-primitive metadata. All array lengths are in sync with
* {@link #vertexCount}.
*
* <p>When the parsing request had {@code readSkinning = false} (mesh-only
* path used by {@code FurnitureGlbParser.buildMeshOnlyGltfData}), the
* {@link #joints} and {@link #weights} arrays are empty.</p>
*/
public static final class PrimitiveParseResult {
public final float[] positions;
public final float[] normals;
public final float[] texCoords;
public final int[] indices;
public final int[] joints;
public final float[] weights;
public final List<GltfData.Primitive> primitives;
public final int vertexCount;
PrimitiveParseResult(
float[] positions,
float[] normals,
float[] texCoords,
int[] indices,
int[] joints,
float[] weights,
List<GltfData.Primitive> primitives,
int vertexCount
) {
this.positions = positions;
this.normals = normals;
this.texCoords = texCoords;
this.indices = indices;
this.joints = joints;
this.weights = weights;
this.primitives = primitives;
this.vertexCount = vertexCount;
}
}
/**
* Parse every primitive of a mesh, accumulate per-attribute buffers, and
* flatten the result.
*
* <p>Invariants:</p>
* <ul>
* <li>POSITION is required. NORMAL and TEXCOORD_0 are optional and
* default to zero-filled arrays of the correct size.</li>
* <li>Per-primitive indices are offset by the running
* {@code cumulativeVertexCount} so the flat arrays index
* correctly.</li>
* <li>{@code JOINTS_0} is read, out-of-range indices clamped to 0 with
* a WARN log (once per file, via {@link #clampJointIndices}).</li>
* <li>{@code WEIGHTS_0} is read and normalized per-vertex.</li>
* <li>Material tintability: name prefix {@code "tintable_"} → per-channel
* {@link GltfData.Primitive} entry.</li>
* </ul>
*
* @param mesh a single entry from the root {@code meshes} array
* @param accessors root {@code accessors} array
* @param bufferViews root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param jointCount skin joint count (used to clamp JOINTS_0); pass
* {@code 0} when {@code readSkinning} is false
* @param readSkinning true to read JOINTS_0 / WEIGHTS_0, false for
* mesh-only (furniture placer fallback)
* @param materialNames material name lookup (from
* {@link #parseMaterialNames}); may contain nulls
* @param debugName file/resource name for diagnostics
* @return the consolidated parse result
*/
public static PrimitiveParseResult parsePrimitives(
JsonObject mesh,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int jointCount,
boolean readSkinning,
String[] materialNames,
String debugName
) {
JsonArray primitives = mesh.getAsJsonArray("primitives");
java.util.List<float[]> allPositions = new java.util.ArrayList<>();
java.util.List<float[]> allNormals = new java.util.ArrayList<>();
java.util.List<float[]> allTexCoords = new java.util.ArrayList<>();
java.util.List<int[]> allJoints = new java.util.ArrayList<>();
java.util.List<float[]> allWeights = new java.util.ArrayList<>();
java.util.List<GltfData.Primitive> parsedPrimitives =
new java.util.ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
);
float[] primNormals = attributes.has("NORMAL")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (readSkinning) {
if (attributes.has("JOINTS_0")) {
primJoints = readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
int clamped = clampJointIndices(primJoints, jointCount);
if (clamped > 0) {
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] Clamped {} out-of-range joint indices in '{}'",
clamped,
debugName
);
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
normalizeWeights(primWeights);
}
}
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (
matIdx >= 0 &&
materialNames != null &&
matIdx < materialNames.length
) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
if (readSkinning) {
allJoints.add(primJoints);
allWeights.add(primWeights);
}
cumulativeVertexCount += primVertexCount;
}
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
int[] indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
return new PrimitiveParseResult(
flattenFloats(allPositions),
flattenFloats(allNormals),
flattenFloats(allTexCoords),
indices,
readSkinning ? flattenInts(allJoints) : new int[0],
readSkinning ? flattenFloats(allWeights) : new float[0],
parsedPrimitives,
cumulativeVertexCount
);
}
// ---- Coordinate system conversion ----
/**
* Convert mesh-level spatial data from glTF space to Minecraft model-def
* space. glTF and MC model-def both face -Z; only X (right→left) and Y
* (up→down) differ. Equivalent to a 180° rotation around Z: negate X and Y.
*
* <p>For animation data, see {@link #convertAnimationToMinecraftSpace}.</p>
*
* <ul>
* <li>Vertex positions / normals: (x, y, z) → (-x, -y, z)</li>
* <li>Rest translations: same negation</li>
* <li>Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)</li>
* <li>Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)</li>
* </ul>
*/
public static void convertMeshToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices
) {
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
}
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
}
/**
* Convert an animation clip's rotations and translations to MC space.
* Negate qx/qy for rotations and negate tx/ty for translations.

View File

@@ -6,8 +6,10 @@ import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.Collections;
import java.util.HashSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -54,12 +56,36 @@ public final class GltfAnimationApplier {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/**
* Cache of converted item-layer KeyframeAnimations.
* Keyed by "animSource#context#ownedPartsHash".
* Same GLB + same context + same owned parts = same KeyframeAnimation.
* Cache of converted item-layer KeyframeAnimations, keyed by
* {@code animSource#context#ownedPartsHash}. LRU-bounded via
* access-ordered {@link LinkedHashMap}: size capped at
* {@link #ITEM_ANIM_CACHE_MAX}, head (least-recently-used) evicted on
* overflow. Wrapped in {@link Collections#synchronizedMap} because
* {@code LinkedHashMap.get} mutates the iteration order. External
* iteration is not supported; use only {@code get}/{@code put}/{@code clear}.
*/
private static final int ITEM_ANIM_CACHE_MAX = 256;
// Initial capacity (int)(cap / loadFactor) + 1 so the cap is reached
// without a rehash.
private static final int ITEM_ANIM_CACHE_INITIAL_CAPACITY =
(int) (ITEM_ANIM_CACHE_MAX / 0.75f) + 1;
private static final Map<String, KeyframeAnimation> itemAnimCache =
new ConcurrentHashMap<>();
Collections.synchronizedMap(
new LinkedHashMap<String, KeyframeAnimation>(
ITEM_ANIM_CACHE_INITIAL_CAPACITY,
0.75f,
true // access-order
) {
@Override
protected boolean removeEldestEntry(
Map.Entry<String, KeyframeAnimation> eldest
) {
return size() > ITEM_ANIM_CACHE_MAX;
}
}
);
/**
* Track which composite state is currently active per entity, to avoid redundant replays.
@@ -184,16 +210,22 @@ public final class GltfAnimationApplier {
// (Struggle.1 vs Struggle.2) get separate cache entries.
String variantCacheKey = itemCacheKey + "#" + (glbAnimName != null ? glbAnimName : "default");
KeyframeAnimation itemAnim = itemAnimCache.get(variantCacheKey);
if (itemAnim == null) {
// Pass both owned parts and enabled parts (owned + free) for selective enabling
itemAnim = GltfPoseConverter.convertSelective(
animData,
glbAnimName,
ownership.thisParts(),
ownership.enabledParts()
);
itemAnimCache.put(variantCacheKey, itemAnim);
// Atomic get-or-compute under the map's monitor. Collections
// .synchronizedMap only synchronizes individual get/put calls, so a
// naive check-then-put races between concurrent converters and can
// both double-convert and trip removeEldestEntry with a stale size.
KeyframeAnimation itemAnim;
synchronized (itemAnimCache) {
itemAnim = itemAnimCache.get(variantCacheKey);
if (itemAnim == null) {
itemAnim = GltfPoseConverter.convertSelective(
animData,
glbAnimName,
ownership.thisParts(),
ownership.enabledParts()
);
itemAnimCache.put(variantCacheKey, itemAnim);
}
}
BondageAnimationManager.playDirect(entity, itemAnim);
@@ -292,20 +324,29 @@ public final class GltfAnimationApplier {
return false;
}
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
// Atomic get-or-compute under the map's monitor (see
// applyV2Animation). All current callers are render-thread so no
// contention in practice, but the synchronized wrap closes the
// window where two converters could race and clobber each other.
KeyframeAnimation compositeAnim;
synchronized (itemAnimCache) {
compositeAnim = itemAnimCache.get(compositeCacheKey);
}
if (compositeAnim == null) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = "gltf_composite";
boolean anyLoaded = false;
int maxEndTick = 1;
Set<String> unionKeyframeParts = new HashSet<>();
boolean anyFullBody = false;
boolean anyFullHead = false;
for (ResolvedItem resolved : resolvedItems) {
RegionBoneMapper.V2ItemAnimInfo item = resolved.info();
@@ -338,9 +379,28 @@ public final class GltfAnimationApplier {
}
}
GltfPoseConverter.addBonesToBuilder(
builder, animData, rawClip, effectiveParts
Set<String> itemKeyframeParts =
GltfPoseConverter.addBonesToBuilder(
builder, animData, rawClip, effectiveParts
);
unionKeyframeParts.addAll(itemKeyframeParts);
maxEndTick = Math.max(
maxEndTick,
GltfPoseConverter.computeEndTick(rawClip)
);
// FullX / FullHeadX opt-in: ANY item requesting it lifts the
// restriction for the composite. The animation name passed to
// the core helper uses the same "gltf_" prefix convention as
// the single-item path.
String prefixed = glbAnimName != null
? "gltf_" + glbAnimName
: null;
if (GltfPoseConverter.isFullBodyAnimName(prefixed)) {
anyFullBody = true;
}
if (GltfPoseConverter.isFullHeadAnimName(prefixed)) {
anyFullHead = true;
}
anyLoaded = true;
LOGGER.debug(
@@ -355,33 +415,32 @@ public final class GltfAnimationApplier {
return false;
}
// Enable only owned parts on the item layer.
// Free parts (head, body, etc. not owned by any item) are disabled here
// so they pass through to the context layer / vanilla animation.
String[] allPartNames = {
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg",
};
for (String partName : allPartNames) {
KeyframeAnimation.StateCollection part = getPartByName(
builder,
partName
);
if (part != null) {
if (allOwnedParts.contains(partName)) {
part.fullyEnablePart(false);
} else {
part.setEnabled(false);
}
}
}
builder.endTick = maxEndTick;
builder.stopTick = maxEndTick;
// Selective-part enabling for the composite. Owned parts always on;
// free parts (including head) opt-in only if ANY item declares a
// FullX / FullHeadX animation AND has keyframes for that part.
GltfPoseConverter.enableSelectivePartsComposite(
builder,
allOwnedParts,
unionKeyframeParts,
anyFullBody,
anyFullHead
);
compositeAnim = builder.build();
itemAnimCache.put(compositeCacheKey, compositeAnim);
synchronized (itemAnimCache) {
// Another thread may have computed the same key while we were
// building. Prefer its result to keep one instance per key,
// matching removeEldestEntry's accounting.
KeyframeAnimation winner = itemAnimCache.get(compositeCacheKey);
if (winner != null) {
compositeAnim = winner;
} else {
itemAnimCache.put(compositeCacheKey, compositeAnim);
}
}
}
BondageAnimationManager.playDirect(entity, compositeAnim);
@@ -475,21 +534,4 @@ public final class GltfAnimationApplier {
return String.join(",", new TreeSet<>(ownedParts));
}
/**
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
*/
private static KeyframeAnimation.StateCollection getPartByName(
KeyframeAnimation.AnimationBuilder builder,
String name
) {
return switch (name) {
case "head" -> builder.head;
case "body" -> builder.body;
case "rightArm" -> builder.rightArm;
case "leftArm" -> builder.leftArm;
case "rightLeg" -> builder.rightLeg;
case "leftLeg" -> builder.leftLeg;
default -> null;
};
}
}

View File

@@ -2,6 +2,7 @@ package com.tiedup.remake.client.gltf;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
@@ -13,13 +14,21 @@ import org.apache.logging.log4j.Logger;
/**
* Lazy-loading cache for parsed glTF data.
* Loads .glb files via Minecraft's ResourceManager on first access.
*
* <p>Loads .glb files via Minecraft's ResourceManager on first access.
* Cache values are {@link Optional}: empty means a previous load attempt
* failed and no retry will happen until {@link #clearCache()} is called.
* This mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureGltfCache
* FurnitureGltfCache}'s pattern so both caches have consistent semantics.</p>
*
* <p>Load is atomic via {@link Map#computeIfAbsent}: two concurrent first-misses
* for the same resource will parse the GLB exactly once.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GltfCache {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final Map<ResourceLocation, GltfData> CACHE =
private static final Map<ResourceLocation, Optional<GltfData>> CACHE =
new ConcurrentHashMap<>();
private GltfCache() {}
@@ -27,13 +36,14 @@ public final class GltfCache {
/**
* Get parsed glTF data for a resource, loading it on first access.
*
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb")
* @param location resource location of the .glb file
* @return parsed GltfData, or null if loading failed
*/
public static GltfData get(ResourceLocation location) {
GltfData cached = CACHE.get(location);
if (cached != null) return cached;
return CACHE.computeIfAbsent(location, GltfCache::load).orElse(null);
}
private static Optional<GltfData> load(ResourceLocation location) {
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
@@ -41,17 +51,15 @@ public final class GltfCache {
.orElse(null);
if (resource == null) {
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
return null;
return Optional.empty();
}
try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, location.toString());
CACHE.put(location, data);
return data;
return Optional.of(data);
}
} catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
return null;
return Optional.empty();
}
}

View File

@@ -72,8 +72,55 @@ public final class GltfClientSetup {
}
/**
* Register resource reload listener to clear GLB caches on resource pack reload.
* This ensures re-exported GLB models are picked up without restarting the game.
* Register resource reload listeners in the order required by the
* cache/consumer dependency graph.
*
* <p><b>ORDER MATTERS — do not rearrange without checking the
* invariants below.</b> Forge does not guarantee parallel-safe
* ordering between listeners registered on the same event; we rely
* on {@code apply()} running sequentially in the order of
* {@code registerReloadListener} calls.</p>
*
* <ol>
* <li><b>GLB cache clear</b> (inline listener below) — must run
* first. Inside this single listener's {@code apply()}:
* <ol type="a">
* <li>Blow away the raw GLB byte caches
* ({@code GltfCache.clearCache},
* {@code GltfAnimationApplier.invalidateCache},
* {@code GltfMeshRenderer.clearRenderTypeCache}).
* These three caches are mutually independent — none
* of them reads from the others — so their relative
* order is not load-bearing.</li>
* <li>Reload {@code ContextGlbRegistry} from the new
* resource packs <i>before</i> clearing
* {@code ContextAnimationFactory.clearCache()} — if
* the order is swapped, the next factory lookup will
* lazily rebuild clips against the stale registry
* (which is still populated at that moment), cache
* them, and keep serving old data until the next
* reload.</li>
* <li>Clear {@code FurnitureGltfCache} last, after the GLB
* layer has repopulated its registry but before any
* downstream item listener queries furniture models.</li>
* </ol>
* </li>
* <li><b>Data-driven item reload</b>
* ({@code DataDrivenItemReloadListener}) — consumes the
* reloaded GLB registry indirectly via item JSON references.
* Must run <i>after</i> the GLB cache clear so any item that
* reaches into the GLB layer during load picks up fresh data.</li>
* <li><b>GLB validation</b>
* ({@code GlbValidationReloadListener}) — runs last. It walks
* both the item registry and the GLB cache to surface
* authoring issues via toast. If it ran earlier, missing
* items would falsely trip the "referenced but not found"
* diagnostic.</li>
* </ol>
*
* <p>When adding a new listener: decide where it sits in this
* producer/consumer chain. If you're not sure, add it at the end
* (the safest position — the rest of the graph is already built).</p>
*/
@SubscribeEvent
public static void onRegisterReloadListeners(
@@ -96,7 +143,6 @@ public final class GltfClientSetup {
ProfilerFiller profiler
) {
GltfCache.clearCache();
GltfSkinCache.clearAll();
GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache();
// Reload context GLB animations from resource packs FIRST,
@@ -125,10 +171,7 @@ public final class GltfClientSetup {
}
}
/**
* FORGE bus event subscribers for entity lifecycle cleanup.
* Removes skin cache entries when entities leave the level, preventing memory leaks.
*/
/** FORGE bus event subscribers (client-side commands). */
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.FORGE,
@@ -136,15 +179,6 @@ public final class GltfClientSetup {
)
public static class ForgeBusEvents {
@SubscribeEvent
public static void onEntityLeaveLevel(
net.minecraftforge.event.entity.EntityLeaveLevelEvent event
) {
if (event.getLevel().isClientSide()) {
GltfSkinCache.removeEntity(event.getEntity().getId());
}
}
@SubscribeEvent
public static void onRegisterClientCommands(
net.minecraftforge.client.event.RegisterClientCommandsEvent event

View File

@@ -1,5 +1,6 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.systems.RenderSystem;
import dev.kosmx.playerAnim.core.util.Pair;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
@@ -42,6 +43,22 @@ public final class GltfLiveBoneReader {
private GltfLiveBoneReader() {}
// Scratch pools for joint-matrix computation. Render-thread-only
// (asserted below). Pre-populated Matrix4f slots are reused via
// set() / identity() / mul(). See GltfSkinningEngine for the twin pool.
private static Matrix4f[] scratchJointMatrices = new Matrix4f[0];
private static Matrix4f[] scratchWorldTransforms = new Matrix4f[0];
private static final Matrix4f scratchLocal = new Matrix4f();
private static Matrix4f[] ensureScratch(Matrix4f[] current, int needed) {
if (current.length >= needed) return current;
Matrix4f[] next = new Matrix4f[needed];
int i = 0;
for (; i < current.length; i++) next[i] = current[i];
for (; i < needed; i++) next[i] = new Matrix4f();
return next;
}
/**
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
* <p>
@@ -57,7 +74,9 @@ public final class GltfLiveBoneReader {
* @param model the HumanoidModel after PlayerAnimator has applied rotations
* @param data parsed glTF data (MC-converted)
* @param entity the living entity being rendered
* @return array of joint matrices ready for skinning, or null on failure
* @return live reference to an internal scratch buffer (or null on failure).
* Caller MUST consume before the next call to any {@code compute*}
* method on this class; do not store.
*/
public static Matrix4f[] computeJointMatricesFromModel(
HumanoidModel<?> model,
@@ -65,10 +84,17 @@ public final class GltfLiveBoneReader {
LivingEntity entity
) {
if (model == null || data == null || entity == null) return null;
assert RenderSystem.isOnRenderThread()
: "GltfLiveBoneReader.computeJointMatricesFromModel must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices();
String[] jointNames = data.jointNames();
@@ -109,23 +135,22 @@ public final class GltfLiveBoneReader {
}
// Build local transform: translate(restTranslation) * rotate(localRot)
Matrix4f local = new Matrix4f();
local.translate(restTranslations[j]);
local.rotate(localRot);
scratchLocal.identity();
scratchLocal.translate(restTranslations[j]);
scratchLocal.rotate(localRot);
// Compose with parent to get world transform
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(
worldTransforms[parents[j]]
).mul(local);
// Compose with parent to get world transform.
// Same semantics as pre-refactor: treat as root when parent hasn't
// been processed yet (parents[j] >= j was a null in the old array).
Matrix4f world = worldTransforms[j];
if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else {
worldTransforms[j] = new Matrix4f(local);
world.set(scratchLocal);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
data.inverseBindMatrices()[j]
);
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;

View File

@@ -4,7 +4,6 @@ import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.renderer.MultiBufferSource;
@@ -30,8 +29,8 @@ public final class GltfMeshRenderer extends RenderStateShard {
"models/obj/shared/white.png"
);
/** Cached default RenderType (white texture). Created once, reused every frame. */
private static RenderType cachedDefaultRenderType;
/** Cached default RenderType (white texture). Nulled by clearRenderTypeCache on reload. */
private static volatile RenderType cachedDefaultRenderType;
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE =
@@ -172,7 +171,8 @@ public final class GltfMeshRenderer extends RenderStateShard {
}
/**
* Internal rendering implementation shared by both overloads.
* Internal rendering implementation shared by both overloads. Emits every
* index with a single flat white color — no per-primitive metadata is read.
*/
private static void renderSkinnedInternal(
GltfData data,
@@ -185,139 +185,76 @@ public final class GltfMeshRenderer extends RenderStateShard {
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
int[] indices = data.indices();
float[] texCoords = data.texCoords();
VertexScratch s = new VertexScratch();
float[] outPos = new float[3];
float[] outNormal = new float[3];
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
for (int idx : indices) {
// Skin this vertex
GltfSkinningEngine.skinVertex(
data,
idx,
jointMatrices,
outPos,
outNormal,
tmpPos,
tmpNorm
for (int idx : data.indices()) {
emitVertex(
vc, pose, normalMat, data, jointMatrices, idx, texCoords,
255, 255, 255, packedLight, packedOverlay, s
);
// UV coordinates
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc
.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(255, 255, 255, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
/**
* Two-pass skinned renderer with cache support.
*
* <p><b>Pass 1</b> (skippable): if {@code cachedPositions} is null, skin every
* unique vertex into flat {@code float[]} arrays (positions and normals).
* If cached arrays are provided, Pass 1 is skipped entirely.</p>
*
* <p><b>Pass 2</b> (always): iterate the index buffer, read skinned data from
* the arrays, and emit to the {@link VertexConsumer}.</p>
*
* @param data parsed glTF data
* @param jointMatrices computed joint matrices from skinning engine
* @param poseStack current pose stack
* @param buffer multi-buffer source
* @param packedLight packed light value
* @param packedOverlay packed overlay value
* @param renderType the RenderType to use for rendering
* @param cachedPositions previously cached skinned positions, or null to compute fresh
* @param cachedNormals previously cached skinned normals, or null to compute fresh
* @return {@code new float[][] { positions, normals }} for the caller to cache
* Scratch buffers reused across every vertex emission in a single render
* call. Kept as a tiny value class so the two public loops share the same
* pre-alloc pattern without duplicating four local variables.
*/
public static float[][] renderSkinnedWithCache(
private static final class VertexScratch {
final float[] outPos = new float[3];
final float[] outNormal = new float[3];
final Vector4f tmpPos = new Vector4f();
final Vector4f tmpNorm = new Vector4f();
}
/**
* Skin {@code idx} and push one vertex into {@code vc}. Extracted from
* {@link #renderSkinnedInternal} and {@link #renderSkinnedTinted} so both
* loops share the vertex-format contract — if the format ever changes,
* the edit happens in one place.
*/
private static void emitVertex(
VertexConsumer vc,
Matrix4f pose,
Matrix3f normalMat,
GltfData data,
Matrix4f[] jointMatrices,
PoseStack poseStack,
MultiBufferSource buffer,
int idx,
float[] texCoords,
int r,
int g,
int b,
int packedLight,
int packedOverlay,
RenderType renderType,
float[] cachedPositions,
float[] cachedNormals
VertexScratch s
) {
int vertexCount = data.vertexCount();
float[] positions;
float[] normals;
// -- Pass 1: Skin all unique vertices (skipped when cache hit) --
if (cachedPositions != null && cachedNormals != null) {
positions = cachedPositions;
normals = cachedNormals;
} else {
positions = new float[vertexCount * 3];
normals = new float[vertexCount * 3];
float[] outPos = new float[3];
float[] outNormal = new float[3];
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
for (int v = 0; v < vertexCount; v++) {
GltfSkinningEngine.skinVertex(
data, v, jointMatrices, outPos, outNormal, tmpPos, tmpNorm
);
positions[v * 3] = outPos[0];
positions[v * 3 + 1] = outPos[1];
positions[v * 3 + 2] = outPos[2];
normals[v * 3] = outNormal[0];
normals[v * 3 + 1] = outNormal[1];
normals[v * 3 + 2] = outNormal[2];
}
}
// -- Pass 2: Emit vertices from arrays to VertexConsumer --
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
int[] indices = data.indices();
float[] texCoords = data.texCoords();
for (int idx : indices) {
float px = positions[idx * 3];
float py = positions[idx * 3 + 1];
float pz = positions[idx * 3 + 2];
float nx = normals[idx * 3];
float ny = normals[idx * 3 + 1];
float nz = normals[idx * 3 + 2];
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc
.vertex(pose, px, py, pz)
.color(255, 255, 255, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, nx, ny, nz)
.endVertex();
}
return new float[][] { positions, normals };
GltfSkinningEngine.skinVertex(
data,
idx,
jointMatrices,
s.outPos,
s.outNormal,
s.tmpPos,
s.tmpNorm
);
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc
.vertex(pose, s.outPos[0], s.outPos[1], s.outPos[2])
.color(r, g, b, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(
normalMat,
s.outNormal[0],
s.outNormal[1],
s.outNormal[2]
)
.endVertex();
}
/**
@@ -353,22 +290,12 @@ public final class GltfMeshRenderer extends RenderStateShard {
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
float[] texCoords = data.texCoords();
VertexScratch s = new VertexScratch();
float[] outPos = new float[3];
float[] outNormal = new float[3];
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
List<GltfData.Primitive> primitives = data.primitives();
for (GltfData.Primitive prim : primitives) {
// Determine color for this primitive
int r = 255,
g = 255,
b = 255;
for (GltfData.Primitive prim : data.primitives()) {
int r = 255, g = 255, b = 255;
if (prim.tintable() && prim.tintChannel() != null) {
Integer colorInt = tintColors.get(prim.tintChannel());
if (colorInt != null) {
@@ -377,29 +304,11 @@ public final class GltfMeshRenderer extends RenderStateShard {
b = colorInt & 0xFF;
}
}
for (int idx : prim.indices()) {
GltfSkinningEngine.skinVertex(
data,
idx,
jointMatrices,
outPos,
outNormal,
tmpPos,
tmpNorm
emitVertex(
vc, pose, normalMat, data, jointMatrices, idx, texCoords,
r, g, b, packedLight, packedOverlay, s
);
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc
.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(r, g, b, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
}

View File

@@ -30,8 +30,59 @@ public final class GltfPoseConverter {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int TICKS_PER_SECOND = 20;
private static final Ease DEFAULT_EASE = Ease.LINEAR;
private GltfPoseConverter() {}
/**
* Compute the end tick (inclusive) of a clip's keyframe timeline, relative
* to the clip's first timestamp. Returns 1 for null or empty clips (minimum
* valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
*
* <p>The baseline subtraction ensures clips authored with an NLA onset
* ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
* undefined on each loop — the clip is always timeline-normalized to start
* at tick 0.</p>
*/
public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
if (
clip == null ||
clip.frameCount() == 0 ||
clip.timestamps().length == 0
) {
return 1;
}
float[] times = clip.timestamps();
int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
float baseline = times[0];
return Math.max(
1,
Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
);
}
/**
* Convert a frame index to an MC tick based on the clip's timestamps,
* relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
*/
private static int frameToTick(
@Nullable GltfData.AnimationClip clip,
int frameIndex,
float baselineSeconds
) {
if (clip == null) return 0;
float[] times = clip.timestamps();
if (frameIndex >= times.length) return 0;
return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
}
/** Return the timestamp baseline for the clip, or 0 if absent. */
private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
if (clip == null || clip.timestamps().length == 0) return 0f;
return clip.timestamps()[0];
}
/**
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
* Uses the default (first) animation clip.
@@ -123,7 +174,7 @@ public final class GltfPoseConverter {
*/
private static KeyframeAnimation convertClipSelective(
GltfData data,
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
String animName,
Set<String> ownedParts,
Set<String> enabledParts
@@ -133,79 +184,42 @@ public final class GltfPoseConverter {
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Track which PlayerAnimator part names received actual animation data
// Track which PlayerAnimator part names received actual animation data.
// Joint-level; not frame-dependent — we detect once on frame 0.
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
// rates (24/30/60 FPS Blender) produce multiple frames that round to the
// same tick; emit once per unique tick (keep the first) so artists see
// deterministic behavior rather than relying on PlayerAnimator's "last
// inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
// Check if this joint has explicit animation data (not just rest pose fallback).
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */f == 0 ? partsWithKeyframes : null
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180deg rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
// Record which PlayerAnimator part received data
if (hasExplicitAnim) {
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart != null) {
partsWithKeyframes.add(animPart);
}
// For lower bones, the keyframe data goes to the upper bone's part
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (upperPart != null) {
partsWithKeyframes.add(upperPart);
}
}
}
}
lastTick = tick;
}
// Selective: enable owned parts always, free parts only for "Full" animations
@@ -220,8 +234,10 @@ public final class GltfPoseConverter {
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
"[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
animName,
frameCount,
endTick,
ownedParts,
enabledParts,
partsWithKeyframes
@@ -229,6 +245,132 @@ public final class GltfPoseConverter {
return anim;
}
/**
* Apply a single frame's delta rotations for every known bone to the builder,
* writing one keyframe per bone at {@code tick}.
*
* @param ownedFilter if non-null, only bones whose animPart is in this
* set are written (shared-builder multi-item path)
* @param keyframeCollector if non-null, parts that have explicit rotation or
* translation channels are added to this set
*/
private static void applyFrameToBuilder(
KeyframeAnimation.AnimationBuilder builder,
GltfData data,
@Nullable GltfData.AnimationClip rawClip,
int frameIndex,
int tick,
Ease ease,
@Nullable Set<String> ownedFilter,
@Nullable Set<String> keyframeCollector
) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Two known bones can map to the same PlayerAnimator part (e.g.
// `body` + `torso` → "body"). Both would write to the same
// StateCollection and the second write silently wins; instead,
// first-in-array-order wins and subsequent collisions are skipped.
// Lower bones don't conflict with upper bones (separate axis).
Set<String> claimedUpperParts = new java.util.HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) continue;
boolean isLower = GltfBoneMapper.isLowerBone(boneName);
// Apply ownedFilter BEFORE claiming the slot: a bone that this item
// doesn't own must not reserve the upper-part slot, otherwise a
// later owned bone mapping to the same slot gets spuriously
// rejected by the collision check below.
if (ownedFilter != null) {
if (!ownedFilter.contains(animPart)) continue;
// For lower bones, also require the upper bone's part to be owned.
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upper);
if (
upperPart == null ||
!ownedFilter.contains(upperPart)
) continue;
}
}
}
if (!isLower && !claimedUpperParts.add(animPart)) {
// Another upper bone already claimed this PlayerAnimator part.
// Skip the duplicate write so HashMap iteration order can't
// silently flip which bone drives the pose.
if (frameIndex == 0) {
LOGGER.warn(
"[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
boneName,
animPart
);
}
continue;
}
Quaternionf animQ = getRawAnimQuaternion(
rawClip,
rawRestRotations,
j,
frameIndex
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (isLower) {
convertLowerBone(builder, boneName, deltaQ, tick, ease);
} else {
convertUpperBone(builder, boneName, deltaQ, tick, ease);
}
if (keyframeCollector != null) {
// Translation-only channels count as "explicit": a pure-
// translation animation (e.g. a rigid-body bounce) still
// feeds keyframes to PlayerAnimator, so its part must be
// claimed for composite merging.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
if (hasExplicitAnim) {
keyframeCollector.add(animPart);
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upper
);
if (upperPart != null) keyframeCollector.add(
upperPart
);
}
}
}
}
}
}
/**
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
*
@@ -248,76 +390,25 @@ public final class GltfPoseConverter {
@Nullable GltfData.AnimationClip rawClip,
Set<String> ownedParts
) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
Set<String> partsWithKeyframes = new HashSet<>();
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Only process bones that belong to this item's owned parts
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null || !ownedParts.contains(animPart)) continue;
// For lower bones, check if the UPPER bone's part is owned
// (lower bone keyframes go to the upper bone's StateCollection)
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (
upperPart == null || !ownedParts.contains(upperPart)
) continue;
}
}
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
ownedParts,
f == 0 ? partsWithKeyframes : null
);
Quaternionf restQ = rawRestRotations[j];
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
if (hasExplicitAnim) {
partsWithKeyframes.add(animPart);
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (upperPart != null) partsWithKeyframes.add(
upperPart
);
}
}
}
lastTick = tick;
}
return partsWithKeyframes;
@@ -351,7 +442,7 @@ public final class GltfPoseConverter {
*/
private static KeyframeAnimation convertClip(
GltfData data,
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
String animName
) {
KeyframeAnimation.AnimationBuilder builder =
@@ -359,123 +450,94 @@ public final class GltfPoseConverter {
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */null
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
// Simplifies algebraically to: animQ * inv(restQ)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180° rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
LOGGER.debug(
String.format(
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
boneName,
restQ.x,
restQ.y,
restQ.z,
restQ.w,
animQ.x,
animQ.y,
animQ.z,
animQ.w,
deltaQ.x,
deltaQ.y,
deltaQ.z,
deltaQ.w
)
);
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
lastTick = tick;
}
builder.fullyEnableParts();
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation",
animName
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
animName,
frameCount,
endTick
);
return anim;
}
/**
* Get the raw animation quaternion for a joint from a specific clip.
* Falls back to rest rotation if the clip is null or has no data for this joint.
* Get the raw animation quaternion for a joint at a specific frame.
* Falls back to rest rotation if the clip is null, has no data for this joint,
* or has an empty channel. Clamps frameIndex to the last available frame if
* the joint's channel is shorter than the shared timestamps array.
*/
private static Quaternionf getRawAnimQuaternion(
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
Quaternionf[] rawRestRotations,
int jointIndex
int jointIndex,
int frameIndex
) {
if (
rawClip != null &&
jointIndex < rawClip.rotations().length &&
rawClip.rotations()[jointIndex] != null
) {
return rawClip.rotations()[jointIndex][0]; // first frame
Quaternionf[] channel = rawClip.rotations()[jointIndex];
if (channel.length > 0) {
int safeFrame = Math.min(frameIndex, channel.length - 1);
return channel[safeFrame];
}
}
return rawRestRotations[jointIndex]; // fallback to rest
// Defensive: under a well-formed GLB, jointCount == restRotations.length
// (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
// that invariant is ever broken by a future parser path.
if (jointIndex >= rawRestRotations.length) {
return new Quaternionf();
}
return rawRestRotations[jointIndex];
}
private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ
Quaternionf deltaQ,
int tick,
Ease ease
) {
// Decompose delta quaternion to Euler ZYX
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
// (the "ZYX" refers to rotation ORDER, not storage order)
// "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
// Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
// middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x; // X rotation (pitch)
float yaw = euler.y; // Y rotation (yaw)
float roll = euler.z; // Z rotation (roll)
float pitch = euler.x;
float yaw = euler.y;
float roll = euler.z;
LOGGER.debug(
String.format(
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
boneName,
Math.toDegrees(pitch),
Math.toDegrees(yaw),
Math.toDegrees(roll)
)
);
// Get the StateCollection for this body part
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return;
@@ -485,41 +547,40 @@ public final class GltfPoseConverter {
);
if (part == null) return;
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
part.pitch.addKeyFrame(tick, pitch, ease);
part.yaw.addKeyFrame(tick, yaw, ease);
part.roll.addKeyFrame(tick, roll, ease);
}
private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ
Quaternionf deltaQ,
int tick,
Ease ease
) {
// Extract bend angle and axis from the delta quaternion
float angle =
2.0f * (float) Math.acos(Math.min(1.0, Math.abs(deltaQ.w)));
// Canonicalize q: q and -q represent the same rotation. Always pick the
// hemisphere with w >= 0 so consecutive frames don't pop across the
// double-cover boundary when interpolating.
float qx = deltaQ.x;
float qy = deltaQ.y;
float qz = deltaQ.z;
float qw = deltaQ.w;
if (qw < 0) {
qx = -qx;
qy = -qy;
qz = -qz;
qw = -qw;
}
// Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));
// Determine bend direction from axis
float bendDirection = 0.0f;
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
if (qx * qx + qz * qz > 0.001f) {
bendDirection = (float) Math.atan2(qz, qx);
}
// Sign: if w is negative, the angle wraps
if (deltaQ.w < 0) {
angle = -angle;
}
LOGGER.debug(
String.format(
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
boneName,
Math.toDegrees(angle),
Math.toDegrees(bendDirection)
)
);
// Apply bend to the upper bone's StateCollection
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone == null) return;
@@ -532,8 +593,8 @@ public final class GltfPoseConverter {
);
if (part == null || !part.isBendable) return;
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
part.bend.addKeyFrame(tick, angle, ease);
part.bendDirection.addKeyFrame(tick, bendDirection, ease);
}
private static KeyframeAnimation.StateCollection getPartByName(
@@ -580,27 +641,86 @@ public final class GltfPoseConverter {
Set<String> partsWithKeyframes,
String animName
) {
// Free bones are only enabled for "Full" animations (FullIdle, FullStruggle, etc.)
// The "gltf_" prefix is added by convertClipSelective, so check for "gltf_Full"
boolean isFullBodyAnimation = animName != null &&
animName.startsWith("gltf_Full");
// Head is protected by default — only enabled as a free bone when the animation
// name contains "Head" (e.g., FullHeadStruggle, FullHeadIdle).
// This lets artists opt-in per animation without affecting the item's regions.
// FullHead prefix (e.g., FullHeadStruggle) opts into head as a free bone.
// Use startsWith to avoid false positives (e.g., FullOverhead, FullAhead).
boolean allowFreeHead = isFullBodyAnimation &&
animName.startsWith("gltf_FullHead");
boolean isFullBodyAnimation = isFullBodyAnimName(animName);
boolean allowFreeHead = isFullHeadAnimName(animName);
enableSelectivePartsCore(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
String[] allParts = {
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg",
};
for (String partName : allParts) {
/**
* Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
* declares opt-in to full-body free-bone animation. See the "Full" prefix
* convention in {@link #enableSelectiveParts}.
*/
public static boolean isFullBodyAnimName(@Nullable String animName) {
return animName != null && animName.startsWith("gltf_Full");
}
/**
* Check whether a resolved-and-prefixed animation name opts in to head
* animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
* protected by default to preserve vanilla head-tracking on bondage items
* that don't specifically want to animate it.
*/
public static boolean isFullHeadAnimName(@Nullable String animName) {
return isFullBodyAnimName(animName) &&
animName.startsWith("gltf_FullHead");
}
/**
* Composite variant of {@link #enableSelectiveParts} used by the multi-item
* path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
* compute the three aggregates themselves: {@code allOwnedParts} is the
* union of owned regions across all items, {@code partsWithKeyframes} is
* the union of keyframe parts returned by each {@link #addBonesToBuilder}
* call, and the two Full/FullHead flags should be true if ANY item in the
* composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
*/
public static void enableSelectivePartsComposite(
KeyframeAnimation.AnimationBuilder builder,
Set<String> allOwnedParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
// In the composite path every animation part is implicitly in
// enabledParts — if a FullX animation has keyframes for it, we want it
// enabled. Pass ALL_PARTS as the enabled set so the single-item
// opt-out path is a no-op.
enableSelectivePartsCore(
builder,
allOwnedParts,
ALL_PARTS_SET,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
private static final Set<String> ALL_PARTS_SET = Set.of(
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg"
);
private static void enableSelectivePartsCore(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts,
Set<String> enabledParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
for (String partName : ALL_PARTS_SET) {
KeyframeAnimation.StateCollection part = getPartByName(
builder,
partName

View File

@@ -1,121 +0,0 @@
package com.tiedup.remake.client.gltf;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Skinning result cache that avoids re-skinning when a player's pose hasn't changed.
*
* <p>Uses a dirty-flag approach: each cache entry stores the raw int bits of
* every float input (joint euler angles) that drove the last skinning pass.
* On the next frame, if all bits match exactly, the cached skinned positions
* and normals are reused (skipping the expensive LBS loop).
*
* <p>Bit comparison via {@link Float#floatToRawIntBits(float)} avoids epsilon
* drift: a pose is "unchanged" only when the inputs are identical down to the
* bit (idle, AFK, paused animation frame).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfSkinCache {
private record CacheKey(int entityId, ResourceLocation modelLoc) {}
private static final class Entry {
int[] lastInputBits;
float[] skinnedPositions;
float[] skinnedNormals;
}
private static final Map<CacheKey, Entry> cache = new HashMap<>();
private GltfSkinCache() {}
/**
* Check whether the pose inputs are bit-identical to the last cached skinning pass.
*
* @param entityId the entity's numeric ID
* @param modelLoc the model ResourceLocation (distinguishes multiple items on one entity)
* @param currentInputs flat array of float inputs that drove joint matrix computation
* @return true if every input bit matches the cached entry (safe to reuse cached data)
*/
public static boolean isPoseUnchanged(
int entityId,
ResourceLocation modelLoc,
float[] currentInputs
) {
CacheKey key = new CacheKey(entityId, modelLoc);
Entry entry = cache.get(key);
if (entry == null || entry.lastInputBits == null) return false;
if (entry.lastInputBits.length != currentInputs.length) return false;
for (int i = 0; i < currentInputs.length; i++) {
if (
entry.lastInputBits[i]
!= Float.floatToRawIntBits(currentInputs[i])
) {
return false;
}
}
return true;
}
/**
* Store a skinning result in the cache, replacing any previous entry for the same key.
*
* @param entityId the entity's numeric ID
* @param modelLoc the model ResourceLocation
* @param poseInputs the float inputs that produced these results (will be bit-snapshotted)
* @param positions skinned vertex positions (will be cloned)
* @param normals skinned vertex normals (will be cloned)
*/
public static void store(
int entityId,
ResourceLocation modelLoc,
float[] poseInputs,
float[] positions,
float[] normals
) {
CacheKey key = new CacheKey(entityId, modelLoc);
Entry entry = cache.computeIfAbsent(key, k -> new Entry());
entry.lastInputBits = new int[poseInputs.length];
for (int i = 0; i < poseInputs.length; i++) {
entry.lastInputBits[i] = Float.floatToRawIntBits(poseInputs[i]);
}
entry.skinnedPositions = positions.clone();
entry.skinnedNormals = normals.clone();
}
/**
* Retrieve cached skinned positions, or null if no cache entry exists.
*/
public static float[] getCachedPositions(
int entityId,
ResourceLocation modelLoc
) {
Entry entry = cache.get(new CacheKey(entityId, modelLoc));
return entry != null ? entry.skinnedPositions : null;
}
/**
* Retrieve cached skinned normals, or null if no cache entry exists.
*/
public static float[] getCachedNormals(
int entityId,
ResourceLocation modelLoc
) {
Entry entry = cache.get(new CacheKey(entityId, modelLoc));
return entry != null ? entry.skinnedNormals : null;
}
/** Clear the entire cache (called on resource reload). */
public static void clearAll() {
cache.clear();
}
/** Remove all cache entries for a specific entity (called on entity leave). */
public static void removeEntity(int entityId) {
cache.entrySet().removeIf(e -> e.getKey().entityId == entityId);
}
}

View File

@@ -1,5 +1,6 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.systems.RenderSystem;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix4f;
@@ -11,21 +12,46 @@ import org.joml.Vector4f;
* CPU-based Linear Blend Skinning (LBS) engine.
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
* All data is in MC-converted space (consistent with IBMs and vertex positions).
*
* <p><b>Scratch pool</b>: the {@code computeJointMatrices*} methods return a
* reference to an internal grow-on-demand buffer. The caller MUST consume the
* returned array before the next call to any {@code compute*} method on this
* class. Storing the reference across frames produces corrupted output on the
* next call.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GltfSkinningEngine {
private GltfSkinningEngine() {}
// Scratch pools for joint-matrix computation. Single-threaded access from
// the render thread only (asserted at call sites). Pre-populated Matrix4f
// slots are reused via set()/identity()/mul() instead of new Matrix4f(...).
private static Matrix4f[] scratchJointMatrices = new Matrix4f[0];
private static Matrix4f[] scratchWorldTransforms = new Matrix4f[0];
private static final Matrix4f scratchLocal = new Matrix4f();
private static Matrix4f[] ensureScratch(Matrix4f[] current, int needed) {
if (current.length >= needed) return current;
Matrix4f[] next = new Matrix4f[needed];
int i = 0;
for (; i < current.length; i++) next[i] = current[i];
for (; i < needed; i++) next[i] = new Matrix4f();
return next;
}
/**
* Compute joint matrices from glTF animation/rest data (default animation).
* Each joint matrix = worldTransform * inverseBindMatrix.
* Uses MC-converted glTF data throughout for consistency.
*
* @param data parsed glTF data (MC-converted)
* @return array of joint matrices ready for skinning
* @return live reference to an internal scratch buffer. Caller MUST consume
* before the next call to any {@code compute*} method; do not store.
*/
public static Matrix4f[] computeJointMatrices(GltfData data) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatrices must run on the render thread (scratch buffers are not thread-safe)";
return computeJointMatricesFromClip(data, data.animation());
}
@@ -40,38 +66,45 @@ public final class GltfSkinningEngine {
* @param data the parsed glTF data (MC-converted)
* @param clip the animation clip to sample (null = rest pose for all joints)
* @param time time in frame-space (0.0 = first frame, N-1 = last frame)
* @return interpolated joint matrices ready for skinning
* @return live reference to an internal scratch buffer. Caller MUST consume
* before the next call to any {@code compute*} method; do not store.
*/
public static Matrix4f[] computeJointMatricesAnimated(
GltfData data,
GltfData.AnimationClip clip,
float time
) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatricesAnimated must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(interpT) * rotate(interpQ)
Matrix4f local = new Matrix4f();
local.translate(getInterpolatedTranslation(data, clip, j, time));
local.rotate(getInterpolatedRotation(data, clip, j, time));
scratchLocal.identity();
scratchLocal.translate(getInterpolatedTranslation(data, clip, j, time));
scratchLocal.rotate(getInterpolatedRotation(data, clip, j, time));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(
worldTransforms[parents[j]]
).mul(local);
// Compose with parent. Same semantics as the previous allocating
// code path: only use the parent when its index is already processed
// (parents[j] < j). Out-of-order/root → treat as identity parent.
Matrix4f world = worldTransforms[j];
if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else {
worldTransforms[j] = new Matrix4f(local);
world.set(scratchLocal);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
data.inverseBindMatrices()[j]
);
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
@@ -84,31 +117,35 @@ public final class GltfSkinningEngine {
GltfData data,
GltfData.AnimationClip clip
) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatricesFromClip must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
Matrix4f local = new Matrix4f();
local.translate(getAnimTranslation(data, clip, j));
local.rotate(getAnimRotation(data, clip, j));
scratchLocal.identity();
scratchLocal.translate(getAnimTranslation(data, clip, j));
scratchLocal.rotate(getAnimRotation(data, clip, j));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(
worldTransforms[parents[j]]
).mul(local);
// Compose with parent — see note in computeJointMatricesAnimated.
Matrix4f world = worldTransforms[j];
if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else {
worldTransforms[j] = new Matrix4f(local);
world.set(scratchLocal);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
data.inverseBindMatrices()[j]
);
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
@@ -116,7 +153,7 @@ public final class GltfSkinningEngine {
/**
* Get the animation rotation for a joint (MC-converted).
* Falls back to rest rotation if no animation.
* Falls back to rest rotation if no animation or the channel is empty.
*/
private static Quaternionf getAnimRotation(
GltfData data,
@@ -125,8 +162,10 @@ public final class GltfSkinningEngine {
) {
if (
clip != null &&
clip.rotations() != null &&
jointIndex < clip.rotations().length &&
clip.rotations()[jointIndex] != null
clip.rotations()[jointIndex] != null &&
clip.rotations()[jointIndex].length > 0
) {
return clip.rotations()[jointIndex][0]; // first frame
}
@@ -135,7 +174,8 @@ public final class GltfSkinningEngine {
/**
* Get the animation translation for a joint (MC-converted).
* Falls back to rest translation if no animation translation exists.
* Falls back to rest translation if no animation translation exists or the
* channel is empty.
*/
private static Vector3f getAnimTranslation(
GltfData data,
@@ -146,7 +186,8 @@ public final class GltfSkinningEngine {
clip != null &&
clip.translations() != null &&
jointIndex < clip.translations().length &&
clip.translations()[jointIndex] != null
clip.translations()[jointIndex] != null &&
clip.translations()[jointIndex].length > 0
) {
return clip.translations()[jointIndex][0]; // first frame
}
@@ -300,10 +341,16 @@ public final class GltfSkinningEngine {
sny = 0,
snz = 0;
int jointCount = data.jointCount();
for (int i = 0; i < 4; i++) {
int ji = joints[vertexIdx * 4 + i];
float w = weights[vertexIdx * 4 + i];
if (w <= 0.0f || ji >= jointMatrices.length) continue;
// Guard against jointCount, NOT jointMatrices.length. The scratch
// pool (P2-04) means jointMatrices may be longer than jointCount,
// with trailing slots holding stale matrices from the previous
// item's skeleton. Tightens the bound back to the pre-scratch
// semantics. Closes B-batch review RISK-E01.
if (w <= 0.0f || ji >= jointCount) continue;
Matrix4f jm = jointMatrices[ji];

View File

@@ -30,9 +30,14 @@ import net.minecraftforge.api.distmarker.OnlyIn;
@OnlyIn(Dist.CLIENT)
public final class GlbValidator {
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
// GLB binary format constants are shared with the runtime parsers to
// prevent divergence. See GlbParserUtils for the canonical definitions.
private static final int GLB_MAGIC =
com.tiedup.remake.client.gltf.GlbParserUtils.GLB_MAGIC;
private static final int GLB_VERSION =
com.tiedup.remake.client.gltf.GlbParserUtils.GLB_VERSION;
private static final int CHUNK_JSON =
com.tiedup.remake.client.gltf.GlbParserUtils.CHUNK_JSON;
private GlbValidator() {}
@@ -92,8 +97,9 @@ public final class GlbValidator {
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //
/** Maximum GLB file size the validator will accept (50 MB). */
private static final long MAX_GLB_SIZE = 50L * 1024 * 1024;
/** Maximum GLB file size the validator will accept (shared with runtime parsers). */
private static final long MAX_GLB_SIZE =
com.tiedup.remake.client.gltf.GlbParserUtils.MAX_GLB_SIZE;
/**
* Parse the GLB header and extract the JSON chunk root object.
@@ -227,17 +233,30 @@ public final class GlbValidator {
return;
}
// Validate bones in the first skin
JsonObject skin = skins.get(0).getAsJsonObject();
if (!skin.has("joints")) {
return;
JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null;
if (nodes == null) return;
// Iterate every skin. Furniture GLBs contain multiple (one per
// Player_* seat armature + one for the mesh itself); validating
// only skin 0 let a broken seat skin crash at load time after
// passing validation.
for (int si = 0; si < skins.size(); si++) {
validateSkin(skins.get(si).getAsJsonObject(), si, root, nodes, source, diagnostics);
}
}
private static void validateSkin(
JsonObject skin,
int skinIndex,
JsonObject root,
JsonArray nodes,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!skin.has("joints")) return;
JsonArray joints = skin.getAsJsonArray("joints");
JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null;
if (nodes == null) {
return;
}
String skinLabel = "skin " + skinIndex;
for (int j = 0; j < joints.size(); j++) {
int nodeIdx = joints.get(j).getAsInt();
@@ -251,30 +270,77 @@ public final class GlbValidator {
}
String rawName = node.get("name").getAsString();
// Strip armature prefix (e.g. "MyRig|body" -> "body")
String boneName = rawName.contains("|")
? rawName.substring(rawName.lastIndexOf('|') + 1)
: rawName;
String boneName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
rawName
);
if (GltfBoneMapper.isKnownBone(boneName)) {
// OK — known bone, no diagnostic needed
continue;
}
if (GltfBoneMapper.isKnownBone(boneName)) continue;
String suggestion = GltfBoneMapper.suggestBoneName(boneName);
if (suggestion != null) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "BONE_TYPO_SUGGESTION",
"Bone '" + boneName + "' is not recognized — did you mean '"
skinLabel + ": bone '" + boneName + "' is not recognized — did you mean '"
+ suggestion + "'?"
));
} else {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.INFO, "UNKNOWN_BONE",
"Bone '" + boneName + "' is not a standard MC bone (treated as custom bone)"
skinLabel + ": bone '" + boneName + "' is not a standard MC bone (treated as custom bone)"
));
}
}
// IBM accessor element count + type checks. A short accessor throws
// AIOOBE when GlbParser builds per-joint matrices; an over-long one
// wastes memory. Missing IBM entirely: glTF spec substitutes identity,
// almost always an authoring bug (renders in bind pose at origin).
if (!skin.has("inverseBindMatrices")) {
// Zero-joint skins already trip earlier diagnostics; the
// "0 joints but no IBM" warning adds nothing.
if (joints.size() > 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "IBM_MISSING",
skinLabel + " has " + joints.size() + " joints but no inverseBindMatrices " +
"— runtime will substitute identity, mesh will render in bind pose at origin"
));
}
} else if (root.has("accessors")) {
int ibmAccIdx = skin.get("inverseBindMatrices").getAsInt();
JsonArray accessors = root.getAsJsonArray("accessors");
if (ibmAccIdx >= 0 && ibmAccIdx < accessors.size()) {
JsonObject ibmAcc = accessors
.get(ibmAccIdx)
.getAsJsonObject();
int ibmCount = ibmAcc.has("count")
? ibmAcc.get("count").getAsInt()
: -1;
String ibmType = ibmAcc.has("type")
? ibmAcc.get("type").getAsString()
: "";
if (!ibmAcc.has("type")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_MISSING_TYPE",
skinLabel + " inverseBindMatrices accessor has no 'type' field — " +
"re-export with the skin's armature selected"
));
} else if (!"MAT4".equals(ibmType)) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_WRONG_TYPE",
skinLabel + " inverseBindMatrices type is '" + ibmType +
"', expected MAT4 — re-export with the skin's armature selected"
));
}
if (ibmCount >= 0 && ibmCount != joints.size()) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_COUNT_MISMATCH",
skinLabel + " inverseBindMatrices has " + ibmCount +
" entries, skin has " + joints.size() + " joints — " +
"re-export with the skin's armature selected"
));
}
}
}
}
// ------------------------------------------------------------------ //
@@ -299,7 +365,7 @@ public final class GlbValidator {
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!"Player".equals(meshName)) {
if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
nonPlayerCount++;
}
}
@@ -326,7 +392,7 @@ public final class GlbValidator {
targetMeshName = meshName;
break; // Convention match — same as GlbParser
}
if (!"Player".equals(meshName)) {
if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
targetMesh = mesh;
targetMeshName = meshName;
}
@@ -338,10 +404,10 @@ public final class GlbValidator {
JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
if (firstPrimitive.has("attributes")) {
JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
String meshLabel = targetMeshName != null
? "'" + targetMeshName + "'"
: "(unnamed)";
if (!attributes.has("WEIGHTS_0")) {
String meshLabel = targetMeshName != null
? "'" + targetMeshName + "'"
: "(unnamed)";
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_WEIGHTS",
"Selected mesh " + meshLabel
@@ -349,6 +415,29 @@ public final class GlbValidator {
+ "— skinning will not work correctly"
));
}
// Without JOINTS_0 every vertex implicitly binds to joint
// 0 (root), so the item renders attached to the root bone
// regardless of how the artist weighted it in Blender.
if (!attributes.has("JOINTS_0")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_JOINTS",
"Selected mesh " + meshLabel
+ " first primitive has no JOINTS_0 attribute "
+ "— vertices will all bind to root joint (bind pose render)"
));
}
// Weight-sum authoring check intentionally omitted: the
// only cheap signal available here (WEIGHTS_0 accessor
// min/max) is per-component across all vertices, which
// cannot be summed to reconstruct any single vertex's
// weight total (the min of component 0 comes from a
// different vertex than the min of component 1). The
// earlier heuristic produced both false positives
// (legitimate 1-influence meshes) and false negatives
// (mixed meshes where one vertex totals 0.9). The parser
// still normalizes weights at load, so the runtime path
// is safe; a proper authoring check would need to decode
// the BIN chunk and scan each vertex tuple. Deferred.
}
}
}
@@ -386,11 +475,9 @@ public final class GlbValidator {
if (!anim.has("name")) {
continue;
}
String animName = anim.get("name").getAsString();
// Strip armature prefix (e.g. "Armature|Idle" -> "Idle")
if (animName.contains("|")) {
animName = animName.substring(animName.lastIndexOf('|') + 1);
}
String animName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
anim.get("name").getAsString()
);
if ("Idle".equals(animName)) {
hasIdle = true;
break;
@@ -403,5 +490,64 @@ public final class GlbValidator {
"No animation named 'Idle' found — the default rest pose may not display correctly"
));
}
// Animation channel target ∈ skin.joints: any channel that targets a
// node NOT in the first skin's joints is silently dropped by the parser
// (GlbParserUtils.parseAnimation: `nodeToJoint[nodeIdx] < 0 → skip`).
// Most commonly: artist keyframes the Armature root object instead of
// its bones. Warn so the artist can fix it in Blender.
validateAnimationTargets(root, animations, source, diagnostics);
}
private static void validateAnimationTargets(
JsonObject root,
JsonArray animations,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("skins")) return;
JsonArray skins = root.getAsJsonArray("skins");
if (skins.size() == 0) return;
JsonObject skin = skins.get(0).getAsJsonObject();
if (!skin.has("joints")) return;
JsonArray joints = skin.getAsJsonArray("joints");
java.util.Set<Integer> jointNodes = new java.util.HashSet<>();
for (int j = 0; j < joints.size(); j++) {
jointNodes.add(joints.get(j).getAsInt());
}
int droppedChannels = 0;
int totalChannels = 0;
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
if (!anim.has("channels")) continue;
JsonArray channels = anim.getAsJsonArray("channels");
for (int ci = 0; ci < channels.size(); ci++) {
JsonObject ch = channels.get(ci).getAsJsonObject();
if (!ch.has("target")) continue;
JsonObject target = ch.getAsJsonObject("target");
if (!target.has("node")) continue;
totalChannels++;
int nodeIdx = target.get("node").getAsInt();
if (!jointNodes.contains(nodeIdx)) {
droppedChannels++;
}
}
}
if (droppedChannels > 0) {
diagnostics.add(new GlbDiagnostic(
source,
null,
Severity.WARNING,
"ANIM_CHANNEL_NOT_IN_SKIN",
droppedChannels +
" / " +
totalChannels +
" animation channel(s) target node(s) outside skin.joints — " +
"these channels will be silently dropped by the runtime. " +
"Keyframe the armature's bones, not the Armature object itself."
));
}
}
}

View File

@@ -173,12 +173,8 @@ public class SettingsAccessor {
* <li>"beam_cuffs" -&gt; "chain"</li>
* </ul>
*
* <p><b>BUG-003 fix:</b> Previously, {@code IHasResistance.getBaseResistance()}
* called {@code ModGameRules.getResistance()} which only knew 4 types (rope, gag,
* blindfold, collar) and returned hardcoded 100 for the other 10 types. Meanwhile
* the old {@code BindVariant.getResistance()} (now removed) read from ModConfig which had all 14 types.
* This caused a display-vs-struggle desync (display: 250, struggle: 100).
* Now both paths use this method.
* <p>Single source of truth for bind resistance — both the display
* layer and the struggle minigame resolve here so they can't drift.</p>
*
* @param bindType The raw item name or config key
* @return Resistance value from config, or 100 as fallback

View File

@@ -70,8 +70,9 @@ public class MasterFollowPlayerGoal extends Goal {
return false;
}
// FIX: Always activate in FOLLOWING state - distance is managed in tick()
// This fixes the bug where Master wouldn't move after buying player
// Always active in FOLLOWING state distance is enforced inside
// tick() rather than by canUse, so the goal keeps running even
// when the player is right next to the Master.
return true;
}

View File

@@ -125,6 +125,17 @@ public class CapabilityEventHandler {
}
});
// Forge 1.20.1 doesn't auto-copy getPersistentData() across death.
// Manually preserve the reconnection tag so death-without-
// keepInventory can't free a locked player.
net.minecraft.nbt.CompoundTag oldPersistent = oldPlayer.getPersistentData();
if (oldPersistent.contains("tiedup_locked_furniture", net.minecraft.nbt.Tag.TAG_COMPOUND)) {
newPlayer.getPersistentData().put(
"tiedup_locked_furniture",
oldPersistent.getCompound("tiedup_locked_furniture").copy()
);
}
// Invalidate old player caps to prevent memory leak
event.getOriginal().invalidateCaps();
return;
@@ -132,6 +143,18 @@ public class CapabilityEventHandler {
// DIMENSION CHANGE (End portal, etc.): Preserve all equipment
// Reconnection tag: only copy when the new player doesn't already
// carry it (Forge's default clone copies persistent data, but we
// defend against teleport paths that bypass it).
net.minecraft.nbt.CompoundTag oldPersistentDim = oldPlayer.getPersistentData();
if (oldPersistentDim.contains("tiedup_locked_furniture", net.minecraft.nbt.Tag.TAG_COMPOUND)
&& !newPlayer.getPersistentData().contains("tiedup_locked_furniture", net.minecraft.nbt.Tag.TAG_COMPOUND)) {
newPlayer.getPersistentData().put(
"tiedup_locked_furniture",
oldPersistentDim.getCompound("tiedup_locked_furniture").copy()
);
}
// === V2 Dimension Change ===
oldPlayer
.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)

View File

@@ -81,6 +81,19 @@ public class PlayerDisconnectHandler {
// Clean up cell selection mode
com.tiedup.remake.cells.CellSelectionManager.cleanup(playerId);
// NOTE: tiedup_locked_furniture is intentionally NOT cleaned on logout —
// it's load-bearing for NetworkEventHandler.handleFurnitureReconnection
// (the "disconnect to escape" prevention).
//
// tiedup_furniture_lockpick_ctx IS cleaned: it's session-ephemeral,
// valid only during an active lockpick mini-game. If left stale it
// causes PacketLockpickAttempt to mis-route a later body-item
// lockpick as a furniture pick and silently return without ending
// the session.
if (event.getEntity() instanceof net.minecraft.server.level.ServerPlayer serverPlayer) {
serverPlayer.getPersistentData().remove("tiedup_furniture_lockpick_ctx");
}
// BUG FIX: Security - Remove labor tools from disconnecting player
// This prevents players from keeping unbreakable tools by disconnecting
if (

View File

@@ -61,9 +61,9 @@ public class MixinPlayerModel {
// The head needs to compensate for this transformation
float rotationDelta = DogPoseRenderHandler.getAppliedRotationDelta(
player.getId()
player.getUUID()
);
boolean moving = DogPoseRenderHandler.isDogPoseMoving(player.getId());
boolean moving = DogPoseRenderHandler.isDogPoseMoving(player.getUUID());
// netHeadYaw is head relative to vanilla body (yHeadRot - yBodyRot)
// We rotated the model by rotationDelta, so compensate:

View File

@@ -118,9 +118,10 @@ public class MixinVillagerEntityBaseModelMCA<T extends LivingEntity> {
);
BondageAnimationManager.playAnimation(villager, animId);
// Tick the animation stack only once per game tick (not every render frame)
// ageInTicks increments by 1 each game tick, with fractional values between ticks
int currentTick = (int) ageInTicks;
// Dedup: tick the animation stack once per game tick, not per
// render frame. Use tickCount (discrete server tick), not the
// partial-tick-interpolated ageInTicks.
int currentTick = villager.tickCount;
UUID entityId = villager.getUUID();
int lastTick =
com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.getLastTick(

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();

View File

@@ -79,7 +79,9 @@ public class PlayerBindState implements IRestrainable, IPlayerBindStateHost {
new PlayerBindState(player)
);
// This fixes the bug where remote players' animations don't appear after observer reconnects
// Refresh the cached reference: an observer reconnect creates a
// new remote-player Entity object with the same UUID, and the
// cached state otherwise keeps pointing at the old (removed) one.
if (state.player != player) {
state.player = player;
}

View File

@@ -39,6 +39,11 @@ public class V2BondageEquipment implements IV2BondageEquipment {
private static final String NBT_ROOT_KEY = "V2BondageRegions";
private static final String NBT_ALSO_SUFFIX = "_also";
// Not thread-safe by itself. Safe in practice because Forge capabilities
// maintain separate instances for logical client and logical server; each
// side's instance is only touched by that side's main thread. If a future
// feature ever reads/writes this from a worker (async asset, network
// thread), the access pattern needs rethinking.
private final EnumMap<BodyRegionV2, ItemStack> regions;
// Pole leash persistence
@@ -107,20 +112,22 @@ public class V2BondageEquipment implements IV2BondageEquipment {
@Override
public boolean isRegionBlocked(BodyRegionV2 region) {
if (region == null) return false;
// Check if any equipped item's getBlockedRegions() includes this region
for (Map.Entry<
BodyRegionV2,
ItemStack
> entry : getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem item) {
if (
item.getBlockedRegions(stack).contains(region) &&
!item.getOccupiedRegions(stack).contains(region)
) {
// Blocked by another item (not self-blocking via occupation)
return true;
}
// Scan regions.values() directly and identity-dedup multi-region
// items inline. Avoids the 2-map allocation (IdentityHashMap +
// LinkedHashMap) of getAllEquipped() — this is called per-frame
// from FirstPersonHandHideHandler and would otherwise produce
// 4 map allocs/frame even when the local player wears nothing.
IdentityHashMap<ItemStack, Boolean> seen = null;
for (ItemStack stack : regions.values()) {
if (stack == null || stack.isEmpty()) continue;
if (!(stack.getItem() instanceof IV2BondageItem item)) continue;
if (seen == null) seen = new IdentityHashMap<>();
if (seen.put(stack, Boolean.TRUE) != null) continue;
if (
item.getBlockedRegions(stack).contains(region) &&
!item.getOccupiedRegions(stack).contains(region)
) {
return true;
}
}
return false;
@@ -130,13 +137,16 @@ public class V2BondageEquipment implements IV2BondageEquipment {
public int getEquippedCount() {
// Count unique non-empty stacks directly, avoiding the 2-map allocation
// of getAllEquipped(). Uses identity-based dedup for multi-region items.
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
// Fast-path: zero allocations when no item is equipped (hot per-tick
// call from hasAnyEquipment).
IdentityHashMap<ItemStack, Boolean> seen = null;
for (ItemStack stack : regions.values()) {
if (stack != null && !stack.isEmpty()) {
if (seen == null) seen = new IdentityHashMap<>();
seen.put(stack, Boolean.TRUE);
}
}
return seen.size();
return seen == null ? 0 : seen.size();
}
@Override

View File

@@ -30,27 +30,48 @@ public final class TintColorResolver {
/**
* Resolve tint colors for an ItemStack.
*
* <p>Hot-path: called once per rendered V2 item per frame. Skips the
* {@link LinkedHashMap} allocation when neither the definition nor the
* stack declares any tint channels — the common case for non-tintable
* items (audit P2-04, 06-v2-bondage-rendering.md HIGH-5).</p>
*
* @param stack the equipped bondage item
* @return channel-to-color map; empty if no tint channels defined or found
* @return channel-to-color map; empty (immutable {@link Map#of()}) if no
* tint channels defined or found
*/
public static Map<String, Integer> resolve(ItemStack stack) {
Map<String, Integer> result = new LinkedHashMap<>();
// 1. Load defaults from DataDrivenItemDefinition
// Hot-path shortcut: both sources empty → return the singleton empty
// map without allocating.
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.tintChannels() != null) {
boolean hasDefChannels =
def != null &&
def.tintChannels() != null &&
!def.tintChannels().isEmpty();
CompoundTag tag = stack.getTag();
boolean hasNbtTints =
tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND);
if (!hasDefChannels && !hasNbtTints) {
return Map.of();
}
Map<String, Integer> result = new LinkedHashMap<>();
if (hasDefChannels) {
result.putAll(def.tintChannels());
}
// 2. Override with NBT "tint_colors" (player dye overrides)
CompoundTag tag = stack.getTag();
if (tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND)) {
if (hasNbtTints) {
CompoundTag tints = tag.getCompound("tint_colors");
for (String key : tints.getAllKeys()) {
result.put(key, tints.getInt(key));
// Type-check the NBT entry: a corrupt save or malicious client
// might store a non-int value; getInt would silently return 0
// and render pure black. Skip non-int entries.
if (tints.contains(key, Tag.TAG_INT)) {
// Clamp to 24-bit RGB (no alpha in tint channels).
result.put(key, tints.getInt(key) & 0xFFFFFF);
}
}
}
return result;
}
}

View File

@@ -14,8 +14,10 @@ import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
@@ -81,6 +83,19 @@ public class V2BondageRenderLayer<
float netHeadYaw,
float headPitch
) {
// Do NOT use entity.isInvisible() — that hides the local player's own
// bondage from their F5/F1 self-view under Invisibility. isInvisibleTo
// handles same-team visibility and spectator viewers correctly BUT
// returns true for self when teamless (MC default) + Invisibility —
// excluding `entity == mc.player` preserves the self-view.
Minecraft mc = Minecraft.getInstance();
if (mc.player != null && entity != mc.player && entity.isInvisibleTo(mc.player)) {
return;
}
if (entity instanceof Player p && p.isSpectator()) {
return;
}
// Get V2 equipment via capability (Players) or IV2EquipmentHolder (Damsels)
IV2BondageEquipment equipment = null;
if (entity instanceof Player player) {
@@ -119,14 +134,23 @@ public class V2BondageRenderLayer<
ItemStack stack = entry.getValue();
if (stack.isEmpty()) continue;
// Furniture blocks this region — skip rendering
if (furnitureBlocked.contains(entry.getKey())) continue;
// Check if the item implements IV2BondageItem
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) {
continue;
}
// Skip if the furniture blocks ANY region this item occupies. A
// multi-region item (armbinder on {ARMS, HANDS, TORSO}) can be
// keyed in the de-duplicated map under ARMS but must still skip
// when a seat blocks only HANDS — hence disjoint() on all regions.
Set<BodyRegionV2> itemRegions = bondageItem.getOccupiedRegions(stack);
if (
!furnitureBlocked.isEmpty() &&
!Collections.disjoint(itemRegions, furnitureBlocked)
) {
continue;
}
// Select slim model variant for Alex-style players or slim Damsels
boolean isSlim;
if (entity instanceof AbstractClientPlayer acp) {

View File

@@ -34,9 +34,6 @@ public record DataDrivenItemDefinition(
/** Optional slim (Alex-style) model variant. */
@Nullable ResourceLocation slimModelLocation,
/** Optional base texture path for color variant resolution. */
@Nullable ResourceLocation texturePath,
/** Optional separate GLB for animations (shared template). */
@Nullable ResourceLocation animationSource,

View File

@@ -38,7 +38,6 @@ import org.jetbrains.annotations.Nullable;
* "translation_key": "item.tiedup.leather_armbinder",
* "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
* "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
* "texture": "tiedup:textures/item/armbinder",
* "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
* "regions": ["ARMS", "HANDS", "TORSO"],
* "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
@@ -150,13 +149,6 @@ public final class DataDrivenItemParser {
fileId
);
// Optional: texture
ResourceLocation texturePath = parseOptionalResourceLocation(
root,
"texture",
fileId
);
// Optional: animation_source
ResourceLocation animationSource = parseOptionalResourceLocation(
root,
@@ -340,7 +332,6 @@ public final class DataDrivenItemParser {
translationKey,
modelLocation,
slimModelLocation,
texturePath,
animationSource,
occupiedRegions,
blockedRegions,

View File

@@ -48,6 +48,16 @@ public final class DataDrivenItemRegistry {
*/
private static volatile RegistrySnapshot SNAPSHOT = RegistrySnapshot.EMPTY;
/**
* Monotonically increasing revision counter, incremented on every
* {@link #reload} or {@link #mergeAll}. Client-side caches (e.g.,
* {@code DataDrivenIconOverrides}) observe this to invalidate themselves
* when the registry changes between bake passes — {@code /reload} updates
* the registry but does not re-fire {@code ModelEvent.ModifyBakingResult},
* so any cached resolution would otherwise go stale.
*/
private static volatile int revision = 0;
/** Guards read-then-write sequences in {@link #reload} and {@link #mergeAll}. */
private static final Object RELOAD_LOCK = new Object();
@@ -66,6 +76,7 @@ public final class DataDrivenItemRegistry {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(new HashMap<>(newDefs));
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
revision++;
}
}
@@ -91,9 +102,19 @@ public final class DataDrivenItemRegistry {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(merged);
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
revision++;
}
}
/**
* Current registry revision — incremented on every {@link #reload} or
* {@link #mergeAll}. Client-side caches compare their stored revision
* against this to detect staleness across {@code /reload} cycles.
*/
public static int getRevision() {
return revision;
}
/**
* Get a definition by its unique ID.
*

View File

@@ -75,11 +75,12 @@ public class PacketSyncV2Equipment {
Entity entity = level.getEntity(msg.entityId);
if (entity instanceof LivingEntity living) {
// IV2EquipmentHolder entities (e.g., Damsels) sync via SynchedEntityData,
// not via this packet. If we receive one for such an entity, deserialize
// directly into their internal equipment storage.
if (living instanceof IV2EquipmentHolder holder) {
holder.getV2Equipment().deserializeNBT(msg.data);
// IV2EquipmentHolder entities (Damsels, NPCs) use SynchedEntityData
// for equipment sync, not this packet — both V2EquipmentHelper.sync
// and syncTo short-circuit on holders. If a packet for a holder
// does arrive it's a protocol bug; drop it rather than race with
// the EntityData path.
if (living instanceof IV2EquipmentHolder) {
return;
}
living

View File

@@ -59,9 +59,30 @@ public class DataDrivenIconOverrides extends ItemOverrides {
private final Mode mode;
/**
* Sentinel meaning "no revision observed yet". Anything other than this
* value means the caches were populated under that revision.
*/
private static final int UNINITIALIZED = Integer.MIN_VALUE;
/**
* Last-observed revision of the backing registry (selected by {@link #mode}).
* If the registry mutates between bake passes (typically via {@code /reload}
* on the server, which does NOT re-fire {@code ModelEvent.ModifyBakingResult}),
* the next {@link #resolve} call detects the mismatch and flushes
* {@link #iconModelCache} / {@link #knownMissing} / {@link #warnedMissing}.
*
* <p>Lazily initialized on first {@link #resolve} to avoid a race where the
* client reload listener runs after {@code ModifyBakingResult} created
* this instance but before the first frame renders — early init here
* would then observe a mismatch and flush an already-warm cache.</p>
*/
private volatile int observedRevision = UNINITIALIZED;
/**
* Cache of resolved icon ResourceLocations to their BakedModels.
* Cleared on resource reload (when ModifyBakingResult fires again).
* Cleared on resource reload (when ModifyBakingResult fires again) and
* implicitly on registry revision change.
* Uses ConcurrentHashMap because resolve() is called from the render thread.
*
* <p>Values are never null (ConcurrentHashMap forbids nulls). Missing icons
@@ -97,6 +118,26 @@ public class DataDrivenIconOverrides extends ItemOverrides {
@Nullable LivingEntity entity,
int seed
) {
// Observe the registry revision; if it moved since we last populated
// our caches, flush them. This handles the /reload-without-rebake path
// where ModelEvent.ModifyBakingResult doesn't re-fire but the
// underlying definition set has changed.
//
// The revision source depends on mode: bondage items live in
// DataDrivenItemRegistry, furniture placers live in FurnitureRegistry.
// A stale check against the wrong registry would ignore /reload updates
// to the corresponding feature.
int currentRevision = currentRegistryRevision();
if (observedRevision == UNINITIALIZED) {
// Lazy init on first resolve. Baseline the current revision so the
// post-bake warm cache populated by the very first resolve call
// isn't spuriously flushed by a race with the reload listener.
observedRevision = currentRevision;
} else if (currentRevision != observedRevision) {
clearCache();
observedRevision = currentRevision;
}
ResourceLocation iconRL = getIconFromStack(stack);
if (iconRL == null) {
// No icon defined for this variant — use the default model
@@ -230,4 +271,21 @@ public class DataDrivenIconOverrides extends ItemOverrides {
knownMissing.clear();
warnedMissing.clear();
}
/**
* Select the registry revision source appropriate for this override's mode.
* Bondage items and furniture placers have independent registries and
* independent /reload cycles, so a bondage-item cache must not invalidate
* just because furniture reloaded (and vice versa).
*/
private int currentRegistryRevision() {
switch (mode) {
case BONDAGE_ITEM:
return DataDrivenItemRegistry.getRevision();
case FURNITURE_PLACER:
return com.tiedup.remake.v2.furniture.FurnitureRegistry.getRevision();
default:
return 0;
}
}
}

View File

@@ -92,6 +92,13 @@ public class ObjBlockRenderer implements BlockEntityRenderer<ObjBlockEntity> {
}
}
/**
* Always true. Furniture OBJ models routinely have visible geometry that
* extends past the block's 1×1×1 AABB (a 3-wide couch), so the default
* per-entity frustum cull produces visible pop-in at screen edges as the
* origin block leaves view. Extra cost: a few OBJ draws per frame for
* off-screen furniture.
*/
@Override
public boolean shouldRenderOffScreen(ObjBlockEntity blockEntity) {
return true;

View File

@@ -5,7 +5,13 @@ import com.tiedup.remake.client.model.CellCoreBakedModel;
import com.tiedup.remake.client.renderer.CellCoreRenderer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.client.resources.model.ModelResourceLocation;
import net.minecraft.resources.ResourceLocation;
@@ -14,6 +20,7 @@ import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.ModelEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.registries.ForgeRegistries;
/**
* V2 Client-side setup.
@@ -65,6 +72,81 @@ public class V2ClientSetup {
);
}
/**
* Register custom icon models for baking.
*
* <p>When a data-driven item or furniture JSON declares an {@code icon}
* ResourceLocation, the mod loads the corresponding model from
* {@code assets/<namespace>/models/item/<path>.json} (or
* {@code .../models/item/icons/<path>.json} by convention). Forge's
* model baker only auto-discovers models referenced by registered items;
* icons that aren't the model of a registered item are invisible unless
* explicitly registered here.</p>
*
* <p>We scan both registries at this phase and register every non-null
* icon. If an icon already matches a registered item's model, Forge
* deduplicates internally — safe to register unconditionally.</p>
*
* <p>Timing caveat: if {@code DataDrivenItemRegistry} hasn't finished
* loading JSON on first bootstrap, {@link #getAllIconLocations()}
* returns fewer entries than expected. The subsequent resource reload
* (triggered by {@code DataDrivenItemReloadListener}) refires this
* event, so icons register correctly on the second pass. In practice,
* inventory icons are visible from the first render after login.</p>
*/
@SubscribeEvent
public static void onRegisterAdditionalModels(
ModelEvent.RegisterAdditional event
) {
Set<ResourceLocation> icons = collectCustomIconLocations();
int registered = 0;
for (ResourceLocation icon : icons) {
event.register(icon);
registered++;
}
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Registered {} custom icon model(s) for baking",
registered
);
}
/**
* Gather every icon ResourceLocation declared by a data-driven item or
* furniture definition that doesn't correspond to an already-registered
* Forge item. Icons pointing at registered items are auto-loaded by the
* baker and don't need explicit registration.
*/
private static Set<ResourceLocation> collectCustomIconLocations() {
Set<ResourceLocation> icons = new HashSet<>();
for (DataDrivenItemDefinition def : DataDrivenItemRegistry.getAll()) {
if (def.icon() != null && !isRegisteredItemModel(def.icon())) {
icons.add(def.icon());
}
}
for (FurnitureDefinition def : FurnitureRegistry.getAll()) {
if (def.icon() != null && !isRegisteredItemModel(def.icon())) {
icons.add(def.icon());
}
}
return icons;
}
/**
* True when {@code modelLoc} follows the {@code <ns>:item/<path>} convention
* and {@code <ns>:<path>} is a registered Forge item. Those icons are
* auto-baked — re-registering is harmless but noisy.
*/
private static boolean isRegisteredItemModel(ResourceLocation modelLoc) {
String path = modelLoc.getPath();
if (!path.startsWith("item/")) return false;
String itemPath = path.substring("item/".length());
ResourceLocation itemRL = ResourceLocation.fromNamespaceAndPath(
modelLoc.getNamespace(),
itemPath
);
return ForgeRegistries.ITEMS.containsKey(itemRL);
}
@SubscribeEvent
public static void onModifyBakingResult(
ModelEvent.ModifyBakingResult event

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). */

View File

@@ -0,0 +1,317 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.CollarHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Shared authorization predicate for furniture packets and in-world interactions.
*
* <p>The 2026-04-17 audit found three divergent code paths enforcing different
* subsets of the intended security model:</p>
* <ul>
* <li>{@code EntityFurniture.interact()} force-mount path — all checks present</li>
* <li>{@code PacketFurnitureForcemount.handleOnServer} — missing
* {@code isTiedUp} and {@code !isPassenger} checks</li>
* <li>{@code EntityFurniture.interact()} lock path and
* {@code PacketFurnitureLock.handleOnServer} — missing collar-ownership
* enforcement</li>
* </ul>
*
* <p>This class unifies the model. The security rule is:</p>
* <ol>
* <li><b>Lock/unlock</b>: sender holds a master key, seat exists and is
* lockable, seat is occupied, and the occupant wears a collar owned by
* the sender (or the sender has OP permissions). A seat whose occupant
* has no collar cannot be locked by anyone other than an OP.</li>
* <li><b>Force-mount</b>: captive is alive, within range, wears a collar
* owned by the sender (or sender is OP), is currently tied-up (leashed),
* and is not already a passenger of some other entity.</li>
* </ol>
*
* <p>The boolean core methods are package-private to allow pure-logic unit
* testing without a Minecraft runtime.</p>
*/
public final class FurnitureAuthPredicate {
/** Max interaction range from sender to furniture/captive (blocks). */
public static final double INTERACTION_RANGE = 5.0;
private FurnitureAuthPredicate() {}
// ============================================================
// Pure-logic core (unit-testable with booleans)
// ============================================================
/**
* Pure-logic authorization for a lock/unlock action. All parameters are
* pre-extracted from Minecraft entities by {@link #canLockUnlock}.
*/
static boolean isAuthorizedForLock(
boolean senderHasKey,
boolean seatExistsAndLockable,
boolean seatOccupied,
boolean occupantHasOwnedCollar
) {
return (
senderHasKey &&
seatExistsAndLockable &&
seatOccupied &&
occupantHasOwnedCollar
);
}
/**
* Pure-logic authorization for a force-mount action. All parameters are
* pre-extracted from Minecraft entities by {@link #canForceMount}.
*/
static boolean isAuthorizedForForceMount(
boolean captiveAlive,
boolean captiveWithinRange,
boolean captiveHasOwnedCollar,
boolean captiveIsTiedUp,
boolean captiveIsNotPassenger
) {
return (
captiveAlive &&
captiveWithinRange &&
captiveHasOwnedCollar &&
captiveIsTiedUp &&
captiveIsNotPassenger
);
}
// ============================================================
// MC-aware wrappers (call sites use these)
// ============================================================
/**
* Check whether {@code sender} may toggle the lock state of the given seat.
* Also runs proximity / liveness gates on the furniture entity itself.
* Logs a DEBUG reason on denial.
*/
public static boolean canLockUnlock(
ServerPlayer sender,
Entity furniture,
String seatId
) {
if (sender == null || furniture == null || seatId == null) {
return false;
}
if (!furniture.isAlive() || furniture.isRemoved()) {
debug(
"denied: furniture removed/dead (id={})",
furniture.getId()
);
return false;
}
if (sender.distanceTo(furniture) > INTERACTION_RANGE) {
debug(
"denied: out of range ({} blocks)",
sender.distanceTo(furniture)
);
return false;
}
if (!(furniture instanceof ISeatProvider provider)) {
debug("denied: entity is not a seat provider");
return false;
}
boolean senderHasKey = holdsMasterKey(sender);
boolean seatExistsAndLockable = seatIsLockable(provider, seatId);
LivingEntity occupant = findOccupant(furniture, seatId);
boolean seatOccupied = occupant != null;
boolean occupantHasOwnedCollar = occupantHasOwnedCollarFor(
occupant,
sender
);
boolean authorized = isAuthorizedForLock(
senderHasKey,
seatExistsAndLockable,
seatOccupied,
occupantHasOwnedCollar
);
if (!authorized) {
debug(
"lock denied: sender={}, key={}, lockable={}, occupied={}, ownedCollar={}",
sender.getName().getString(),
senderHasKey,
seatExistsAndLockable,
seatOccupied,
occupantHasOwnedCollar
);
}
return authorized;
}
/**
* Check whether {@code sender} may force-mount {@code captive} onto
* {@code furniture}. Logs a DEBUG reason on denial.
*/
public static boolean canForceMount(
ServerPlayer sender,
Entity furniture,
LivingEntity captive
) {
if (sender == null || furniture == null || captive == null) {
return false;
}
if (!furniture.isAlive() || furniture.isRemoved()) return false;
// Entity.distanceTo ignores dimension — require same level explicitly.
if (sender.level() != furniture.level()) return false;
if (sender.level() != captive.level()) return false;
if (sender.distanceTo(furniture) > INTERACTION_RANGE) return false;
// A full furniture must reject force-mount: assignSeat would find no
// free seat and the captive would become a passenger with no seat id.
if (furniture instanceof com.tiedup.remake.v2.furniture.EntityFurniture ef) {
FurnitureDefinition def = ef.getDefinition();
int seatCount = def != null ? def.seats().size() : 0;
if (ef.getPassengers().size() >= seatCount) return false;
}
boolean captiveAlive = captive.isAlive() && !captive.isRemoved();
boolean captiveWithinRange =
sender.distanceTo(captive) <= INTERACTION_RANGE &&
captive.distanceTo(furniture) <= INTERACTION_RANGE;
boolean captiveHasOwnedCollar = captiveHasCollarOwnedBy(
captive,
sender
);
boolean captiveIsTiedUp = captiveIsLeashed(captive);
boolean captiveIsNotPassenger =
!captive.isPassenger() || captive.getVehicle() == furniture;
boolean authorized = isAuthorizedForForceMount(
captiveAlive,
captiveWithinRange,
captiveHasOwnedCollar,
captiveIsTiedUp,
captiveIsNotPassenger
);
if (!authorized) {
debug(
"force-mount denied: sender={}, captive={}, alive={}, range={}, ownedCollar={}, tiedUp={}, notPassenger={}",
sender.getName().getString(),
captive.getName().getString(),
captiveAlive,
captiveWithinRange,
captiveHasOwnedCollar,
captiveIsTiedUp,
captiveIsNotPassenger
);
}
return authorized;
}
// ============================================================
// Extraction helpers (isolate MC-API touch points)
// ============================================================
private static boolean holdsMasterKey(Player sender) {
return (
sender.getMainHandItem().getItem() instanceof ItemMasterKey ||
sender.getOffhandItem().getItem() instanceof ItemMasterKey
);
}
private static boolean seatIsLockable(ISeatProvider provider, String seatId) {
for (SeatDefinition seat : provider.getSeats()) {
if (seat.id().equals(seatId)) {
return seat.lockable();
}
}
return false;
}
/**
* Find the passenger currently assigned to the given seat. Returns null if
* the seat is unoccupied or the occupant is not a LivingEntity.
*/
@Nullable
private static LivingEntity findOccupant(Entity furniture, String seatId) {
if (!(furniture instanceof EntityFurniture ef)) return null;
for (Entity passenger : ef.getPassengers()) {
SeatDefinition assigned = ef.getSeatForPassenger(passenger);
if (
assigned != null &&
assigned.id().equals(seatId) &&
passenger instanceof LivingEntity living
) {
return living;
}
}
return null;
}
/**
* True when {@code occupant} wears a collar and either the sender owns it
* or the sender has OP permissions.
*/
private static boolean occupantHasOwnedCollarFor(
@Nullable LivingEntity occupant,
ServerPlayer sender
) {
if (occupant == null) return sender.hasPermissions(2);
return captiveHasCollarOwnedBy(occupant, sender);
}
/**
* True when {@code captive} has a V2 collar in the NECK slot AND either
* the sender owns it or the sender has OP permissions.
*/
private static boolean captiveHasCollarOwnedBy(
LivingEntity captive,
ServerPlayer sender
) {
IBondageState state = resolveBondageState(captive);
if (state == null || !state.hasCollar()) {
return sender.hasPermissions(2);
}
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) {
return sender.hasPermissions(2);
}
return (
CollarHelper.isOwner(collar, sender) || sender.hasPermissions(2)
);
}
/**
* Resolve an {@link IBondageState} for any entity that can wear bondage —
* player, MCA villager, damsel, etc. Delegates to {@link KidnappedHelper}
* which handles the multi-type dispatch.
*/
@Nullable
private static IBondageState resolveBondageState(LivingEntity entity) {
if (entity instanceof Player player) {
return com.tiedup.remake.state.PlayerBindState.getInstance(player);
}
return KidnappedHelper.getKidnappedState(entity);
}
/**
* True when the entity is currently leashed/tied-up (not just wearing
* restraints — actively held by a captor).
*/
private static boolean captiveIsLeashed(LivingEntity captive) {
IBondageState state = resolveBondageState(captive);
return state != null && state.isTiedUp();
}
private static void debug(String format, Object... args) {
TiedUpMod.LOGGER.debug("[FurnitureAuth] " + format, args);
}
}

View File

@@ -300,9 +300,17 @@ public final class FurnitureParser {
);
return null;
}
if (seatId.contains(":")) {
// Reject separators used by SEAT_ASSIGNMENTS_SYNC encoding
// ("uuid;seat|uuid;seat|…") — a seat id containing | or ; would
// corrupt the client map on parse. `:` is reserved for the
// ResourceLocation separator if this id is ever promoted to one.
if (
seatId.contains(":") ||
seatId.contains("|") ||
seatId.contains(";")
) {
LOGGER.error(
"{} Skipping {}: seats[{}] id '{}' must not contain ':'",
"{} Skipping {}: seats[{}] id '{}' must not contain ':', '|', or ';'",
TAG,
fileId,
index,

View File

@@ -35,6 +35,14 @@ public final class FurnitureRegistry {
FurnitureDefinition
> DEFINITIONS = Map.of();
/**
* Monotonically increasing revision counter, incremented on every
* {@link #reload}. Client-side caches (e.g.,
* {@code DataDrivenIconOverrides} in {@code FURNITURE_PLACER} mode)
* observe this to invalidate themselves when the registry changes.
*/
private static volatile int revision = 0;
private FurnitureRegistry() {}
/**
@@ -48,6 +56,16 @@ public final class FurnitureRegistry {
Map<ResourceLocation, FurnitureDefinition> newDefs
) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
revision++;
}
/**
* Current registry revision — incremented on every {@link #reload}.
* Client-side caches compare their stored revision against this to detect
* staleness across {@code /reload} cycles or sync-packet updates.
*/
public static int getRevision() {
return revision;
}
/**

View File

@@ -0,0 +1,51 @@
package com.tiedup.remake.v2.furniture;
import net.minecraft.world.phys.Vec3;
/**
* Side-neutral math helpers for the server-side fallback seat geometry used
* when GLB-baked {@code SeatTransform} data is unavailable.
*
* <p>Seats are placed evenly along the furniture entity's local right axis:
* for {@code n} seats the offsets are {@code -(n-1)/2, -(n-1)/2 + 1, …, (n-1)/2}
* (so 1 seat → center; 3 seats → -1, 0, 1). The right axis is derived from
* the entity's yaw.</p>
*
* <p>Previously inlined at three {@code EntityFurniture} sites (positionRider,
* findNearestAvailableSeat, findNearestOccupiedLockableSeat) with identical
* formulas. Centralised here so any future tweak (e.g. per-definition seat
* spacing) updates one place.</p>
*/
public final class FurnitureSeatGeometry {
private FurnitureSeatGeometry() {}
/**
* Entity-local right axis in world XZ coordinates for the given yaw.
* Y component is always 0.
*
* @param yawDegrees entity yaw in degrees (as {@code Entity.getYRot()})
* @return unit vector pointing to the entity's right in world space
*/
public static Vec3 rightAxis(float yawDegrees) {
double yawRad = Math.toRadians(yawDegrees);
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
return new Vec3(rightX, 0.0, rightZ);
}
/**
* Local offset along the right axis for {@code seatIndex} among
* {@code seatCount} evenly-spaced seats.
*
* <p>Examples: 1 seat → 0.0 (centered). 2 seats → -0.5, 0.5. 3 seats →
* -1.0, 0.0, 1.0.</p>
*
* @param seatIndex zero-based seat index
* @param seatCount total seats on the furniture (must be ≥ 1)
* @return signed distance from centre along the right axis
*/
public static double seatOffset(int seatIndex, int seatCount) {
return seatCount == 1 ? 0.0 : (seatIndex - (seatCount - 1) / 2.0);
}
}

View File

@@ -21,6 +21,15 @@ public interface ISeatProvider {
@Nullable
SeatDefinition getSeatForPassenger(Entity passenger);
/**
* Find the passenger entity currently assigned to {@code seatId}, or
* null if the seat is unoccupied. Used by reconnection / force-mount
* paths to refuse a seat-takeover that would orphan an existing
* occupant.
*/
@Nullable
Entity findPassengerInSeat(String seatId);
/** Assign a passenger to a specific seat. */
void assignSeat(Entity passenger, String seatId);

View File

@@ -15,11 +15,9 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
@@ -57,47 +55,30 @@ public final class FurnitureGlbParser {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private static final String PLAYER_PREFIX = "Player_";
private FurnitureGlbParser() {}
/**
* Parse a multi-armature .glb file into a {@link FurnitureGltfData}.
* Parse a multi-armature .glb file into a {@link FurnitureGltfData}. Validates
* header, version, and total length (capped at {@link GlbParserUtils#MAX_GLB_SIZE})
* before allocating chunk buffers.
*
* @param input the input stream (read fully, not closed by this method)
* @param debugName human-readable name for log messages
* @return parsed furniture data
* @throws IOException if the file is malformed or I/O fails
* @throws IOException if the file is malformed, oversized, or truncated
*/
public static FurnitureGltfData parse(InputStream input, String debugName)
throws IOException {
byte[] allBytes = input.readAllBytes();
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
ByteOrder.LITTLE_ENDIAN
);
// ---- GLB header ----
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
buf.getInt(); // total file length — not needed, we already have all bytes
ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);
// ---- JSON chunk ----
int jsonChunkLength = buf.getInt();
int jsonChunkLength = GlbParserUtils.readChunkLength(
buf,
"JSON",
debugName
);
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
if (jsonChunkType != GlbParserUtils.CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName);
}
byte[] jsonBytes = new byte[jsonChunkLength];
@@ -108,9 +89,13 @@ public final class FurnitureGlbParser {
// ---- BIN chunk ----
ByteBuffer binData = null;
if (buf.hasRemaining()) {
int binChunkLength = buf.getInt();
int binChunkLength = GlbParserUtils.readChunkLength(
buf,
"BIN",
debugName
);
int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) {
if (binChunkType != GlbParserUtils.CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName);
}
byte[] binBytes = new byte[binChunkLength];
@@ -127,138 +112,25 @@ public final class FurnitureGlbParser {
JsonArray meshes = root.getAsJsonArray("meshes");
JsonArray skins = root.getAsJsonArray("skins");
// ---- Identify Player_* armature root nodes ----
// A "Player_*" armature root is any node whose name starts with "Player_".
// Its name suffix is the seat ID.
Map<String, Integer> seatIdToRootNode = new LinkedHashMap<>(); // seatId -> node index
Set<Integer> playerRootNodes = new HashSet<>();
// ---- Identify Player_* armature roots, classify skins, extract seat transforms ----
// Delegated to PlayerArmatureScanner (extracted 2026-04-18) to keep this
// orchestration method small. Behavior preserved byte-for-byte.
PlayerArmatureScanner.ArmatureScan scan =
PlayerArmatureScanner.scan(nodes);
Map<String, Integer> seatIdToRootNode = scan.seatIdToRootNode;
if (nodes != null) {
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
String name = node.has("name")
? node.get("name").getAsString()
: "";
if (
name.startsWith(PLAYER_PREFIX) &&
name.length() > PLAYER_PREFIX.length()
) {
String seatId = name.substring(PLAYER_PREFIX.length());
seatIdToRootNode.put(seatId, ni);
playerRootNodes.add(ni);
LOGGER.debug(
"[FurnitureGltf] Found Player armature: '{}' -> seat '{}'",
name,
seatId
);
}
}
}
PlayerArmatureScanner.SkinClassification classification =
PlayerArmatureScanner.classifySkins(
skins,
nodes,
scan,
debugName
);
int furnitureSkinIdx = classification.furnitureSkinIdx;
Map<String, Integer> seatIdToSkinIdx = classification.seatIdToSkinIdx;
// ---- Classify skins ----
// For each skin, check if its skeleton node (or the first joint's parent) is a Player_* root.
// The "skeleton" field in a glTF skin points to the root bone node of that armature.
int furnitureSkinIdx = -1;
Map<String, Integer> seatIdToSkinIdx = new LinkedHashMap<>(); // seatId -> skin index
if (skins != null) {
for (int si = 0; si < skins.size(); si++) {
JsonObject skin = skins.get(si).getAsJsonObject();
int skeletonNode = skin.has("skeleton")
? skin.get("skeleton").getAsInt()
: -1;
// Check if the skeleton root node is a Player_* armature
String matchedSeatId = null;
if (
skeletonNode >= 0 && playerRootNodes.contains(skeletonNode)
) {
// Direct match: skeleton field points to a Player_* node
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (entry.getValue() == skeletonNode) {
matchedSeatId = entry.getKey();
break;
}
}
}
// Fallback: check if any joint in this skin is a child of a Player_* root
if (matchedSeatId == null && skin.has("joints")) {
matchedSeatId = matchSkinToPlayerArmature(
skin.getAsJsonArray("joints"),
nodes,
seatIdToRootNode
);
}
if (matchedSeatId != null) {
seatIdToSkinIdx.put(matchedSeatId, si);
LOGGER.debug(
"[FurnitureGltf] Skin {} -> seat '{}'",
si,
matchedSeatId
);
} else if (furnitureSkinIdx < 0) {
furnitureSkinIdx = si;
LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si);
} else {
LOGGER.warn(
"[FurnitureGltf] Extra non-Player skin {} ignored in '{}'",
si,
debugName
);
}
}
}
// ---- Extract seat transforms from Player_* root nodes ----
Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
new LinkedHashMap<>();
for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
String seatId = entry.getKey();
int nodeIdx = entry.getValue();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
Vector3f position = new Vector3f();
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
position.set(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
}
Quaternionf rotation = new Quaternionf();
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
rotation.set(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
}
seatTransforms.put(
seatId,
new FurnitureGltfData.SeatTransform(seatId, position, rotation)
);
LOGGER.debug(
"[FurnitureGltf] Seat '{}' transform: pos=({},{},{}), rot=({},{},{},{})",
seatId,
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
rotation.w
);
}
PlayerArmatureScanner.extractTransforms(nodes, seatIdToRootNode);
// ---- Parse furniture mesh (full GltfData from the furniture skin) ----
GltfData furnitureMesh = parseFurnitureSkin(
@@ -551,50 +423,6 @@ public final class FurnitureGlbParser {
);
}
// Skin classification helpers
/**
* Try to match a skin's joints to a Player_* armature by checking whether
* any joint node is a descendant of a Player_* root node.
*/
@Nullable
private static String matchSkinToPlayerArmature(
JsonArray skinJoints,
JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
for (JsonElement jointElem : skinJoints) {
int jointNodeIdx = jointElem.getAsInt();
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) {
return entry.getKey();
}
}
}
return null;
}
/**
* Check if nodeIdx is a descendant of ancestorIdx via the node children hierarchy.
* Also returns true if nodeIdx == ancestorIdx.
*/
private static boolean isDescendantOf(
int nodeIdx,
int ancestorIdx,
JsonArray nodes
) {
if (nodeIdx == ancestorIdx) return true;
JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject();
if (!ancestor.has("children")) return false;
for (JsonElement child : ancestor.getAsJsonArray("children")) {
if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true;
}
return false;
}
/**
* Detect the name of the furniture armature's skeleton root node.
*/
@@ -655,7 +483,6 @@ public final class FurnitureGlbParser {
// ---- Parse ALL joints from this skin (no bone filtering for furniture) ----
int jointCount = skinJoints.size();
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -670,56 +497,26 @@ public final class FurnitureGlbParser {
nodeToJoint[nodeIdx] = j;
}
// Read joint names and rest poses
java.util.Arrays.fill(parentJointIndices, -1);
// Strip the Blender armature prefix (e.g. "Armature|root" → "root")
// so names line up with parseSeatSkeleton and GlbParser.
for (int j = 0; j < jointCount; j++) {
int nodeIdx = jointNodeIndices.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
jointNames[j] = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawName);
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf();
}
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
restRotations[j] = GlbParserUtils.readRestRotation(node);
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
for (JsonElement child : node.getAsJsonArray("children")) {
int childNodeIdx = child.getAsInt();
if (childNodeIdx < nodeToJoint.length) {
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
}
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
jointCount
);
// ---- Inverse bind matrices ----
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
@@ -752,7 +549,7 @@ public final class FurnitureGlbParser {
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!meshName.startsWith("Player")) {
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
}
}
@@ -773,135 +570,25 @@ public final class FurnitureGlbParser {
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
List<int[]> allJoints = new ArrayList<>();
List<float[]> allWeights = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = GlbParserUtils.readFloatAccessor(
GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
mesh,
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
jointCount,
/* readSkinning */true,
materialNames,
debugName
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// Skinning (no remap needed -- furniture keeps all joints)
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// Material / tint channel
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
positions = r.positions;
normals = r.normals;
texCoords = r.texCoords;
indices = r.indices;
meshJoints = r.joints;
weights = r.weights;
vertexCount = r.vertexCount;
parsedPrimitives.addAll(r.primitives);
} else {
LOGGER.info(
"[FurnitureGltf] No furniture mesh found in '{}'",
@@ -923,13 +610,12 @@ public final class FurnitureGlbParser {
}
// ---- Convert to Minecraft space ----
convertToMinecraftSpace(
GlbParserUtils.convertMeshToMinecraftSpace(
positions,
normals,
restTranslations,
restRotations,
inverseBindMatrices,
jointCount
inverseBindMatrices
);
return new GltfData(
@@ -979,7 +665,7 @@ public final class FurnitureGlbParser {
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!meshName.startsWith("Player")) {
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
break;
}
@@ -994,108 +680,26 @@ public final class FurnitureGlbParser {
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = GlbParserUtils.readFloatAccessor(
GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
mesh,
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
/* jointCount */0,
/* readSkinning */false,
materialNames,
debugName
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
positions = r.positions;
normals = r.normals;
texCoords = r.texCoords;
indices = r.indices;
vertexCount = r.vertexCount;
parsedPrimitives.addAll(r.primitives);
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
isTintable ? matName : null
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
cumulativeVertexCount += primVertexCount;
}
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
// Convert positions and normals to MC space
// Mesh-only path does not run full convertMeshToMinecraftSpace
// (no rest poses or IBMs to transform), just the vertex-space flip.
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
@@ -1213,13 +817,10 @@ public final class FurnitureGlbParser {
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix (e.g., "MyRig|body" -> "body")
if (name.contains("|")) {
name = name.substring(name.lastIndexOf('|') + 1);
}
String name = GlbParserUtils.stripArmaturePrefix(rawName);
if (GltfBoneMapper.isKnownBone(name)) {
skinJointRemap[j] = filteredJointNodes.size();
filteredJointNodes.add(nodeIdx);
@@ -1243,7 +844,6 @@ public final class FurnitureGlbParser {
}
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -1256,7 +856,6 @@ public final class FurnitureGlbParser {
}
// Read joint names and rest poses
java.util.Arrays.fill(parentJointIndices, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
@@ -1264,51 +863,17 @@ public final class FurnitureGlbParser {
String rawBoneName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix consistently
jointNames[j] = rawBoneName.contains("|")
? rawBoneName.substring(rawBoneName.lastIndexOf('|') + 1)
: rawBoneName;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawBoneName);
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf();
}
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
restRotations[j] = GlbParserUtils.readRestRotation(node);
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
for (JsonElement child : node.getAsJsonArray("children")) {
int childNodeIdx = child.getAsInt();
if (childNodeIdx < nodeToJoint.length) {
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
}
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
jointCount
);
// ---- Inverse bind matrices ----
// IBM accessor is indexed by original skin joint order, pick filtered entries
@@ -1344,13 +909,12 @@ public final class FurnitureGlbParser {
// Empty arrays for positions/normals (skeleton-only, no mesh)
float[] emptyPositions = new float[0];
float[] emptyNormals = new float[0];
convertToMinecraftSpace(
GlbParserUtils.convertMeshToMinecraftSpace(
emptyPositions,
emptyNormals,
restTranslations,
restRotations,
inverseBindMatrices,
jointCount
inverseBindMatrices
);
LOGGER.debug(
@@ -1437,13 +1001,10 @@ public final class FurnitureGlbParser {
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix (e.g., "MyRig|body" -> "body")
if (name.contains("|")) {
name = name.substring(name.lastIndexOf('|') + 1);
}
String name = GlbParserUtils.stripArmaturePrefix(rawName);
if (GltfBoneMapper.isKnownBone(name)) {
filteredJointNodes.add(nodeIdx);
}
@@ -1495,7 +1056,7 @@ public final class FurnitureGlbParser {
}
// Parse with the filtered joint mapping
GltfData.AnimationClip clip = parseAnimationWithMapping(
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
anim,
accessors,
bufferViews,
@@ -1686,8 +1247,8 @@ public final class FurnitureGlbParser {
nodeToJoint[nodeIdx] = j;
}
// Delegate to the standard animation parsing logic
return parseAnimationWithMapping(
// Delegate to the shared animation parsing logic
return GlbParserUtils.parseAnimation(
animation,
accessors,
bufferViews,
@@ -1697,170 +1258,4 @@ public final class FurnitureGlbParser {
);
}
/**
* Parse an animation clip using a provided node-to-joint index mapping.
* Mirrors GlbParser.parseAnimation exactly.
*/
@Nullable
private static GltfData.AnimationClip parseAnimationWithMapping(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
if (!target.has("node")) continue;
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (
nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0
) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// Coordinate conversion
/**
* Convert spatial data from glTF space to Minecraft model-def space.
* Same transform as GlbParser: 180 degrees around Z, negating X and Y.
*/
private static void convertToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180 deg Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
}
}

View File

@@ -2,6 +2,7 @@ package com.tiedup.remake.v2.furniture.client;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
@@ -18,6 +19,13 @@ import org.jetbrains.annotations.Nullable;
* <p>Loads .glb files via Minecraft's ResourceManager on first access and parses them
* with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}.
*
* <p>Cache values are {@link Optional}: empty means a previous load/parse failed
* and no retry should be attempted until {@link #clear()} is called. Previously
* a private {@code FAILED_SENTINEL} instance of {@code FurnitureGltfData} with
* a {@code null} furniture mesh was used for this purpose; identity-equality on
* that sentinel was brittle and let future callers observe a half-constructed
* data object.</p>
*
* <p>Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries.
*
* <p>This class is client-only and must never be referenced from server code.
@@ -27,15 +35,10 @@ public final class FurnitureGltfCache {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
/**
* Sentinel value stored in the cache when loading fails, to avoid retrying
* broken resources on every frame.
*/
private static final FurnitureGltfData FAILED_SENTINEL =
new FurnitureGltfData(null, Map.of(), Map.of(), Map.of());
private static final Map<ResourceLocation, FurnitureGltfData> CACHE =
new ConcurrentHashMap<>();
private static final Map<
ResourceLocation,
Optional<FurnitureGltfData>
> CACHE = new ConcurrentHashMap<>();
private FurnitureGltfCache() {}
@@ -45,22 +48,22 @@ public final class FurnitureGltfCache {
* @param modelLocation resource location of the .glb file
* (e.g., {@code tiedup:models/furniture/wooden_stocks.glb})
* @return parsed {@link FurnitureGltfData}, or {@code null} if loading/parsing failed
* (persistent until {@link #clear()} is called)
*/
@Nullable
public static FurnitureGltfData get(ResourceLocation modelLocation) {
FurnitureGltfData cached = CACHE.computeIfAbsent(
return CACHE.computeIfAbsent(
modelLocation,
FurnitureGltfCache::load
);
return cached == FAILED_SENTINEL ? null : cached;
).orElse(null);
}
/**
* Load and parse a furniture GLB from the resource manager.
*
* @return parsed data, or the {@link #FAILED_SENTINEL} on failure
* Load and parse a furniture GLB from the resource manager. Returns an
* empty {@link Optional} on any failure — the failure is cached so the
* parser isn't invoked again for the same resource until {@link #clear()}.
*/
private static FurnitureGltfData load(ResourceLocation loc) {
private static Optional<FurnitureGltfData> load(ResourceLocation loc) {
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
@@ -68,7 +71,7 @@ public final class FurnitureGltfCache {
.orElse(null);
if (resource == null) {
LOGGER.error("[FurnitureGltf] Resource not found: {}", loc);
return FAILED_SENTINEL;
return Optional.empty();
}
try (InputStream is = resource.open()) {
@@ -77,7 +80,7 @@ public final class FurnitureGltfCache {
loc.toString()
);
LOGGER.debug("[FurnitureGltf] Cached: {}", loc);
return data;
return Optional.of(data);
}
} catch (Exception e) {
LOGGER.error(
@@ -85,7 +88,7 @@ public final class FurnitureGltfCache {
loc,
e
);
return FAILED_SENTINEL;
return Optional.empty();
}
}

View File

@@ -44,9 +44,14 @@ public record FurnitureGltfData(
* Root transform of a Player_* armature, defining where a seated player is
* positioned and oriented relative to the furniture origin.
*
* <p>Values are in Minecraft model-def space: X/Y negated from raw glTF
* space to match the 180°-around-Z rotation applied to the furniture mesh
* by {@link com.tiedup.remake.client.gltf.GlbParserUtils#convertMeshToMinecraftSpace}.
* The conversion happens in {@code PlayerArmatureScanner.extractTransforms}.</p>
*
* @param seatId seat identifier (e.g., "main", "left")
* @param position translation offset in glTF space (meters, Y-up)
* @param rotation orientation quaternion in glTF space
* @param position translation offset in MC model-def space (meters, Y-up, X/Y negated from glTF)
* @param rotation orientation quaternion in MC model-def space (qx/qy negated from glTF)
*/
public record SeatTransform(
String seatId,

View File

@@ -0,0 +1,279 @@
package com.tiedup.remake.v2.furniture.client;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.tiedup.remake.client.gltf.GlbParserUtils;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* First pass of {@link FurnitureGlbParser}: discover {@code Player_*} armature
* roots, classify skins (furniture vs. per-seat), and extract per-seat root
* transforms.
*
* <p>Extracted from {@code FurnitureGlbParser.parse} to limit the god-class
* size and keep the scan/classify/extract logic testable in isolation.
* Pure-functional — no state on the class, every method returns a fresh
* result.</p>
*/
@OnlyIn(Dist.CLIENT)
final class PlayerArmatureScanner {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
private static final String PLAYER_PREFIX = "Player_";
private PlayerArmatureScanner() {}
/**
* Result of {@link #scan}: maps seat ID → node index for every
* {@code Player_<seatId>} root node, plus the set of root node indices
* (used later to classify skins).
*/
static final class ArmatureScan {
final Map<String, Integer> seatIdToRootNode;
final Set<Integer> playerRootNodes;
ArmatureScan(
Map<String, Integer> seatIdToRootNode,
Set<Integer> playerRootNodes
) {
this.seatIdToRootNode = seatIdToRootNode;
this.playerRootNodes = playerRootNodes;
}
}
/**
* Walk every node, match names against {@code "Player_<seatId>"}, and
* return the (seatId → nodeIdx) map plus the set of those node indices.
* A node named exactly {@code "Player_"} (empty seat ID) is skipped.
*/
static ArmatureScan scan(@Nullable JsonArray nodes) {
Map<String, Integer> seatIdToRootNode = new LinkedHashMap<>();
Set<Integer> playerRootNodes = new HashSet<>();
if (nodes == null) return new ArmatureScan(
seatIdToRootNode,
playerRootNodes
);
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
String name = node.has("name")
? node.get("name").getAsString()
: "";
if (
name.startsWith(PLAYER_PREFIX) &&
name.length() > PLAYER_PREFIX.length()
) {
String seatId = name.substring(PLAYER_PREFIX.length());
seatIdToRootNode.put(seatId, ni);
playerRootNodes.add(ni);
LOGGER.debug(
"[FurnitureGltf] Found Player armature: '{}' -> seat '{}'",
name,
seatId
);
}
}
return new ArmatureScan(seatIdToRootNode, playerRootNodes);
}
/**
* Result of {@link #classifySkins}: the first non-seat skin index (the
* "furniture" skin, or -1 if none) and a {@code seatId → skinIdx} map.
*/
static final class SkinClassification {
final int furnitureSkinIdx;
final Map<String, Integer> seatIdToSkinIdx;
SkinClassification(
int furnitureSkinIdx,
Map<String, Integer> seatIdToSkinIdx
) {
this.furnitureSkinIdx = furnitureSkinIdx;
this.seatIdToSkinIdx = seatIdToSkinIdx;
}
}
/**
* Classify each skin as either "furniture" or "seat N":
* <ol>
* <li>If the skin's {@code skeleton} field points at a known Player_*
* root → it's that seat.</li>
* <li>Fallback: if any joint in the skin is a descendant of a Player_*
* root → that seat.</li>
* <li>Otherwise: the first such skin is the "furniture" skin.</li>
* </ol>
* Extra non-matching skins are logged and ignored.
*/
static SkinClassification classifySkins(
@Nullable JsonArray skins,
@Nullable JsonArray nodes,
ArmatureScan scan,
String debugName
) {
int furnitureSkinIdx = -1;
Map<String, Integer> seatIdToSkinIdx = new LinkedHashMap<>();
if (skins == null || nodes == null) return new SkinClassification(
furnitureSkinIdx,
seatIdToSkinIdx
);
for (int si = 0; si < skins.size(); si++) {
JsonObject skin = skins.get(si).getAsJsonObject();
int skeletonNode = skin.has("skeleton")
? skin.get("skeleton").getAsInt()
: -1;
String matchedSeatId = null;
if (
skeletonNode >= 0 &&
scan.playerRootNodes.contains(skeletonNode)
) {
for (Map.Entry<
String,
Integer
> entry : scan.seatIdToRootNode.entrySet()) {
if (entry.getValue() == skeletonNode) {
matchedSeatId = entry.getKey();
break;
}
}
}
if (matchedSeatId == null && skin.has("joints")) {
matchedSeatId = matchSkinToPlayerArmature(
skin.getAsJsonArray("joints"),
nodes,
scan.seatIdToRootNode
);
}
if (matchedSeatId != null) {
seatIdToSkinIdx.put(matchedSeatId, si);
LOGGER.debug(
"[FurnitureGltf] Skin {} -> seat '{}'",
si,
matchedSeatId
);
} else if (furnitureSkinIdx < 0) {
furnitureSkinIdx = si;
LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si);
} else {
LOGGER.warn(
"[FurnitureGltf] Extra non-Player skin {} ignored in '{}'",
si,
debugName
);
}
}
return new SkinClassification(furnitureSkinIdx, seatIdToSkinIdx);
}
/**
* Build per-seat root transforms from the {@code Player_*} node rest pose.
* Position and rotation are read from the node's {@code translation} /
* {@code rotation} fields (identity defaults if absent) and then converted
* from raw glTF space to Minecraft model-def space by negating X/Y on the
* translation and qx/qy on the quaternion — matching the 180°-around-Z
* rotation applied to the furniture mesh by
* {@link com.tiedup.remake.client.gltf.GlbParserUtils#convertMeshToMinecraftSpace}.
*/
static Map<String, FurnitureGltfData.SeatTransform> extractTransforms(
@Nullable JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
new LinkedHashMap<>();
if (nodes == null) return seatTransforms;
for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
String seatId = entry.getKey();
int nodeIdx = entry.getValue();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
Vector3f position = GlbParserUtils.readRestTranslation(node);
Quaternionf rotation = GlbParserUtils.readRestRotation(node);
// glTF space → MC model-def space (180° around Z), matching the
// same transform GlbParserUtils.convertMeshToMinecraftSpace applies
// to the furniture mesh. Must stay consistent with the mesh or the
// passenger renders on the opposite side of asymmetric furniture.
position.x = -position.x;
position.y = -position.y;
rotation.x = -rotation.x;
rotation.y = -rotation.y;
seatTransforms.put(
seatId,
new FurnitureGltfData.SeatTransform(seatId, position, rotation)
);
LOGGER.debug(
"[FurnitureGltf] Seat '{}' transform (MC space): pos=({},{},{}), rot=({},{},{},{})",
seatId,
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
rotation.w
);
}
return seatTransforms;
}
/**
* Walk a skin's joint list to see if any joint is a descendant of one of
* the {@code Player_*} root nodes. Used as the fallback classification
* when the skin's {@code skeleton} field isn't set.
*/
@Nullable
private static String matchSkinToPlayerArmature(
JsonArray skinJoints,
JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
for (JsonElement jointElem : skinJoints) {
int jointNodeIdx = jointElem.getAsInt();
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) {
return entry.getKey();
}
}
}
return null;
}
/**
* Check if {@code nodeIdx} is {@code ancestorIdx} itself or any descendant
* of {@code ancestorIdx} via the node children hierarchy.
*/
private static boolean isDescendantOf(
int nodeIdx,
int ancestorIdx,
JsonArray nodes
) {
if (nodeIdx == ancestorIdx) return true;
JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject();
if (!ancestor.has("children")) return false;
for (JsonElement child : ancestor.getAsJsonArray("children")) {
if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true;
}
return false;
}
}

View File

@@ -166,12 +166,15 @@ public class PacketFurnitureEscape {
return;
}
// Compute difficulty
// Compute difficulty. Clamp to [0, MAX] so a misconfigured
// IV2BondageItem returning a negative bonus can't underflow the
// instant-escape check below and slip into the minigame with a
// negative resistance state.
int baseDifficulty = provider.getLockedDifficulty(seat.id());
int itemBonus = computeItemDifficultyBonus(sender, provider, seat);
int totalDifficulty = Math.min(
baseDifficulty + itemBonus,
MAX_DIFFICULTY
int totalDifficulty = Math.max(
0,
Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY)
);
TiedUpMod.LOGGER.debug(
@@ -306,9 +309,13 @@ public class PacketFurnitureEscape {
targetSeat
);
}
int totalDifficulty = Math.min(
baseDifficulty + itemBonus,
MAX_DIFFICULTY
// Clamp to [0, MAX] — same rationale as handleStruggle: a misconfigured
// item returning negative getEscapeDifficulty could push totalDifficulty
// below 0, skipping the == 0 instant-success branch below and starting
// a minigame with negative difficulty (sweet spot wider than intended).
int totalDifficulty = Math.max(
0,
Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY)
);
TiedUpMod.LOGGER.debug(
@@ -387,6 +394,10 @@ public class PacketFurnitureEscape {
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
ctx.putInt("furniture_id", furnitureEntity.getId());
ctx.putString("seat_id", targetSeat.id());
// Nonce: the handler accepts this ctx only when session_id matches
// the active session. Prevents stale ctx from mis-routing a later
// body-item lockpick.
ctx.putUUID("session_id", session.getSessionId());
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
// Send initial lockpick state to open the minigame GUI on the client
@@ -530,11 +541,9 @@ public class PacketFurnitureEscape {
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(furnitureEntity.getYRot());
// Entity-local right axis (perpendicular to facing in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = com.tiedup.remake.v2.furniture.FurnitureSeatGeometry.rightAxis(
furnitureEntity.getYRot()
);
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -556,11 +565,13 @@ public class PacketFurnitureEscape {
if (!hasPassenger) continue;
// Approximate seat world position along the right axis
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = com.tiedup.remake.v2.furniture.FurnitureSeatGeometry.seatOffset(
i, seatCount
);
Vec3 seatWorldPos = new Vec3(
furnitureEntity.getX() + rightX * offset,
furnitureEntity.getX() + right.x * offset,
furnitureEntity.getY() + 0.5,
furnitureEntity.getZ() + rightZ * offset
furnitureEntity.getZ() + right.z * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);

View File

@@ -98,54 +98,16 @@ public class PacketFurnitureForcemount {
return;
}
// Captive must be alive
if (!captive.isAlive() || captive.isRemoved()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive is not alive: {}",
captive.getName().getString()
);
return;
}
// Captive must be within 5 blocks of both sender and furniture
if (sender.distanceTo(captive) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from sender"
);
return;
}
if (captive.distanceTo(entity) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from furniture"
);
return;
}
// Verify collar ownership: captive must have a collar owned by sender
IBondageState captiveState = KidnappedHelper.getKidnappedState(captive);
if (captiveState == null || !captiveState.hasCollar()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive has no collar: {}",
captive.getName().getString()
);
return;
}
ItemStack collarStack = captiveState.getEquipment(BodyRegionV2.NECK);
if (!CollarHelper.isCollar(collarStack)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Invalid collar item on captive"
);
return;
}
// Collar must be owned by sender (or sender has admin permission)
if (!CollarHelper.isOwner(collarStack, sender) && !sender.hasPermissions(2)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} is not the collar owner of {}",
sender.getName().getString(),
captive.getName().getString()
);
// Unified authorization: range, liveness, collar ownership, leashed
// status, not-already-passenger. Shared with EntityFurniture.interact()
// via FurnitureAuthPredicate to prevent security drift between paths.
if (
!com.tiedup.remake.v2.furniture.FurnitureAuthPredicate.canForceMount(
sender,
entity,
captive
)
) {
return;
}

View File

@@ -77,47 +77,23 @@ public class PacketFurnitureLock {
Entity entity = sender.level().getEntity(msg.entityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Sender must hold a key item in either hand
boolean hasKey =
(sender.getMainHandItem().getItem() instanceof ItemMasterKey) ||
(sender.getOffhandItem().getItem() instanceof ItemMasterKey);
if (!hasKey) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} does not hold a key item in either hand",
sender.getName().getString()
);
// Unified authorization: range, master key, seat state, collar ownership.
// Shared with EntityFurniture.interact() via FurnitureAuthPredicate to
// prevent the security drift found in the 2026-04-17 audit.
if (
!com.tiedup.remake.v2.furniture.FurnitureAuthPredicate.canLockUnlock(
sender,
entity,
msg.seatId
)
) {
return;
}
// Validate the seat exists and is lockable
// Retrieve seat metadata for the downstream feedback/animation logic
SeatDefinition seat = findSeatById(provider, msg.seatId);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' not found on entity {}",
msg.seatId,
msg.entityId
);
return;
}
if (!seat.lockable()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not lockable",
msg.seatId
);
return;
}
// Seat must be occupied (someone sitting in it)
if (!isSeatOccupied(provider, entity, msg.seatId)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not occupied",
msg.seatId
);
return;
}
if (seat == null) return;
// Toggle the lock state
boolean wasLocked = provider.isSeatLocked(msg.seatId);
@@ -158,13 +134,17 @@ public class PacketFurnitureLock {
}
}
// Set lock/unlock animation state. The next updateAnimState() call
// (from tick or passenger change) will reset it to OCCUPIED/IDLE.
// Use setTransitionState (not setAnimState) so the LOCKING /
// UNLOCKING pose survives past one tick — otherwise the tick-
// decrement path in EntityFurniture reverts to transitionTarget
// before any client observes the transient state.
boolean nowLocked = !wasLocked;
furniture.setAnimState(
furniture.setTransitionState(
nowLocked
? EntityFurniture.STATE_LOCKING
: EntityFurniture.STATE_UNLOCKING
: EntityFurniture.STATE_UNLOCKING,
20,
EntityFurniture.STATE_OCCUPIED
);
// Broadcast updated state to all tracking clients

View File

@@ -0,0 +1,710 @@
package com.tiedup.remake.client.gltf;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link GlbParserUtils}.
*
* <p>Covers the parser safety surface introduced to defeat the OOM / silent-drift
* bugs found in the 2026-04-17 audit:</p>
* <ul>
* <li>{@link GlbParserUtils#readGlbSafely(java.io.InputStream, String)} header cap</li>
* <li>{@link GlbParserUtils#readChunkLength(java.nio.ByteBuffer, String, String)} bounds</li>
* <li>{@link GlbParserUtils#normalizeWeights(float[])} correctness</li>
* <li>{@link GlbParserUtils#clampJointIndices(int[], int)} contract</li>
* </ul>
*/
class GlbParserUtilsTest {
/**
* Build a minimum-size GLB header: 4B magic, 4B version, 4B totalLength.
* Returns the 12 bytes; no chunks follow.
*/
private static byte[] minimalHeader(int totalLength) {
ByteBuffer buf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
buf.putInt(GlbParserUtils.GLB_MAGIC);
buf.putInt(GlbParserUtils.GLB_VERSION);
buf.putInt(totalLength);
return buf.array();
}
@Nested
@DisplayName("readGlbSafely — OOM/truncation guards")
class ReadGlbSafelyTests {
@Test
@DisplayName("rejects files exceeding MAX_GLB_SIZE cap")
void rejectsOversized() {
// Claim a totalLength 10x the cap, but only provide 12 bytes of header.
byte[] header = minimalHeader(GlbParserUtils.MAX_GLB_SIZE + 1);
IOException ex = assertThrows(IOException.class, () ->
GlbParserUtils.readGlbSafely(
new ByteArrayInputStream(header),
"hostile.glb"
)
);
assertTrue(
ex.getMessage().contains("exceeds cap"),
"message should mention cap: " + ex.getMessage()
);
}
@Test
@DisplayName("rejects negative totalLength")
void rejectsNegativeTotalLength() {
byte[] header = minimalHeader(-1);
IOException ex = assertThrows(IOException.class, () ->
GlbParserUtils.readGlbSafely(
new ByteArrayInputStream(header),
"negative.glb"
)
);
// Either the "too small" or "exceeds cap" path is acceptable —
// what matters is that negative lengths don't slip through.
assertTrue(
ex.getMessage().contains("too small") ||
ex.getMessage().contains("exceeds cap"),
"message should reject negative length: " + ex.getMessage()
);
}
@Test
@DisplayName("rejects wrong magic bytes")
void rejectsWrongMagic() {
ByteBuffer buf = ByteBuffer.allocate(12).order(
ByteOrder.LITTLE_ENDIAN
);
buf.putInt(0xDEADBEEF); // wrong magic
buf.putInt(GlbParserUtils.GLB_VERSION);
buf.putInt(100);
IOException ex = assertThrows(IOException.class, () ->
GlbParserUtils.readGlbSafely(
new ByteArrayInputStream(buf.array()),
"junk.glb"
)
);
assertTrue(
ex.getMessage().contains("Not a GLB"),
"message should reject bad magic: " + ex.getMessage()
);
}
@Test
@DisplayName("rejects wrong version")
void rejectsWrongVersion() {
ByteBuffer buf = ByteBuffer.allocate(12).order(
ByteOrder.LITTLE_ENDIAN
);
buf.putInt(GlbParserUtils.GLB_MAGIC);
buf.putInt(999); // future version
buf.putInt(100);
IOException ex = assertThrows(IOException.class, () ->
GlbParserUtils.readGlbSafely(
new ByteArrayInputStream(buf.array()),
"future.glb"
)
);
assertTrue(
ex.getMessage().contains("Unsupported GLB version"),
"message should mention version: " + ex.getMessage()
);
}
@Test
@DisplayName("rejects truncated body (totalLength promises more than stream provides)")
void rejectsTruncatedBody() {
// Claim 100 bytes total, but only supply the 12-byte header.
byte[] header = minimalHeader(100);
IOException ex = assertThrows(IOException.class, () ->
GlbParserUtils.readGlbSafely(
new ByteArrayInputStream(header),
"truncated.glb"
)
);
assertTrue(
ex.getMessage().contains("truncated"),
"message should mention truncation: " + ex.getMessage()
);
}
}
@Nested
@DisplayName("readChunkLength — buffer bounds")
class ReadChunkLengthTests {
@Test
@DisplayName("rejects negative length")
void rejectsNegativeLength() {
ByteBuffer buf = ByteBuffer.allocate(8).order(
ByteOrder.LITTLE_ENDIAN
);
buf.putInt(-5); // negative length
buf.putInt(GlbParserUtils.CHUNK_JSON); // type follows, space reserved
buf.position(0);
assertThrows(IOException.class, () ->
GlbParserUtils.readChunkLength(buf, "JSON", "bad.glb")
);
}
@Test
@DisplayName("rejects length exceeding remaining")
void rejectsOverflowingLength() {
// Buffer capacity 16: 4B length + 4B type + 8B payload.
// Claim length=100 which doesn't fit.
ByteBuffer buf = ByteBuffer.allocate(16).order(
ByteOrder.LITTLE_ENDIAN
);
buf.putInt(100);
buf.putInt(GlbParserUtils.CHUNK_JSON);
buf.position(0);
assertThrows(IOException.class, () ->
GlbParserUtils.readChunkLength(buf, "JSON", "overflow.glb")
);
}
@Test
@DisplayName("accepts valid length")
void acceptsValidLength() throws IOException {
ByteBuffer buf = ByteBuffer.allocate(16).order(
ByteOrder.LITTLE_ENDIAN
);
buf.putInt(8); // 8 bytes payload (will follow)
buf.putInt(GlbParserUtils.CHUNK_JSON);
buf.position(0);
// 4 (type) + 8 (payload) == 12 remaining after length read; len=8 must
// be <= remaining-4 = 8. Passes.
int len = GlbParserUtils.readChunkLength(buf, "JSON", "ok.glb");
assertEquals(8, len);
}
}
@Nested
@DisplayName("normalizeWeights")
class NormalizeWeightsTests {
@Test
@DisplayName("normalizes a tuple summing to < 1.0")
void normalizesLowSum() {
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f }; // sum 0.9
GlbParserUtils.normalizeWeights(w);
float sum = w[0] + w[1] + w[2] + w[3];
assertEquals(1.0f, sum, 1.0e-5f);
}
@Test
@DisplayName("normalizes a tuple summing to > 1.0")
void normalizesHighSum() {
float[] w = { 0.6f, 0.4f, 0.2f, 0.0f }; // sum 1.2
GlbParserUtils.normalizeWeights(w);
float sum = w[0] + w[1] + w[2] + w[3];
assertEquals(1.0f, sum, 1.0e-5f);
}
@Test
@DisplayName("leaves already-normalized tuple within ε untouched")
void leavesNormalizedUntouched() {
float[] w = { 0.4f, 0.3f, 0.2f, 0.1f }; // sum exactly 1.0
float[] orig = w.clone();
GlbParserUtils.normalizeWeights(w);
assertArrayEquals(orig, w, 1.0e-6f);
}
@Test
@DisplayName("leaves zero-sum tuple alone (un-skinned vertex)")
void leavesZeroSumAlone() {
float[] w = { 0.0f, 0.0f, 0.0f, 0.0f };
GlbParserUtils.normalizeWeights(w);
float sum = w[0] + w[1] + w[2] + w[3];
assertEquals(0.0f, sum, 1.0e-6f);
}
@Test
@DisplayName("processes multiple tuples independently")
void multipleTuples() {
float[] w = {
0.5f, 0.3f, 0.1f, 0.0f, // tuple 1: sum 0.9
0.6f, 0.4f, 0.2f, 0.0f, // tuple 2: sum 1.2
0.4f, 0.3f, 0.2f, 0.1f, // tuple 3: sum 1.0 (untouched)
};
GlbParserUtils.normalizeWeights(w);
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
assertEquals(1.0f, w[4] + w[5] + w[6] + w[7], 1.0e-5f);
assertEquals(1.0f, w[8] + w[9] + w[10] + w[11], 1.0e-5f);
}
@Test
@DisplayName("tolerates non-multiple-of-4 length (processes well-formed prefix, ignores tail)")
void nonMultipleOfFourLength() {
// 7 elements: one complete tuple + 3 orphan weights.
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f, 0.9f, 0.9f, 0.9f };
GlbParserUtils.normalizeWeights(w);
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
// Trailing elements should be untouched (not normalized).
assertEquals(0.9f, w[4], 1.0e-6f);
assertEquals(0.9f, w[5], 1.0e-6f);
assertEquals(0.9f, w[6], 1.0e-6f);
}
@Test
@DisplayName("null array is a safe no-op")
void nullArrayIsSafe() {
GlbParserUtils.normalizeWeights(null);
// No exception = pass.
}
}
@Nested
@DisplayName("isPlayerMesh / stripArmaturePrefix")
class NamingConventionTests {
@Test
@DisplayName("isPlayerMesh: exact name 'Player' matches")
void isPlayerExact() {
assertTrue(GlbParserUtils.isPlayerMesh("Player"));
}
@Test
@DisplayName("isPlayerMesh: prefixed name 'Player_main' matches (startsWith)")
void isPlayerPrefixed() {
assertTrue(GlbParserUtils.isPlayerMesh("Player_main"));
assertTrue(GlbParserUtils.isPlayerMesh("Player_left"));
assertTrue(GlbParserUtils.isPlayerMesh("Player_foo"));
}
@Test
@DisplayName("isPlayerMesh: non-Player name rejected")
void isPlayerRejectsItem() {
assertFalse(GlbParserUtils.isPlayerMesh("Item"));
assertFalse(GlbParserUtils.isPlayerMesh(""));
assertFalse(GlbParserUtils.isPlayerMesh("player")); // lowercase
assertFalse(GlbParserUtils.isPlayerMesh("MyMesh"));
}
@Test
@DisplayName("isPlayerMesh: null is safe and rejected")
void isPlayerNullSafe() {
assertFalse(GlbParserUtils.isPlayerMesh(null));
}
@Test
@DisplayName("stripArmaturePrefix: 'MyRig|bone' → 'bone'")
void stripsPipe() {
assertEquals("bone", GlbParserUtils.stripArmaturePrefix("MyRig|bone"));
}
@Test
@DisplayName("stripArmaturePrefix: name without pipe is unchanged")
void noPipeUnchanged() {
assertEquals("body", GlbParserUtils.stripArmaturePrefix("body"));
}
@Test
@DisplayName("stripArmaturePrefix: multiple pipes → keep everything after LAST")
void multiplePipesLastWins() {
assertEquals("C", GlbParserUtils.stripArmaturePrefix("A|B|C"));
}
@Test
@DisplayName("stripArmaturePrefix: prefix-only '|body' → 'body'")
void prefixOnly() {
assertEquals("body", GlbParserUtils.stripArmaturePrefix("|body"));
}
@Test
@DisplayName("stripArmaturePrefix: trailing pipe 'body|' → empty string")
void trailingPipe() {
assertEquals("", GlbParserUtils.stripArmaturePrefix("body|"));
}
@Test
@DisplayName("stripArmaturePrefix: null returns null")
void nullReturnsNull() {
assertNull(GlbParserUtils.stripArmaturePrefix(null));
}
}
@Nested
@DisplayName("readRestRotation / readRestTranslation")
class ReadRestPoseTests {
private JsonObject parseNode(String json) {
return JsonParser.parseString(json).getAsJsonObject();
}
@Test
@DisplayName("empty node returns identity quaternion")
void emptyRotation() {
Quaternionf q = GlbParserUtils.readRestRotation(parseNode("{}"));
assertEquals(0.0f, q.x, 1.0e-6f);
assertEquals(0.0f, q.y, 1.0e-6f);
assertEquals(0.0f, q.z, 1.0e-6f);
assertEquals(1.0f, q.w, 1.0e-6f);
}
@Test
@DisplayName("empty node returns zero vector")
void emptyTranslation() {
Vector3f t = GlbParserUtils.readRestTranslation(parseNode("{}"));
assertEquals(0.0f, t.x, 1.0e-6f);
assertEquals(0.0f, t.y, 1.0e-6f);
assertEquals(0.0f, t.z, 1.0e-6f);
}
@Test
@DisplayName("node with rotation reads all four components in XYZW order")
void readsRotation() {
Quaternionf q = GlbParserUtils.readRestRotation(
parseNode("{\"rotation\": [0.1, 0.2, 0.3, 0.9]}")
);
assertEquals(0.1f, q.x, 1.0e-6f);
assertEquals(0.2f, q.y, 1.0e-6f);
assertEquals(0.3f, q.z, 1.0e-6f);
assertEquals(0.9f, q.w, 1.0e-6f);
}
@Test
@DisplayName("node with translation reads all three components")
void readsTranslation() {
Vector3f t = GlbParserUtils.readRestTranslation(
parseNode("{\"translation\": [1.5, -2.5, 3.0]}")
);
assertEquals(1.5f, t.x, 1.0e-6f);
assertEquals(-2.5f, t.y, 1.0e-6f);
assertEquals(3.0f, t.z, 1.0e-6f);
}
@Test
@DisplayName("null node returns default values (no NPE)")
void nullNode() {
Quaternionf q = GlbParserUtils.readRestRotation(null);
Vector3f t = GlbParserUtils.readRestTranslation(null);
assertEquals(1.0f, q.w, 1.0e-6f);
assertEquals(0.0f, t.x, 1.0e-6f);
}
}
@Nested
@DisplayName("buildParentJointIndices")
class BuildParentJointIndicesTests {
/** Helper to build a JsonArray of node-like JsonObjects. */
private JsonArray parseNodes(String json) {
return JsonParser.parseString(json).getAsJsonArray();
}
@Test
@DisplayName("flat skeleton (no children) → all roots")
void flatSkeleton() {
JsonArray nodes = parseNodes("[{}, {}, {}]");
int[] nodeToJoint = { 0, 1, 2 };
int[] parents = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
3
);
assertArrayEquals(new int[] { -1, -1, -1 }, parents);
}
@Test
@DisplayName("simple tree: root with two children")
void simpleTree() {
// Node 0: root with children [1, 2]
// Node 1: leaf
// Node 2: leaf
JsonArray nodes = parseNodes(
"[{\"children\": [1, 2]}, {}, {}]"
);
int[] nodeToJoint = { 0, 1, 2 };
int[] parents = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
3
);
assertEquals(-1, parents[0]); // root
assertEquals(0, parents[1]);
assertEquals(0, parents[2]);
}
@Test
@DisplayName("nested tree: root → child → grandchild")
void nestedTree() {
JsonArray nodes = parseNodes(
"[{\"children\": [1]}, {\"children\": [2]}, {}]"
);
int[] nodeToJoint = { 0, 1, 2 };
int[] parents = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
3
);
assertArrayEquals(new int[] { -1, 0, 1 }, parents);
}
@Test
@DisplayName("non-joint node in hierarchy is skipped")
void nonJointSkipped() {
// Nodes 0 is non-joint (-1 mapping), 1 and 2 are joints.
// Node 0 children = [1, 2] but node 0 is not a joint → no parent set.
JsonArray nodes = parseNodes(
"[{\"children\": [1, 2]}, {}, {}]"
);
int[] nodeToJoint = { -1, 0, 1 };
int[] parents = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
2
);
assertArrayEquals(new int[] { -1, -1 }, parents);
}
}
@Nested
@DisplayName("parseAnimation")
class ParseAnimationTests {
/** Build a minimal in-memory GLB accessor layout: one FLOAT SCALAR, one VEC4. */
private JsonObject parseJson(String s) {
return JsonParser.parseString(s).getAsJsonObject();
}
@Test
@DisplayName("animation with no matching channels returns null")
void noMatchingChannels() {
// Channel targets node 5, but nodeToJoint only covers 0-2.
JsonObject anim = parseJson(
"{" +
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 5, \"path\": \"rotation\"}}]," +
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
"}"
);
JsonArray accessors = parseJson(
"{\"a\":[{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0}]}"
).getAsJsonArray("a");
JsonArray bufferViews = parseJson(
"{\"b\":[{\"byteLength\":4,\"byteOffset\":0}]}"
).getAsJsonArray("b");
ByteBuffer bin = ByteBuffer.allocate(16).order(
ByteOrder.LITTLE_ENDIAN
);
int[] nodeToJoint = { 0, 1, 2 };
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
anim,
accessors,
bufferViews,
bin,
nodeToJoint,
3
);
assertNull(
clip,
"no channels target the skin joints — should return null"
);
}
@Test
@DisplayName("single rotation channel produces per-joint rotations array")
void singleRotationChannel() {
// Layout:
// accessor 0: 1 SCALAR timestamp float at byteOffset 0 (4 bytes)
// accessor 1: 1 VEC4 quat floats at byteOffset 4 (16 bytes)
// Channel: node 1 (jointIdx 1), path "rotation", sampler 0.
JsonObject anim = parseJson(
"{" +
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}]," +
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
"}"
);
JsonArray accessors = parseJson(
"{\"a\":[" +
"{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0,\"byteOffset\":0}," +
"{\"count\":1,\"componentType\":5126,\"type\":\"VEC4\",\"bufferView\":1,\"byteOffset\":0}" +
"]}"
).getAsJsonArray("a");
JsonArray bufferViews = parseJson(
"{\"b\":[" +
"{\"byteLength\":4,\"byteOffset\":0}," +
"{\"byteLength\":16,\"byteOffset\":4}" +
"]}"
).getAsJsonArray("b");
ByteBuffer bin = ByteBuffer.allocate(20).order(
ByteOrder.LITTLE_ENDIAN
);
bin.putFloat(0, 0.0f); // timestamp 0
bin.putFloat(4, 0.1f); // quat x
bin.putFloat(8, 0.2f); // quat y
bin.putFloat(12, 0.3f); // quat z
bin.putFloat(16, 0.9f); // quat w
int[] nodeToJoint = { -1, 1, -1 }; // only node 1 → joint 1
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
anim,
accessors,
bufferViews,
bin,
nodeToJoint,
2
);
assertNotNull(clip);
assertEquals(1, clip.frameCount());
assertArrayEquals(new float[] { 0.0f }, clip.timestamps(), 1.0e-6f);
Quaternionf[][] rots = clip.rotations();
assertEquals(2, rots.length);
assertNull(rots[0], "joint 0 has no rotation channel");
assertNotNull(rots[1]);
assertEquals(0.1f, rots[1][0].x, 1.0e-6f);
assertEquals(0.9f, rots[1][0].w, 1.0e-6f);
}
}
@Nested
@DisplayName("convertMeshToMinecraftSpace")
class ConvertMeshTests {
@Test
@DisplayName("negates X and Y on positions, normals, rest translations")
void negatesSpatialData() {
float[] positions = { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
float[] normals = { 0.1f, 0.2f, 0.3f };
Vector3f[] restT = { new Vector3f(7.0f, 8.0f, 9.0f) };
Quaternionf[] restR = { new Quaternionf(0.1f, 0.2f, 0.3f, 0.9f) };
Matrix4f[] ibm = { new Matrix4f().identity() };
GlbParserUtils.convertMeshToMinecraftSpace(
positions,
normals,
restT,
restR,
ibm
);
assertArrayEquals(
new float[] { -1.0f, -2.0f, 3.0f, -4.0f, -5.0f, 6.0f },
positions,
1.0e-6f
);
assertArrayEquals(
new float[] { -0.1f, -0.2f, 0.3f },
normals,
1.0e-6f
);
assertEquals(-7.0f, restT[0].x, 1.0e-6f);
assertEquals(-8.0f, restT[0].y, 1.0e-6f);
assertEquals(9.0f, restT[0].z, 1.0e-6f);
assertEquals(-0.1f, restR[0].x, 1.0e-6f);
assertEquals(-0.2f, restR[0].y, 1.0e-6f);
assertEquals(0.3f, restR[0].z, 1.0e-6f);
assertEquals(0.9f, restR[0].w, 1.0e-6f);
}
@Test
@DisplayName("IBM transform C·M·C inverts itself: applying twice returns original")
void ibmDoubleInvertsToIdentity() {
Matrix4f orig = new Matrix4f().rotateY(0.5f).translate(1f, 2f, 3f);
Matrix4f ibm = new Matrix4f(orig);
GlbParserUtils.convertMeshToMinecraftSpace(
new float[0],
new float[0],
new Vector3f[0],
new Quaternionf[0],
new Matrix4f[] { ibm }
);
GlbParserUtils.convertMeshToMinecraftSpace(
new float[0],
new float[0],
new Vector3f[0],
new Quaternionf[0],
new Matrix4f[] { ibm }
);
// C·C = identity, so C·(C·M·C)·C = M
float[] origVals = new float[16];
float[] ibmVals = new float[16];
orig.get(origVals);
ibm.get(ibmVals);
assertArrayEquals(origVals, ibmVals, 1.0e-5f);
}
@Test
@DisplayName("empty arrays are a safe no-op")
void emptyArraysSafe() {
GlbParserUtils.convertMeshToMinecraftSpace(
new float[0],
new float[0],
new Vector3f[0],
new Quaternionf[0],
new Matrix4f[0]
);
}
}
@Nested
@DisplayName("clampJointIndices")
class ClampJointIndicesTests {
@Test
@DisplayName("clamps out-of-range positive indices to 0")
void clampsPositiveOutOfRange() {
int[] joints = { 0, 1, 999, 5 };
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
assertArrayEquals(new int[] { 0, 1, 0, 5 }, joints);
assertEquals(1, clamped);
}
@Test
@DisplayName("clamps negative indices to 0")
void clampsNegatives() {
int[] joints = { 0, -5, 3, -1 };
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
assertArrayEquals(new int[] { 0, 0, 3, 0 }, joints);
assertEquals(2, clamped);
}
@Test
@DisplayName("all-valid array: no clamps, returns 0")
void allValidNoClamp() {
int[] joints = { 0, 1, 2, 3 };
int[] orig = joints.clone();
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
assertArrayEquals(orig, joints);
assertEquals(0, clamped);
}
@Test
@DisplayName("null array is a safe no-op, returns 0")
void nullArrayIsSafe() {
int clamped = GlbParserUtils.clampJointIndices(null, 10);
assertEquals(0, clamped);
}
@Test
@DisplayName("jointCount boundary: index == jointCount is out-of-range")
void boundaryEqualIsOutOfRange() {
int[] joints = { 0, 9, 10 }; // 10 is OOB when jointCount=10
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
assertArrayEquals(new int[] { 0, 9, 0 }, joints);
assertEquals(1, clamped);
}
}
}

View File

@@ -0,0 +1,147 @@
package com.tiedup.remake.client.gltf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link GltfPoseConverter#computeEndTick}.
*
* <p>This is the public entry point that controls how many ticks a GLB animation
* occupies on the PlayerAnimator timeline. The P0-01 fix replaced a hardcoded
* {@code endTick = 1} (which silently froze every animation to frame 0) with a
* timestamp-derived computation. These tests pin down that behavior so a future
* refactor can't silently re-introduce the bug.</p>
*
* <p>Full end-to-end tests (keyframe emission into a {@code KeyframeAnimation}
* builder, multi-item composite, selective-part enabling) require instantiating
* a complete {@code GltfData} and the PlayerAnimator library — deferred to a
* GameTest or a later harness upgrade.</p>
*/
class GltfPoseConverterTest {
private static final int TICKS_PER_SECOND = 20;
private static GltfData.AnimationClip clip(float... timestamps) {
int n = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[1][n];
Vector3f[][] translations = new Vector3f[1][n];
for (int i = 0; i < n; i++) {
rotations[0][i] = new Quaternionf(); // identity
translations[0][i] = new Vector3f();
}
return new GltfData.AnimationClip(timestamps, rotations, translations, n);
}
@Nested
@DisplayName("computeEndTick — timestamp → tick conversion")
class ComputeEndTickTests {
@Test
@DisplayName("null clip returns 1 (minimum valid endTick)")
void nullClipReturnsOne() {
assertEquals(1, GltfPoseConverter.computeEndTick(null));
}
@Test
@DisplayName("zero-frame clip returns 1")
void zeroFrameReturnsOne() {
GltfData.AnimationClip c = new GltfData.AnimationClip(
new float[0],
new Quaternionf[0][],
null,
0
);
assertEquals(1, GltfPoseConverter.computeEndTick(c));
}
@Test
@DisplayName("single-frame clip at t=0 returns 1")
void singleFrameAtZero() {
assertEquals(1, GltfPoseConverter.computeEndTick(clip(0.0f)));
}
@Test
@DisplayName("3 frames at 0/0.5/1.0s returns 20 ticks")
void threeFramesOneSecondSpan() {
// Last timestamp 1.0s → 1.0 * 20 = 20 ticks.
assertEquals(20, GltfPoseConverter.computeEndTick(clip(0f, 0.5f, 1.0f)));
}
@Test
@DisplayName("baseline normalization: timestamps 0.5/1.0/1.5s → endTick 20 (span 1s)")
void baselineNormalizesToSpan() {
// If baseline wasn't subtracted, this would return round(1.5*20)=30.
// With baseline=0.5, endTick = round((1.5-0.5)*20) = 20.
assertEquals(
20,
GltfPoseConverter.computeEndTick(clip(0.5f, 1.0f, 1.5f))
);
}
@Test
@DisplayName("single-frame clip at t=3.0 still returns 1 (baseline collapses to zero)")
void singleFrameAtLargeTime() {
// times[0] = 3.0, baseline = 3.0, (3.0 - 3.0) * 20 = 0, clamped to 1.
assertEquals(1, GltfPoseConverter.computeEndTick(clip(3.0f)));
}
@Test
@DisplayName("long clip: 2.5s span returns 50 ticks")
void longClipTwoAndHalfSeconds() {
GltfData.AnimationClip c = clip(0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f);
assertEquals(50, GltfPoseConverter.computeEndTick(c));
}
@Test
@DisplayName("rounding: last timestamp 0.025s (< 0.5 tick) still yields at least 1 tick")
void subTickTimestampClampsToOne() {
// round(0.025 * 20) = round(0.5) = 0 or 1 depending on rounding mode.
// Math.round(float) returns (int)Math.floor(x+0.5) = 1 here.
// But even if it were 0, the Math.max(1, ...) clamp should protect us.
int endTick = GltfPoseConverter.computeEndTick(clip(0f, 0.025f));
assertEquals(
1,
endTick,
"Very short spans must collapse to endTick=1, not 0 (would break PlayerAnimator)"
);
}
@Test
@DisplayName("high-FPS source preserves total span: 60 frames over 2s → 40 ticks")
void highFpsPreservesSpan() {
// 60 evenly-spaced frames from 0.0 to 2.0s.
float[] times = new float[60];
for (int i = 0; i < 60; i++) {
times[i] = i * (2.0f / 59.0f);
}
GltfData.AnimationClip c = clip(times);
assertEquals(
Math.round(2.0f * TICKS_PER_SECOND),
GltfPoseConverter.computeEndTick(c)
);
}
@Test
@DisplayName("timestamps array longer than frameCount: uses min(len-1, frameCount-1)")
void timestampsLongerThanFrameCount() {
// frameCount=3 means only the first 3 timestamps matter even if array is longer.
Quaternionf[][] rotations = new Quaternionf[1][3];
rotations[0][0] = new Quaternionf();
rotations[0][1] = new Quaternionf();
rotations[0][2] = new Quaternionf();
GltfData.AnimationClip c = new GltfData.AnimationClip(
new float[] { 0f, 0.5f, 1.0f, 99.0f, 99.0f }, // 5 timestamps
rotations,
null,
3 // but only 3 active frames
);
// Last valid index = min(5-1, 3-1) = 2 → times[2] = 1.0 → 20 ticks.
assertEquals(20, GltfPoseConverter.computeEndTick(c));
}
}
}

View File

@@ -0,0 +1,214 @@
package com.tiedup.remake.v2.furniture;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for the pure-logic authorization core in {@link FurnitureAuthPredicate}.
*
* <p>These tests pin down the truth tables for the lock and force-mount gates
* without touching any Minecraft API. The MC-aware wrappers ({@code canLockUnlock},
* {@code canForceMount}) extract booleans from live entities and delegate here,
* so full-stack validation is manual (in-game).</p>
*
* <p>The 2026-04-17 audit documented three divergent auth paths (BUG-002, BUG-003)
* that this predicate now unifies. If anyone re-introduces the drift, the
* isAuthorizedForLock / isAuthorizedForForceMount methods will catch it here.</p>
*/
class FurnitureAuthPredicateTest {
@Nested
@DisplayName("isAuthorizedForLock — AND of 4 gates")
class LockAuthorization {
@Test
@DisplayName("all four gates true → authorized")
void allTrue() {
assertTrue(
FurnitureAuthPredicate.isAuthorizedForLock(
true,
true,
true,
true
)
);
}
@Test
@DisplayName("missing master key → denied")
void missingKey() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForLock(
false,
true,
true,
true
)
);
}
@Test
@DisplayName("seat not lockable → denied")
void seatNotLockable() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForLock(
true,
false,
true,
true
)
);
}
@Test
@DisplayName("seat not occupied → denied")
void seatNotOccupied() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForLock(
true,
true,
false,
true
)
);
}
@Test
@DisplayName("occupant has no collar (or sender not owner) → denied")
void noOwnedCollar() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForLock(
true,
true,
true,
false
)
);
}
@Test
@DisplayName("all gates false → denied")
void allFalse() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForLock(
false,
false,
false,
false
)
);
}
}
@Nested
@DisplayName("isAuthorizedForForceMount — AND of 5 gates")
class ForceMountAuthorization {
@Test
@DisplayName("all five gates true → authorized")
void allTrue() {
assertTrue(
FurnitureAuthPredicate.isAuthorizedForForceMount(
true,
true,
true,
true,
true
)
);
}
@Test
@DisplayName("captive not alive → denied")
void captiveDead() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
false,
true,
true,
true,
true
)
);
}
@Test
@DisplayName("captive out of range → denied")
void captiveOutOfRange() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
true,
false,
true,
true,
true
)
);
}
@Test
@DisplayName("no owned collar → denied")
void noOwnedCollar() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
true,
true,
false,
true,
true
)
);
}
@Test
@DisplayName("captive not tied up (leashed) → denied (audit BUG-003)")
void notTiedUp() {
// This is the exact regression guard for audit BUG-003: before the
// fix, the packet path did NOT check isTiedUp, so any collar-owning
// captor could force-mount a captive that had never been leashed.
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
true,
true,
true,
false,
true
)
);
}
@Test
@DisplayName("captive already passenger of another entity → denied (audit BUG-003)")
void alreadyPassenger() {
// BUG-003 regression guard: before the fix, the packet path used
// startRiding(force=true) which would break prior mounts silently.
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
true,
true,
true,
true,
false
)
);
}
@Test
@DisplayName("all gates false → denied")
void allFalse() {
assertFalse(
FurnitureAuthPredicate.isAuthorizedForForceMount(
false,
false,
false,
false,
false
)
);
}
}
}

View File

@@ -0,0 +1,95 @@
package com.tiedup.remake.v2.furniture;
import static org.junit.jupiter.api.Assertions.assertEquals;
import net.minecraft.world.phys.Vec3;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for the server-side fallback seat-geometry math extracted from the
* three inlined {@code EntityFurniture} call sites.
*
* <p>The formulas themselves are trivial; these tests are a regression guard
* against someone "cleaning up" the right-axis expression and inverting a
* sign, or changing the seat-spacing convention (which is observable in
* gameplay — passengers shift left/right on asymmetric furniture).</p>
*/
class FurnitureSeatGeometryTest {
private static final double EPS = 1e-9;
@Nested
@DisplayName("rightAxis — entity-local right in world XZ")
class RightAxis {
@Test
@DisplayName("yaw=0 → right axis points to world -X (MC south-facing convention)")
void yawZero() {
Vec3 r = FurnitureSeatGeometry.rightAxis(0f);
assertEquals(-1.0, r.x, EPS);
assertEquals(0.0, r.y, EPS);
assertEquals(0.0, r.z, EPS);
}
@Test
@DisplayName("yaw=180 → right axis flips to +X")
void yaw180() {
Vec3 r = FurnitureSeatGeometry.rightAxis(180f);
assertEquals(1.0, r.x, EPS);
assertEquals(0.0, r.z, EPS);
}
@Test
@DisplayName("yaw=90 → right axis points to world -Z")
void yaw90() {
Vec3 r = FurnitureSeatGeometry.rightAxis(90f);
assertEquals(0.0, r.x, EPS);
assertEquals(-1.0, r.z, EPS);
}
@Test
@DisplayName("Y is always 0 (right axis is horizontal)")
void yIsAlwaysZero() {
for (float yaw = -360f; yaw <= 360f; yaw += 37f) {
assertEquals(0.0, FurnitureSeatGeometry.rightAxis(yaw).y, EPS);
}
}
}
@Nested
@DisplayName("seatOffset — even spacing around centre")
class SeatOffset {
@Test
@DisplayName("1 seat → always 0 (centered)")
void singleSeat() {
assertEquals(0.0, FurnitureSeatGeometry.seatOffset(0, 1), EPS);
}
@Test
@DisplayName("2 seats → -0.5, 0.5")
void twoSeats() {
assertEquals(-0.5, FurnitureSeatGeometry.seatOffset(0, 2), EPS);
assertEquals(0.5, FurnitureSeatGeometry.seatOffset(1, 2), EPS);
}
@Test
@DisplayName("3 seats → -1, 0, 1")
void threeSeats() {
assertEquals(-1.0, FurnitureSeatGeometry.seatOffset(0, 3), EPS);
assertEquals(0.0, FurnitureSeatGeometry.seatOffset(1, 3), EPS);
assertEquals(1.0, FurnitureSeatGeometry.seatOffset(2, 3), EPS);
}
@Test
@DisplayName("4 seats → -1.5, -0.5, 0.5, 1.5")
void fourSeats() {
assertEquals(-1.5, FurnitureSeatGeometry.seatOffset(0, 4), EPS);
assertEquals(-0.5, FurnitureSeatGeometry.seatOffset(1, 4), EPS);
assertEquals(0.5, FurnitureSeatGeometry.seatOffset(2, 4), EPS);
assertEquals(1.5, FurnitureSeatGeometry.seatOffset(3, 4), EPS);
}
}
}