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:

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