Refactor V2 animation, furniture, and GLTF rendering

Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.

Parsing and rendering
  - Shared GLB parsing helpers consolidated into GlbParserUtils
    (accessor reads, weight normalization, joint-index clamping,
    coordinate-space conversion, animation parse, primitive loop).
  - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
    GltfLiveBoneReader — removes per-frame joint-matrix allocation
    from the render hot path.
  - emitVertex helper dedups three parallel loops in GltfMeshRenderer.
  - TintColorResolver.resolve has a zero-alloc path when the item
    declares no tint channels.
  - itemAnimCache bounded to 256 entries (access-order LRU) with
    atomic get-or-compute under the map's monitor.

Animation correctness
  - First-in-joint-order wins when body and torso both map to the
    same PlayerAnimator slot; duplicate writes log a single WARN.
  - Multi-item composites honor the FullX / FullHeadX opt-in that
    the single-item path already recognized.
  - Seat transforms converted to Minecraft model-def space so
    asymmetric furniture renders passengers at the correct offset.
  - GlbValidator: IBM count / type / presence, JOINTS_0 presence,
    animation channel target validation, multi-skin support.

Furniture correctness and anti-exploit
  - Seat assignment synced via SynchedEntityData (server is
    authoritative; eliminates client-server divergence on multi-seat).
  - Force-mount authorization requires same dimension and a free
    seat; cross-dimension distance checks rejected.
  - Reconnection on login checks for seat takeover before re-mount
    and force-loads the target chunk for cross-dimension cases.
  - tiedup_furniture_lockpick_ctx carries a session UUID nonce so
    stale context can't misroute a body-item lockpick.
  - tiedup_locked_furniture survives death without keepInventory
    (Forge 1.20.1 does not auto-copy persistent data on respawn).

Lifecycle and memory
  - EntityCleanupHandler fans EntityLeaveLevelEvent out to every
    per-entity state map on the client.
  - DogPoseRenderHandler re-keyed by UUID (stable across dimension
    change; entity int ids are recycled).
  - PetBedRenderHandler, PlayerArmHideEventHandler, and
    HeldItemHideHandler use receiveCanceled + sentinel sets so
    Pre-time mutations are restored even when a downstream handler
    cancels the render.

Tests
  - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
    FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
NotEvil
2026-04-18 17:34:03 +02:00
parent 17815873ac
commit 355e2936c9
63 changed files with 4965 additions and 2226 deletions

View File

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

View File

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

View File

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

View File

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