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:
19
build.gradle
19
build.gradle
@@ -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.
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–5 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.98–1.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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +173,8 @@ public class SettingsAccessor {
|
||||
* <li>"beam_cuffs" -> "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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user