From c0c53f950410174ea9966a29cbeccd52f47b3a80 Mon Sep 17 00:00:00 2001 From: NotEvil Date: Fri, 17 Apr 2026 01:44:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(validation):=20add=20GlbValidator=20?= =?UTF-8?q?=E2=80=94=20structural=20validation=20from=20JSON=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/gltf/diagnostic/GlbValidator.java | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java 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 new file mode 100644 index 0000000..1df9c45 --- /dev/null +++ b/src/main/java/com/tiedup/remake/client/gltf/diagnostic/GlbValidator.java @@ -0,0 +1,360 @@ +package com.tiedup.remake.client.gltf.diagnostic; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.tiedup.remake.client.gltf.GltfBoneMapper; +import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity; +import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** + * Lightweight structural validator for GLB files. + * + *

Reads only the 12-byte header and the JSON chunk (first chunk) — never + * touches the binary mesh data. Produces a list of {@link GlbDiagnostic} + * findings that can range from hard errors (invalid header, missing skin) to + * informational notes (custom bone names).

+ * + *

All methods are static; the class cannot be instantiated.

+ */ +@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" + + private GlbValidator() {} + + /** + * Validate a GLB file by reading its header and JSON chunk. + * + * @param input the raw GLB byte stream (will be fully consumed) + * @param source resource location of the GLB file (used in diagnostics) + * @return a validation result containing all findings + */ + public static GlbValidationResult validate( + InputStream input, + ResourceLocation source + ) { + List diagnostics = new ArrayList<>(); + + JsonObject root; + try { + root = readJsonChunk(input, source, diagnostics); + } catch (Exception e) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + "Failed to read GLB header or JSON chunk: " + e.getMessage() + )); + return GlbValidationResult.of(source, diagnostics); + } + + // If readJsonChunk added an ERROR, root will be null + if (root == null) { + return GlbValidationResult.of(source, diagnostics); + } + + validateSkins(root, source, diagnostics); + validateMeshes(root, source, diagnostics); + validateAnimations(root, source, diagnostics); + + return GlbValidationResult.of(source, diagnostics); + } + + /** + * Cross-reference a validation result against an item definition. + * Stub for future checks (e.g. region/bone coverage, animation name + * matching against definition-declared poses). + * + * @param result the prior structural validation result + * @param def the item definition to cross-reference + * @return additional diagnostics (currently empty) + */ + public static List validateAgainstDefinition( + GlbValidationResult result, + DataDrivenItemDefinition def + ) { + return Collections.emptyList(); + } + + // ------------------------------------------------------------------ // + // Header + JSON chunk extraction // + // ------------------------------------------------------------------ // + + /** + * Parse the GLB header and extract the JSON chunk root object. + * Returns null and adds ERROR diagnostics on failure. + */ + private static JsonObject readJsonChunk( + InputStream input, + ResourceLocation source, + List diagnostics + ) throws Exception { + byte[] allBytes = input.readAllBytes(); + + if (allBytes.length < 12) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + "File too small to contain a GLB header (" + allBytes.length + " bytes)" + )); + return null; + } + + ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN); + + // -- Header (12 bytes) -- + int magic = buf.getInt(); + if (magic != GLB_MAGIC) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + String.format("Bad magic number: 0x%08X (expected 0x%08X)", magic, GLB_MAGIC) + )); + return null; + } + + int version = buf.getInt(); + if (version != GLB_VERSION) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + "Unsupported GLB version " + version + " (expected " + GLB_VERSION + ")" + )); + return null; + } + + @SuppressWarnings("unused") + int totalLength = buf.getInt(); + + // -- First chunk must be JSON -- + if (buf.remaining() < 8) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + "File truncated: no chunk header after GLB header" + )); + return null; + } + + int jsonChunkLength = buf.getInt(); + int jsonChunkType = buf.getInt(); + + if (jsonChunkType != CHUNK_JSON) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + String.format( + "First chunk is not JSON: type 0x%08X (expected 0x%08X)", + jsonChunkType, CHUNK_JSON + ) + )); + return null; + } + + if (buf.remaining() < jsonChunkLength) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "INVALID_GLB_HEADER", + "File truncated: JSON chunk declares " + jsonChunkLength + + " bytes but only " + buf.remaining() + " remain" + )); + return null; + } + + byte[] jsonBytes = new byte[jsonChunkLength]; + buf.get(jsonBytes); + String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8); + + return JsonParser.parseString(jsonStr).getAsJsonObject(); + } + + // ------------------------------------------------------------------ // + // Skin / bone validation // + // ------------------------------------------------------------------ // + + private static void validateSkins( + JsonObject root, + ResourceLocation source, + List diagnostics + ) { + if (!root.has("skins")) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "NO_SKINS", + "GLB has no 'skins' array — skinned mesh rendering requires at least one skin" + )); + return; + } + + JsonArray skins = root.getAsJsonArray("skins"); + if (skins.size() == 0) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.ERROR, "NO_SKINS", + "GLB 'skins' array is empty — skinned mesh rendering requires at least one skin" + )); + return; + } + + // Validate bones in the first skin + JsonObject skin = skins.get(0).getAsJsonObject(); + if (!skin.has("joints")) { + return; + } + + JsonArray joints = skin.getAsJsonArray("joints"); + JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null; + if (nodes == null) { + return; + } + + for (int j = 0; j < joints.size(); j++) { + int nodeIdx = joints.get(j).getAsInt(); + if (nodeIdx < 0 || nodeIdx >= nodes.size()) { + continue; + } + + JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); + if (!node.has("name")) { + continue; + } + + String rawName = node.get("name").getAsString(); + // Strip armature prefix (e.g. "MyRig|body" -> "body") + String boneName = rawName.contains("|") + ? rawName.substring(rawName.lastIndexOf('|') + 1) + : rawName; + + if (GltfBoneMapper.isKnownBone(boneName)) { + // OK — known bone, no diagnostic needed + 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 '" + + suggestion + "'?" + )); + } else { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.INFO, "UNKNOWN_BONE", + "Bone '" + boneName + "' is not a standard MC bone (treated as custom bone)" + )); + } + } + } + + // ------------------------------------------------------------------ // + // Mesh validation // + // ------------------------------------------------------------------ // + + private static void validateMeshes( + JsonObject root, + ResourceLocation source, + List diagnostics + ) { + if (!root.has("meshes")) { + return; + } + + JsonArray meshes = root.getAsJsonArray("meshes"); + + // Count non-Player meshes + int nonPlayerCount = 0; + for (int mi = 0; mi < meshes.size(); mi++) { + JsonObject mesh = meshes.get(mi).getAsJsonObject(); + String meshName = mesh.has("name") + ? mesh.get("name").getAsString() + : ""; + if (!"Player".equals(meshName)) { + nonPlayerCount++; + } + } + + if (nonPlayerCount > 1) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.WARNING, "MULTI_MESH_AMBIGUITY", + nonPlayerCount + " non-Player meshes found — name your item mesh 'Item' " + + "for explicit selection" + )); + } + + // Check WEIGHTS_0 on the first mesh's first primitive + if (meshes.size() > 0) { + JsonObject firstMesh = meshes.get(0).getAsJsonObject(); + if (firstMesh.has("primitives")) { + JsonArray primitives = firstMesh.getAsJsonArray("primitives"); + if (primitives.size() > 0) { + JsonObject firstPrimitive = primitives.get(0).getAsJsonObject(); + if (firstPrimitive.has("attributes")) { + JsonObject attributes = firstPrimitive.getAsJsonObject("attributes"); + if (!attributes.has("WEIGHTS_0")) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.WARNING, "NO_WEIGHTS", + "First mesh's first primitive has no WEIGHTS_0 attribute " + + "— skinning will not work correctly" + )); + } + } + } + } + } + } + + // ------------------------------------------------------------------ // + // Animation validation // + // ------------------------------------------------------------------ // + + private static void validateAnimations( + JsonObject root, + ResourceLocation source, + List diagnostics + ) { + if (!root.has("animations")) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.WARNING, "NO_IDLE_ANIMATION", + "GLB has no 'animations' array — no Idle animation found" + )); + return; + } + + JsonArray animations = root.getAsJsonArray("animations"); + if (animations.size() == 0) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.WARNING, "NO_IDLE_ANIMATION", + "GLB 'animations' array is empty — no Idle animation found" + )); + return; + } + + boolean hasIdle = false; + for (int ai = 0; ai < animations.size(); ai++) { + JsonObject anim = animations.get(ai).getAsJsonObject(); + 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); + } + if ("Idle".equals(animName)) { + hasIdle = true; + break; + } + } + + if (!hasIdle) { + diagnostics.add(new GlbDiagnostic( + source, null, Severity.WARNING, "NO_IDLE_ANIMATION", + "No animation named 'Idle' found — the default rest pose may not display correctly" + )); + } + } +}