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