From 355e2936c90cbdc54d9eb97b1a3b48b5d75b7fab Mon Sep 17 00:00:00 2001 From: NotEvil Date: Sat, 18 Apr 2026 17:34:03 +0200 Subject: [PATCH] Refactor V2 animation, furniture, and GLTF rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broad consolidation of the V2 bondage-item, furniture-entity, and client-side GLTF pipeline. Parsing and rendering - Shared GLB parsing helpers consolidated into GlbParserUtils (accessor reads, weight normalization, joint-index clamping, coordinate-space conversion, animation parse, primitive loop). - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and GltfLiveBoneReader — removes per-frame joint-matrix allocation from the render hot path. - emitVertex helper dedups three parallel loops in GltfMeshRenderer. - TintColorResolver.resolve has a zero-alloc path when the item declares no tint channels. - itemAnimCache bounded to 256 entries (access-order LRU) with atomic get-or-compute under the map's monitor. Animation correctness - First-in-joint-order wins when body and torso both map to the same PlayerAnimator slot; duplicate writes log a single WARN. - Multi-item composites honor the FullX / FullHeadX opt-in that the single-item path already recognized. - Seat transforms converted to Minecraft model-def space so asymmetric furniture renders passengers at the correct offset. - GlbValidator: IBM count / type / presence, JOINTS_0 presence, animation channel target validation, multi-skin support. Furniture correctness and anti-exploit - Seat assignment synced via SynchedEntityData (server is authoritative; eliminates client-server divergence on multi-seat). - Force-mount authorization requires same dimension and a free seat; cross-dimension distance checks rejected. - Reconnection on login checks for seat takeover before re-mount and force-loads the target chunk for cross-dimension cases. - tiedup_furniture_lockpick_ctx carries a session UUID nonce so stale context can't misroute a body-item lockpick. - tiedup_locked_furniture survives death without keepInventory (Forge 1.20.1 does not auto-copy persistent data on respawn). Lifecycle and memory - EntityCleanupHandler fans EntityLeaveLevelEvent out to every per-entity state map on the client. - DogPoseRenderHandler re-keyed by UUID (stable across dimension change; entity int ids are recycled). - PetBedRenderHandler, PlayerArmHideEventHandler, and HeldItemHideHandler use receiveCanceled + sentinel sets so Pre-time mutations are restored even when a downstream handler cancels the render. Tests - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter, FurnitureSeatGeometry, and FurnitureAuthPredicate. --- build.gradle | 19 + docs/ARTIST_GUIDE.md | 76 +- .../animation/AnimationStateRegistry.java | 22 +- .../animation/BondageAnimationManager.java | 87 +- .../render/DogPoseRenderHandler.java | 36 +- .../render/FirstPersonHandHideHandler.java | 36 +- .../animation/render/HeldItemHideHandler.java | 24 +- .../animation/render/PetBedRenderHandler.java | 69 +- .../render/PlayerArmHideEventHandler.java | 57 +- .../animation/render/RenderConstants.java | 6 +- .../animation/tick/AnimationTickHandler.java | 82 +- .../animation/tick/MCAAnimationTickCache.java | 16 +- .../tick/NpcAnimationTickHandler.java | 92 +- .../client/events/EntityCleanupHandler.java | 45 +- .../tiedup/remake/client/gltf/GlbParser.java | 501 ++--------- .../remake/client/gltf/GlbParserUtils.java | 800 +++++++++++++++++- .../client/gltf/GltfAnimationApplier.java | 164 ++-- .../tiedup/remake/client/gltf/GltfCache.java | 28 +- .../remake/client/gltf/GltfClientSetup.java | 66 +- .../client/gltf/GltfLiveBoneReader.java | 55 +- .../remake/client/gltf/GltfMeshRenderer.java | 223 ++--- .../remake/client/gltf/GltfPoseConverter.java | 638 ++++++++------ .../remake/client/gltf/GltfSkinCache.java | 121 --- .../client/gltf/GltfSkinningEngine.java | 117 ++- .../client/gltf/diagnostic/GlbValidator.java | 212 ++++- .../tiedup/remake/core/SettingsAccessor.java | 8 +- .../ai/master/MasterFollowPlayerGoal.java | 5 +- .../lifecycle/CapabilityEventHandler.java | 23 + .../lifecycle/PlayerDisconnectHandler.java | 13 + .../remake/mixin/client/MixinPlayerModel.java | 4 +- .../MixinVillagerEntityBaseModelMCA.java | 7 +- .../remake/network/NetworkEventHandler.java | 40 +- .../PacketEndConversationC2S.java | 6 +- .../merchant/PacketCloseMerchantScreen.java | 6 +- .../minigame/PacketLockpickAttempt.java | 32 +- .../tiedup/remake/state/PlayerBindState.java | 4 +- .../capability/V2BondageEquipment.java | 42 +- .../v2/bondage/client/TintColorResolver.java | 43 +- .../bondage/client/V2BondageRenderLayer.java | 30 +- .../datadriven/DataDrivenItemDefinition.java | 3 - .../datadriven/DataDrivenItemParser.java | 9 - .../datadriven/DataDrivenItemRegistry.java | 21 + .../network/PacketSyncV2Equipment.java | 11 +- .../v2/client/DataDrivenIconOverrides.java | 60 +- .../remake/v2/client/ObjBlockRenderer.java | 7 + .../remake/v2/client/V2ClientSetup.java | 82 ++ .../remake/v2/furniture/EntityFurniture.java | 287 +++++-- .../v2/furniture/FurnitureAuthPredicate.java | 317 +++++++ .../remake/v2/furniture/FurnitureParser.java | 12 +- .../v2/furniture/FurnitureRegistry.java | 18 + .../v2/furniture/FurnitureSeatGeometry.java | 51 ++ .../remake/v2/furniture/ISeatProvider.java | 9 + .../furniture/client/FurnitureGlbParser.java | 795 +++-------------- .../furniture/client/FurnitureGltfCache.java | 41 +- .../furniture/client/FurnitureGltfData.java | 9 +- .../client/PlayerArmatureScanner.java | 279 ++++++ .../network/PacketFurnitureEscape.java | 41 +- .../network/PacketFurnitureForcemount.java | 58 +- .../network/PacketFurnitureLock.java | 60 +- .../client/gltf/GlbParserUtilsTest.java | 710 ++++++++++++++++ .../client/gltf/GltfPoseConverterTest.java | 147 ++++ .../furniture/FurnitureAuthPredicateTest.java | 214 +++++ .../furniture/FurnitureSeatGeometryTest.java | 95 +++ 63 files changed, 4965 insertions(+), 2226 deletions(-) delete mode 100644 src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java create mode 100644 src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java create mode 100644 src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometry.java create mode 100644 src/main/java/com/tiedup/remake/v2/furniture/client/PlayerArmatureScanner.java create mode 100644 src/test/java/com/tiedup/remake/client/gltf/GlbParserUtilsTest.java create mode 100644 src/test/java/com/tiedup/remake/client/gltf/GltfPoseConverterTest.java create mode 100644 src/test/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicateTest.java create mode 100644 src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometryTest.java diff --git a/build.gradle b/build.gradle index eff6a43..d14fb18 100644 --- a/build.gradle +++ b/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. diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md index dad2dc5..abbe78b 100644 --- a/docs/ARTIST_GUIDE.md +++ b/docs/ARTIST_GUIDE.md @@ -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 ''` | 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.). diff --git a/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java index bdfb1cd..cb2c794 100644 --- a/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java +++ b/src/main/java/com/tiedup/remake/client/animation/AnimationStateRegistry.java @@ -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. * - *

Holds per-player state maps that were previously scattered across - * AnimationTickHandler. Provides a single clearAll() entry point for - * world unload cleanup. + *

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.

*/ @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 lastTiedState = new ConcurrentHashMap<>(); - /** Track last animation ID per player to avoid redundant updates */ - static final Map lastAnimId = new ConcurrentHashMap<>(); - private AnimationStateRegistry() {} public static Map getLastTiedState() { return lastTiedState; } - public static Map 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(); diff --git a/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java index fc0ed3a..bf54669 100644 --- a/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java +++ b/src/main/java/com/tiedup/remake/client/animation/BondageAnimationManager.java @@ -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 layerFailureLogged = + java.util.concurrent.ConcurrentHashMap.newKeySet(); + /** * Get the animation layer for a player from PlayerAnimationAccess. + * + *

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.

*/ @SuppressWarnings("unchecked") private static ModifierLayer 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 layer = getFurnitureLayer(player); + ModifierLayer 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. + * + *

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.

+ */ + @SuppressWarnings("unchecked") + @javax.annotation.Nullable + private static ModifierLayer getOrCreateFurnitureLayer( + Player player + ) { + if (player instanceof AbstractClientPlayer clientPlayer) { + try { + ModifierLayer 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 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"); } } diff --git a/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java index 0c77dd7..424595c 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/DogPoseRenderHandler.java @@ -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 dogPoseState = - new Int2ObjectOpenHashMap<>(); + private static final Map 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 diff --git a/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java index f00dd18..195f852 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/FirstPersonHandHideHandler.java @@ -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) + *

Behavior:

+ *
    + *
  • Tied up (legacy V1 state): hide hands completely — hands are behind back
  • + *
  • Mittens (legacy V1 item): hide hands + items (Forge limitation: RenderHandEvent + * controls hand + item together)
  • + *
  • V2 item in HANDS or ARMS region: 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.
  • + *
+ * + *

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.

*/ @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); } } diff --git a/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java index 01b60e0..3c9248e 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/HeldItemHideHandler.java @@ -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 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(); + } } diff --git a/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java index 289fb19..bb9082e 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/PetBedRenderHandler.java @@ -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). * *

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

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 FORCED_POSE_PLAYERS = + ConcurrentHashMap.newKeySet(); + + /** + * Before player render: Apply vertical offset and forced pose for pet bed. + * + *

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.

+ */ + @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. + * + *

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.

*/ - @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(); + } } diff --git a/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java b/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java index 3dd08a7..0ec5ff8 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/PlayerArmHideEventHandler.java @@ -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 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 + * + *

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

*/ - @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(); + } } diff --git a/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java b/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java index 6c6da6b..570ce03 100644 --- a/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java +++ b/src/main/java/com/tiedup/remake/client/animation/render/RenderConstants.java @@ -4,10 +4,8 @@ import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; /** - * Centralizes magic numbers used across render handlers. - * - *

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 { diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java index 4e3dd17..9df304e 100644 --- a/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/tick/AnimationTickHandler.java @@ -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 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(); diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java b/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java index 1ed9265..f65b6be 100644 --- a/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java +++ b/src/main/java/com/tiedup/remake/client/animation/tick/MCAAnimationTickCache.java @@ -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. * *

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.

*/ @OnlyIn(Dist.CLIENT) public final class MCAAnimationTickCache { - private static final Map lastTickMap = new HashMap<>(); + private static final Map 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); + } } diff --git a/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java b/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java index da664d6..5e91c81 100644 --- a/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java +++ b/src/main/java/com/tiedup/remake/client/animation/tick/NpcAnimationTickHandler.java @@ -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 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. + * + *

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.

*/ @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 { * *

Legacy fallback: if no GLB model is found, falls back to JSON-based * PlayerAnimator animations via {@link BondageAnimationManager}. + * + *

For future contributors: 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.

*/ 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); } } diff --git a/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java b/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java index 44fb94d..34e649a 100644 --- a/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java +++ b/src/main/java/com/tiedup/remake/client/events/EntityCleanupHandler.java @@ -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. - * - *

This handler automatically cleans up animation layers and pending animations - * when entities leave the world, preventing memory leaks from stale cache entries. - * - *

Phase: Performance & Memory Management - * - *

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() ); } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java b/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java index e682c57..2647886 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GlbParser.java @@ -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 allPositions = new ArrayList<>(); - List allNormals = new ArrayList<>(); - List allTexCoords = new ArrayList<>(); - List allJoints = new ArrayList<>(); - List 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 rotJoints = new ArrayList<>(); - List rotTimestamps = new ArrayList<>(); - List rotValues = new ArrayList<>(); - - List transJoints = new ArrayList<>(); - List transTimestamps = new ArrayList<>(); - List 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; - } - } - } - } - } - } } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java b/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java index 0bd3c2e..6b6ea22 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java @@ -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. + * + *

Protects downstream parsers from OOM and negative-length crashes on malformed + * or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.

+ * + * @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. + * + *

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.

+ * + *

Modifies the array in place. Call once at parse time; zero per-frame cost.

+ */ + 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). Mutates {@code joints} in place. + * 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): + *
    + *
  • {@code count >= 0} and {@code components >= 1}
  • + *
  • {@code count * components} doesn't overflow int
  • + *
  • {@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}
  • + *
+ * + * @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. + * + *

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.

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

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.

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

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.

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

Skin-specific filtering is encoded in {@code nodeToJoint}: channels + * targeting a node with a {@code -1} mapping are skipped.

+ * + * @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 rotJoints = new java.util.ArrayList<>(); + java.util.List rotTimestamps = new java.util.ArrayList<>(); + java.util.List rotValues = new java.util.ArrayList<>(); + + java.util.List transJoints = new java.util.ArrayList<>(); + java.util.List transTimestamps = new java.util.ArrayList<>(); + java.util.List 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}. + * + *

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.

+ */ + 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 primitives; + public final int vertexCount; + + PrimitiveParseResult( + float[] positions, + float[] normals, + float[] texCoords, + int[] indices, + int[] joints, + float[] weights, + List 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. + * + *

Invariants:

+ *
    + *
  • POSITION is required. NORMAL and TEXCOORD_0 are optional and + * default to zero-filled arrays of the correct size.
  • + *
  • Per-primitive indices are offset by the running + * {@code cumulativeVertexCount} so the flat arrays index + * correctly.
  • + *
  • {@code JOINTS_0} is read, out-of-range indices clamped to 0 with + * a WARN log (once per file, via {@link #clampJointIndices}).
  • + *
  • {@code WEIGHTS_0} is read and normalized per-vertex.
  • + *
  • Material tintability: name prefix {@code "tintable_"} → per-channel + * {@link GltfData.Primitive} entry.
  • + *
+ * + * @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 allPositions = new java.util.ArrayList<>(); + java.util.List allNormals = new java.util.ArrayList<>(); + java.util.List allTexCoords = new java.util.ArrayList<>(); + java.util.List allJoints = new java.util.ArrayList<>(); + java.util.List allWeights = new java.util.ArrayList<>(); + java.util.List 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. + * + *

For animation data, see {@link #convertAnimationToMinecraftSpace}.

+ * + *
    + *
  • Vertex positions / normals: (x, y, z) → (-x, -y, z)
  • + *
  • Rest translations: same negation
  • + *
  • Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)
  • + *
  • Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)
  • + *
+ */ + 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. diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java index 79850a9..a541c0c 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfAnimationApplier.java @@ -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 itemAnimCache = - new ConcurrentHashMap<>(); + Collections.synchronizedMap( + new LinkedHashMap( + ITEM_ANIM_CACHE_INITIAL_CAPACITY, + 0.75f, + true // access-order + ) { + @Override + protected boolean removeEldestEntry( + Map.Entry 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 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 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; - }; - } } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java b/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java index 2739e56..21f3409 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfCache.java @@ -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. + * + *

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.

+ * + *

Load is atomic via {@link Map#computeIfAbsent}: two concurrent first-misses + * for the same resource will parse the GLB exactly once.

*/ @OnlyIn(Dist.CLIENT) public final class GltfCache { private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); - private static final Map CACHE = + private static final Map> 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 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(); } } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java index c8625aa..c623811 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java @@ -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. + * + *

ORDER MATTERS — do not rearrange without checking the + * invariants below. 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.

+ * + *
    + *
  1. GLB cache clear (inline listener below) — must run + * first. Inside this single listener's {@code apply()}: + *
      + *
    1. 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.
    2. + *
    3. Reload {@code ContextGlbRegistry} from the new + * resource packs before 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.
    4. + *
    5. Clear {@code FurnitureGltfCache} last, after the GLB + * layer has repopulated its registry but before any + * downstream item listener queries furniture models.
    6. + *
    + *
  2. + *
  3. Data-driven item reload + * ({@code DataDrivenItemReloadListener}) — consumes the + * reloaded GLB registry indirectly via item JSON references. + * Must run after the GLB cache clear so any item that + * reaches into the GLB layer during load picks up fresh data.
  4. + *
  5. GLB validation + * ({@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.
  6. + *
+ * + *

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

*/ @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 diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java b/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java index 85ab2c2..abd530e 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfLiveBoneReader.java @@ -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. *

@@ -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; diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java index d25e706..001e58f 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfMeshRenderer.java @@ -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 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. - * - *

Pass 1 (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.

- * - *

Pass 2 (always): iterate the index buffer, read skinned data from - * the arrays, and emit to the {@link VertexConsumer}.

- * - * @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 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(); } } } diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java index 0af4e7a..993fd65 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java @@ -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. + * + *

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.

+ */ + 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 ownedParts, Set 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 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 ownedFilter, + @Nullable Set 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 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 ownedParts ) { - String[] jointNames = data.jointNames(); - Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); Set 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 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 allOwnedParts, + Set 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 ALL_PARTS_SET = Set.of( + "head", + "body", + "rightArm", + "leftArm", + "rightLeg", + "leftLeg" + ); + + private static void enableSelectivePartsCore( + KeyframeAnimation.AnimationBuilder builder, + Set ownedParts, + Set enabledParts, + Set partsWithKeyframes, + boolean isFullBodyAnimation, + boolean allowFreeHead + ) { + for (String partName : ALL_PARTS_SET) { KeyframeAnimation.StateCollection part = getPartByName( builder, partName diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java deleted file mode 100644 index 5360600..0000000 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinCache.java +++ /dev/null @@ -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. - * - *

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

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 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); - } -} diff --git a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java index 14a8f0b..982baaf 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java +++ b/src/main/java/com/tiedup/remake/client/gltf/GltfSkinningEngine.java @@ -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). + * + *

Scratch pool: 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.

*/ @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]; diff --git a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java index 6b649aa..243ce8b 100644 --- a/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java +++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java @@ -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 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 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 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." + )); + } } } diff --git a/src/main/java/com/tiedup/remake/core/SettingsAccessor.java b/src/main/java/com/tiedup/remake/core/SettingsAccessor.java index 047c737..8bbfff6 100644 --- a/src/main/java/com/tiedup/remake/core/SettingsAccessor.java +++ b/src/main/java/com/tiedup/remake/core/SettingsAccessor.java @@ -173,12 +173,8 @@ public class SettingsAccessor { *
  • "beam_cuffs" -> "chain"
  • * * - *

    BUG-003 fix: 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. + *

    Single source of truth for bind resistance — both the display + * layer and the struggle minigame resolve here so they can't drift.

    * * @param bindType The raw item name or config key * @return Resistance value from config, or 100 as fallback diff --git a/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java b/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java index 83fb742..9b4080d 100644 --- a/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java +++ b/src/main/java/com/tiedup/remake/entities/ai/master/MasterFollowPlayerGoal.java @@ -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; } diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java index 2734b25..f012126 100644 --- a/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java +++ b/src/main/java/com/tiedup/remake/events/lifecycle/CapabilityEventHandler.java @@ -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) diff --git a/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java index 2b8001f..cd85931 100644 --- a/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java +++ b/src/main/java/com/tiedup/remake/events/lifecycle/PlayerDisconnectHandler.java @@ -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 ( diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java b/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java index 0a37cbc..cc44072 100644 --- a/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinPlayerModel.java @@ -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: diff --git a/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java index b86c14a..7aee22b 100644 --- a/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java +++ b/src/main/java/com/tiedup/remake/mixin/client/MixinVillagerEntityBaseModelMCA.java @@ -118,9 +118,10 @@ public class MixinVillagerEntityBaseModelMCA { ); 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( diff --git a/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java b/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java index 985e93a..131ffcc 100644 --- a/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java +++ b/src/main/java/com/tiedup/remake/network/NetworkEventHandler.java @@ -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( diff --git a/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java index 1867f66..1338910 100644 --- a/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java +++ b/src/main/java/com/tiedup/remake/network/conversation/PacketEndConversationC2S.java @@ -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); diff --git a/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java b/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java index e473ec0..0604b18 100644 --- a/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java +++ b/src/main/java/com/tiedup/remake/network/merchant/PacketCloseMerchantScreen.java @@ -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()); } } diff --git a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java index 482c6c2..512206a 100644 --- a/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java +++ b/src/main/java/com/tiedup/remake/network/minigame/PacketLockpickAttempt.java @@ -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(); diff --git a/src/main/java/com/tiedup/remake/state/PlayerBindState.java b/src/main/java/com/tiedup/remake/state/PlayerBindState.java index 8c28fbb..35a3398 100644 --- a/src/main/java/com/tiedup/remake/state/PlayerBindState.java +++ b/src/main/java/com/tiedup/remake/state/PlayerBindState.java @@ -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; } diff --git a/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java index 9f3c53b..edc1d27 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/capability/V2BondageEquipment.java @@ -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 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 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 seen = new IdentityHashMap<>(); + // Fast-path: zero allocations when no item is equipped (hot per-tick + // call from hasAnyEquipment). + IdentityHashMap 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 diff --git a/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java b/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java index e904af4..e40daa0 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/client/TintColorResolver.java @@ -30,27 +30,48 @@ public final class TintColorResolver { /** * Resolve tint colors for an ItemStack. * + *

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

    + * * @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 resolve(ItemStack stack) { - Map 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 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; } } diff --git a/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java b/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java index 7434012..0658e40 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/client/V2BondageRenderLayer.java @@ -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 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) { diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java index da1e07c..0656db4 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java @@ -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, diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index d401aa2..2084750 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -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, diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java index 0be4bb5..6455ed7 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java @@ -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 defs = Collections.unmodifiableMap(new HashMap<>(newDefs)); SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs)); + revision++; } } @@ -91,9 +102,19 @@ public final class DataDrivenItemRegistry { Map 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. * diff --git a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java index 84d3336..116ff06 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/network/PacketSyncV2Equipment.java @@ -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 diff --git a/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java index 2ee8e88..4315abc 100644 --- a/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java +++ b/src/main/java/com/tiedup/remake/v2/client/DataDrivenIconOverrides.java @@ -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}. + * + *

    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.

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

    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; + } + } } diff --git a/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java b/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java index b747db5..47e0821 100644 --- a/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java +++ b/src/main/java/com/tiedup/remake/v2/client/ObjBlockRenderer.java @@ -92,6 +92,13 @@ public class ObjBlockRenderer implements BlockEntityRenderer { } } + /** + * 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; diff --git a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java index 7f0bd8d..7fcafcf 100644 --- a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java +++ b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java @@ -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. + * + *

    When a data-driven item or furniture JSON declares an {@code icon} + * ResourceLocation, the mod loads the corresponding model from + * {@code assets//models/item/.json} (or + * {@code .../models/item/icons/.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.

    + * + *

    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.

    + * + *

    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.

    + */ + @SubscribeEvent + public static void onRegisterAdditionalModels( + ModelEvent.RegisterAdditional event + ) { + Set 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 collectCustomIconLocations() { + Set 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 :item/} convention + * and {@code :} 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 diff --git a/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java index 7de1979..e89523b 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java @@ -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. + *

    + * 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 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 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. + * + *

    Called from three sites:

    + *
      + *
    • {@link #addPassenger} on mount
    • + *
    • {@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes
    • + *
    • The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)
    • + *
    + * + *

    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.

    + */ + 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). */ diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java new file mode 100644 index 0000000..4f1543b --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicate.java @@ -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. + * + *

    The 2026-04-17 audit found three divergent code paths enforcing different + * subsets of the intended security model:

    + *
      + *
    • {@code EntityFurniture.interact()} force-mount path — all checks present
    • + *
    • {@code PacketFurnitureForcemount.handleOnServer} — missing + * {@code isTiedUp} and {@code !isPassenger} checks
    • + *
    • {@code EntityFurniture.interact()} lock path and + * {@code PacketFurnitureLock.handleOnServer} — missing collar-ownership + * enforcement
    • + *
    + * + *

    This class unifies the model. The security rule is:

    + *
      + *
    1. Lock/unlock: 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.
    2. + *
    3. Force-mount: 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.
    4. + *
    + * + *

    The boolean core methods are package-private to allow pure-logic unit + * testing without a Minecraft runtime.

    + */ +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); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java index d761ddd..17798b4 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java @@ -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, diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java index 98212b2..b735f26 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureRegistry.java @@ -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 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; } /** diff --git a/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometry.java b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometry.java new file mode 100644 index 0000000..b4433b9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometry.java @@ -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. + * + *

    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.

    + * + *

    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.

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

    Examples: 1 seat → 0.0 (centered). 2 seats → -0.5, 0.5. 3 seats → + * -1.0, 0.0, 1.0.

    + * + * @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); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java index 6637564..51231f0 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/ISeatProvider.java @@ -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); diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java index 419c73c..b188c6f 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGlbParser.java @@ -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 seatIdToRootNode = new LinkedHashMap<>(); // seatId -> node index - Set 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 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 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 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 seatTransforms = - new LinkedHashMap<>(); - for (Map.Entry 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 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 allPositions = new ArrayList<>(); - List allNormals = new ArrayList<>(); - List allTexCoords = new ArrayList<>(); - List allJoints = new ArrayList<>(); - List 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 allPositions = new ArrayList<>(); - List allNormals = new ArrayList<>(); - List 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 rotJoints = new ArrayList<>(); - List rotTimestamps = new ArrayList<>(); - List rotValues = new ArrayList<>(); - - List transJoints = new ArrayList<>(); - List transTimestamps = new ArrayList<>(); - List 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); - } - } } diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java index 2cfe47f..24d2386 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfCache.java @@ -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; *

    Loads .glb files via Minecraft's ResourceManager on first access and parses them * with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}. * + *

    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.

    + * *

    Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries. * *

    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 CACHE = - new ConcurrentHashMap<>(); + private static final Map< + ResourceLocation, + Optional + > 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 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(); } } diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java index f732da6..0b246b4 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/FurnitureGltfData.java @@ -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. * + *

    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}.

    + * * @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, diff --git a/src/main/java/com/tiedup/remake/v2/furniture/client/PlayerArmatureScanner.java b/src/main/java/com/tiedup/remake/v2/furniture/client/PlayerArmatureScanner.java new file mode 100644 index 0000000..935acf2 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/furniture/client/PlayerArmatureScanner.java @@ -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. + * + *

    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.

    + */ +@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_} root node, plus the set of root node indices + * (used later to classify skins). + */ + static final class ArmatureScan { + + final Map seatIdToRootNode; + final Set playerRootNodes; + + ArmatureScan( + Map seatIdToRootNode, + Set playerRootNodes + ) { + this.seatIdToRootNode = seatIdToRootNode; + this.playerRootNodes = playerRootNodes; + } + } + + /** + * Walk every node, match names against {@code "Player_"}, 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 seatIdToRootNode = new LinkedHashMap<>(); + Set 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 seatIdToSkinIdx; + + SkinClassification( + int furnitureSkinIdx, + Map seatIdToSkinIdx + ) { + this.furnitureSkinIdx = furnitureSkinIdx; + this.seatIdToSkinIdx = seatIdToSkinIdx; + } + } + + /** + * Classify each skin as either "furniture" or "seat N": + *
      + *
    1. If the skin's {@code skeleton} field points at a known Player_* + * root → it's that seat.
    2. + *
    3. Fallback: if any joint in the skin is a descendant of a Player_* + * root → that seat.
    4. + *
    5. Otherwise: the first such skin is the "furniture" skin.
    6. + *
    + * 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 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 extractTransforms( + @Nullable JsonArray nodes, + Map seatIdToRootNode + ) { + Map seatTransforms = + new LinkedHashMap<>(); + if (nodes == null) return seatTransforms; + for (Map.Entry 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 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; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java index b5af9d0..8d62107 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureEscape.java @@ -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); diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java index 1a09c32..974c094 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureForcemount.java @@ -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; } diff --git a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java index 459d469..3f0bb57 100644 --- a/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java +++ b/src/main/java/com/tiedup/remake/v2/furniture/network/PacketFurnitureLock.java @@ -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 diff --git a/src/test/java/com/tiedup/remake/client/gltf/GlbParserUtilsTest.java b/src/test/java/com/tiedup/remake/client/gltf/GlbParserUtilsTest.java new file mode 100644 index 0000000..2740c44 --- /dev/null +++ b/src/test/java/com/tiedup/remake/client/gltf/GlbParserUtilsTest.java @@ -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}. + * + *

    Covers the parser safety surface introduced to defeat the OOM / silent-drift + * bugs found in the 2026-04-17 audit:

    + *
      + *
    • {@link GlbParserUtils#readGlbSafely(java.io.InputStream, String)} header cap
    • + *
    • {@link GlbParserUtils#readChunkLength(java.nio.ByteBuffer, String, String)} bounds
    • + *
    • {@link GlbParserUtils#normalizeWeights(float[])} correctness
    • + *
    • {@link GlbParserUtils#clampJointIndices(int[], int)} contract
    • + *
    + */ +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); + } + } +} diff --git a/src/test/java/com/tiedup/remake/client/gltf/GltfPoseConverterTest.java b/src/test/java/com/tiedup/remake/client/gltf/GltfPoseConverterTest.java new file mode 100644 index 0000000..ae56a58 --- /dev/null +++ b/src/test/java/com/tiedup/remake/client/gltf/GltfPoseConverterTest.java @@ -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}. + * + *

    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.

    + * + *

    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.

    + */ +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)); + } + } +} diff --git a/src/test/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicateTest.java b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicateTest.java new file mode 100644 index 0000000..db6b122 --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureAuthPredicateTest.java @@ -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}. + * + *

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

    + * + *

    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.

    + */ +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 + ) + ); + } + } +} diff --git a/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometryTest.java b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometryTest.java new file mode 100644 index 0000000..af9936b --- /dev/null +++ b/src/test/java/com/tiedup/remake/v2/furniture/FurnitureSeatGeometryTest.java @@ -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. + * + *

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

    + */ +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); + } + } +}