Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GltfPoseConverter#computeEndTick}.
|
||||
*
|
||||
* <p>This is the public entry point that controls how many ticks a GLB animation
|
||||
* occupies on the PlayerAnimator timeline. The P0-01 fix replaced a hardcoded
|
||||
* {@code endTick = 1} (which silently froze every animation to frame 0) with a
|
||||
* timestamp-derived computation. These tests pin down that behavior so a future
|
||||
* refactor can't silently re-introduce the bug.</p>
|
||||
*
|
||||
* <p>Full end-to-end tests (keyframe emission into a {@code KeyframeAnimation}
|
||||
* builder, multi-item composite, selective-part enabling) require instantiating
|
||||
* a complete {@code GltfData} and the PlayerAnimator library — deferred to a
|
||||
* GameTest or a later harness upgrade.</p>
|
||||
*/
|
||||
class GltfPoseConverterTest {
|
||||
|
||||
private static final int TICKS_PER_SECOND = 20;
|
||||
|
||||
private static GltfData.AnimationClip clip(float... timestamps) {
|
||||
int n = timestamps.length;
|
||||
Quaternionf[][] rotations = new Quaternionf[1][n];
|
||||
Vector3f[][] translations = new Vector3f[1][n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
rotations[0][i] = new Quaternionf(); // identity
|
||||
translations[0][i] = new Vector3f();
|
||||
}
|
||||
return new GltfData.AnimationClip(timestamps, rotations, translations, n);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("computeEndTick — timestamp → tick conversion")
|
||||
class ComputeEndTickTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null clip returns 1 (minimum valid endTick)")
|
||||
void nullClipReturnsOne() {
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-frame clip returns 1")
|
||||
void zeroFrameReturnsOne() {
|
||||
GltfData.AnimationClip c = new GltfData.AnimationClip(
|
||||
new float[0],
|
||||
new Quaternionf[0][],
|
||||
null,
|
||||
0
|
||||
);
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-frame clip at t=0 returns 1")
|
||||
void singleFrameAtZero() {
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(clip(0.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3 frames at 0/0.5/1.0s returns 20 ticks")
|
||||
void threeFramesOneSecondSpan() {
|
||||
// Last timestamp 1.0s → 1.0 * 20 = 20 ticks.
|
||||
assertEquals(20, GltfPoseConverter.computeEndTick(clip(0f, 0.5f, 1.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("baseline normalization: timestamps 0.5/1.0/1.5s → endTick 20 (span 1s)")
|
||||
void baselineNormalizesToSpan() {
|
||||
// If baseline wasn't subtracted, this would return round(1.5*20)=30.
|
||||
// With baseline=0.5, endTick = round((1.5-0.5)*20) = 20.
|
||||
assertEquals(
|
||||
20,
|
||||
GltfPoseConverter.computeEndTick(clip(0.5f, 1.0f, 1.5f))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-frame clip at t=3.0 still returns 1 (baseline collapses to zero)")
|
||||
void singleFrameAtLargeTime() {
|
||||
// times[0] = 3.0, baseline = 3.0, (3.0 - 3.0) * 20 = 0, clamped to 1.
|
||||
assertEquals(1, GltfPoseConverter.computeEndTick(clip(3.0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("long clip: 2.5s span returns 50 ticks")
|
||||
void longClipTwoAndHalfSeconds() {
|
||||
GltfData.AnimationClip c = clip(0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f);
|
||||
assertEquals(50, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rounding: last timestamp 0.025s (< 0.5 tick) still yields at least 1 tick")
|
||||
void subTickTimestampClampsToOne() {
|
||||
// round(0.025 * 20) = round(0.5) = 0 or 1 depending on rounding mode.
|
||||
// Math.round(float) returns (int)Math.floor(x+0.5) = 1 here.
|
||||
// But even if it were 0, the Math.max(1, ...) clamp should protect us.
|
||||
int endTick = GltfPoseConverter.computeEndTick(clip(0f, 0.025f));
|
||||
assertEquals(
|
||||
1,
|
||||
endTick,
|
||||
"Very short spans must collapse to endTick=1, not 0 (would break PlayerAnimator)"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high-FPS source preserves total span: 60 frames over 2s → 40 ticks")
|
||||
void highFpsPreservesSpan() {
|
||||
// 60 evenly-spaced frames from 0.0 to 2.0s.
|
||||
float[] times = new float[60];
|
||||
for (int i = 0; i < 60; i++) {
|
||||
times[i] = i * (2.0f / 59.0f);
|
||||
}
|
||||
GltfData.AnimationClip c = clip(times);
|
||||
assertEquals(
|
||||
Math.round(2.0f * TICKS_PER_SECOND),
|
||||
GltfPoseConverter.computeEndTick(c)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("timestamps array longer than frameCount: uses min(len-1, frameCount-1)")
|
||||
void timestampsLongerThanFrameCount() {
|
||||
// frameCount=3 means only the first 3 timestamps matter even if array is longer.
|
||||
Quaternionf[][] rotations = new Quaternionf[1][3];
|
||||
rotations[0][0] = new Quaternionf();
|
||||
rotations[0][1] = new Quaternionf();
|
||||
rotations[0][2] = new Quaternionf();
|
||||
GltfData.AnimationClip c = new GltfData.AnimationClip(
|
||||
new float[] { 0f, 0.5f, 1.0f, 99.0f, 99.0f }, // 5 timestamps
|
||||
rotations,
|
||||
null,
|
||||
3 // but only 3 active frames
|
||||
);
|
||||
// Last valid index = min(5-1, 3-1) = 2 → times[2] = 1.0 → 20 ticks.
|
||||
assertEquals(20, GltfPoseConverter.computeEndTick(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user