import json, struct, os, sys, random
OUT = sys.argv[1]
NS = int(sys.argv[2]) if len(sys.argv) > 2 else 3
errs = []
warns = []
def err(m): errs.append(m)
def warn(m): warns.append(m)
COMP = {5120:1,5121:1,5122:2,5123:2,5125:4,5126:4} NC = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}
def check_glb(path):
d = open(path, "rb").read()
if len(d) < 12: return err(f"{path}: too short")
magic, ver, total = struct.unpack("<III", d[:12])
if magic != 0x46546C67: return err(f"{path}: bad GLB magic")
if ver != 2: err(f"{path}: GLB version {ver} != 2")
if total != len(d): err(f"{path}: GLB length {total} != file {len(d)}")
jlen, jtype = struct.unpack("<II", d[12:20])
if jtype != 0x4E4F534A: return err(f"{path}: chunk0 not JSON")
if jlen % 4: err(f"{path}: JSON chunk not 4-aligned")
jchunk = d[20:20+jlen]
if jchunk[-1:] not in (b" ", b"}", b"]"):
if jchunk.rstrip(b" ")[-1:] not in (b"}", b"]"): err(f"{path}: JSON pad not spaces")
g = json.loads(jchunk)
off = 20 + jlen
blen, btype = struct.unpack("<II", d[off:off+8])
if btype != 0x004E4942: return err(f"{path}: chunk1 not BIN")
if blen % 4: err(f"{path}: BIN chunk not 4-aligned")
binoff = off + 8
used = g.get("extensionsUsed", [])
compressed = "EXT_meshopt_compression" in used
buffers = g["buffers"]
if buffers[0]["byteLength"] > blen: err(f"{path}: buffer0.byteLength > BIN {blen}")
if compressed:
req = g.get("extensionsRequired", [])
need = ["EXT_meshopt_compression"]
if "KHR_mesh_quantization" in used:
need.append("KHR_mesh_quantization")
for e in need:
if e not in req: err(f"{path}: {e} must be in extensionsRequired")
fb = buffers[1].get("extensions", {}).get("EXT_meshopt_compression", {}).get("fallback") \
if len(buffers) > 1 else None
if fb is not True: err(f"{path}: missing fallback buffer (buffer 1, fallback:true)")
for i, bv in enumerate(g["bufferViews"]):
ext = bv.get("extensions", {}).get("EXT_meshopt_compression")
if ext:
if ext.get("buffer") != 0: err(f"{path}: bufferView {i} EXT buffer != 0")
if ext["byteOffset"] + ext["byteLength"] > blen:
err(f"{path}: bufferView {i} compressed source exceeds BIN")
fbi = bv.get("buffer")
if bv.get("byteOffset", 0) + bv["byteLength"] > buffers[fbi]["byteLength"]:
err(f"{path}: bufferView {i} decompressed range exceeds fallback buffer")
if ext.get("mode") not in ("ATTRIBUTES", "TRIANGLES", "INDICES"):
err(f"{path}: bufferView {i} bad EXT mode {ext.get('mode')}")
if ext.get("mode") == "ATTRIBUTES" and ext["count"] * ext.get("byteStride", 0) != bv["byteLength"]:
err(f"{path}: bufferView {i} count*stride != decompressed byteLength")
else:
if bv.get("byteOffset", 0) + bv["byteLength"] > blen:
err(f"{path}: bufferView {i} exceeds BIN")
POS_CT = (5126, 5123, 5122) IDX_CT = (5125, 5123) for i, a in enumerate(g["accessors"]):
ct, ty = a["componentType"], a["type"]
if ct not in COMP: err(f"{path}: accessor {i} bad componentType {ct}"); continue
stride = COMP[ct] * NC[ty]
bv = g["bufferViews"][a["bufferView"]]
need = a.get("byteOffset", 0) + a["count"] * stride
if need > bv["byteLength"]:
err(f"{path}: accessor {i} overflows bufferView ({need}>{bv['byteLength']})")
for m in g.get("meshes", []):
for p in m["primitives"]:
attrs = p["attributes"]
pa = g["accessors"][attrs["POSITION"]]
vc = pa["count"]
if pa["type"] != "VEC3" or pa["componentType"] not in POS_CT:
err(f"{path}: POSITION not VEC3 / unexpected componentType {pa['componentType']}")
if "min" not in pa or "max" not in pa or len(pa["min"]) != 3:
err(f"{path}: POSITION missing min/max[3]")
elif any(pa["min"][k] > pa["max"][k] for k in range(3)):
err(f"{path}: POSITION min>max")
if "TEXCOORD_0" in attrs and g["accessors"][attrs["TEXCOORD_0"]]["count"] != vc:
err(f"{path}: TEXCOORD_0 count != POSITION count")
if "indices" in p:
ia = g["accessors"][p["indices"]]
if ia["componentType"] not in IDX_CT or ia["type"] != "SCALAR":
err(f"{path}: indices not SCALAR/uint")
bv = g["bufferViews"][ia["bufferView"]]
if "EXT_meshopt_compression" not in bv.get("extensions", {}):
s = binoff + bv.get("byteOffset", 0) + ia.get("byteOffset", 0)
n = ia["count"]; mx = 0
for k in range(0, n, max(1, n // 500)):
mx = max(mx, struct.unpack("<I", d[s+k*4:s+k*4+4])[0])
if mx >= vc: err(f"{path}: index {mx} >= vertex count {vc}")
mat = p.get("material")
if mat is not None:
bct = g["materials"][mat].get("pbrMetallicRoughness", {}).get("baseColorTexture")
if bct:
if "TEXCOORD_0" not in attrs: err(f"{path}: textured prim lacks TEXCOORD_0")
img = g["images"][g["textures"][bct["index"]]["source"]]
bv = g["bufferViews"][img["bufferView"]] s = binoff + bv.get("byteOffset", 0)
if img.get("mimeType") != "image/jpeg": err(f"{path}: image not image/jpeg")
if d[s:s+2] != b"\xff\xd8": err(f"{path}: image not valid JPEG")
if "materials" in g and "KHR_materials_unlit" not in used:
err(f"{path}: uses unlit material but extensionsUsed missing it")
return g
def box_ok(b):
return isinstance(b, list) and len(b) == 12
def walk_tileset(ts_path, parent_ge, glb_samples):
ts = json.load(open(ts_path))
a = ts.get("asset", {})
if a.get("version") != "1.1": warn(f"{ts_path}: asset.version {a.get('version')}")
base = os.path.dirname(ts_path)
seen_glb = {} def walk(t, pge):
bv = t.get("boundingVolume", {})
if "box" not in bv or not box_ok(bv["box"]):
err(f"{ts_path}: tile boundingVolume.box invalid")
ge = t.get("geometricError")
if ge is None or ge < 0: err(f"{ts_path}: bad geometricError {ge}")
elif pge is not None and ge > pge + 1e-6:
err(f"{ts_path}: GE {ge} > parent {pge} (non-monotonic)")
if t.get("refine", "REPLACE") not in ("REPLACE", "ADD"):
err(f"{ts_path}: bad refine {t.get('refine')}")
c = t.get("content")
if c:
uri = c["uri"]
p = os.path.join(base, uri)
if not os.path.exists(p):
err(f"{ts_path}: content uri missing: {uri}")
elif uri.endswith(".json"):
walk_tileset(p, ge, glb_samples) elif uri.endswith(".glb"):
seen_glb[uri] = seen_glb.get(uri, 0) + 1
if seen_glb[uri] == 2:
err(f"{ts_path}: glb '{uri}' referenced by multiple tiles (filename collision)")
glb_samples.append(p)
for ch in t.get("children", []):
walk(ch, ge)
walk(ts["root"], parent_ge)
if "transform" in ts["root"] and len(ts["root"]["transform"]) != 16:
err(f"{ts_path}: root transform not 16 floats")
glb_samples = []
walk_tileset(os.path.join(OUT, "tileset.json"), None, glb_samples)
print(f"tilesets walked OK; {len(glb_samples)} glb content refs found")
random.seed(42)
by_area = {}
for p in glb_samples:
by_area.setdefault(os.path.dirname(p), []).append(p)
sample = []
for area, ps in by_area.items():
sample += random.sample(ps, min(NS, len(ps)))
print(f"deep-checking {len(sample)} glbs across {len(by_area)} areas...")
for p in sample:
try: check_glb(p)
except Exception as e: err(f"{p}: exception {e}")
print(f"\n=== {len(errs)} errors, {len(warns)} warnings ===")
for m in errs[:40]: print("ERR ", m)
for m in warns[:10]: print("WARN", m)
sys.exit(1 if errs else 0)