extends RefCounted
class_name GlbCheck
## IS THIS .glb SAFE TO HAND TO GODOT? — runtime models (hyper-games #93, models plan).
##
## A placed model's bytes come from another peer. Godot's GLTFDocument was written
## for files on the author's own disk, and two of its behaviours are wrong for that:
##
## IT FOLLOWS URIs. A buffer or image with `"uri": "../../x.png"` is resolved
## against the base path and READ FROM LOCAL DISK. A peer's model must never be
## able to make my game open my files, so any `uri` at all is refused — a .glb
## carries its data in its own binary chunk, and the ones that do not are not
## worth the hole.
##
## IT DECODES BEFORE IT LOOKS. A 1 MB PNG can declare 30000 x 30000 and decode to
## 3.6 GB. So image sizes are read from the PNG / JPEG / WebP HEADERS here, before
## any decoder runs, and an image in a format we cannot size is refused.
##
## Everything here is pure (bytes in, verdict out) so the gate can feed it crafted
## files with no overlay, no room and no GPU. Limits are the user's (2026-10-04).
const MAX_BYTES := 64 * 1024 * 1024
const MAX_TRIANGLES := 300000
const MAX_TEXTURE_PX := 4096
const MAX_IMAGES := 32
const MAX_NODES := 2000
const MAGIC := 0x46546C67 # "glTF"
const CHUNK_JSON := 0x4E4F534A
const CHUNK_BIN := 0x004E4942
## {ok: bool, reason: String, json: Dictionary, bin: PackedByteArray, triangles: int}
## `reason` is short and human — it is what the red box over a blocked model says.
static func check(bytes: PackedByteArray) -> Dictionary:
var out := {"ok": false, "reason": "", "json": {}, "bin": PackedByteArray(), "triangles": 0}
if bytes.size() > MAX_BYTES:
out["reason"] = "file too large (%d MB, limit %d)" % [bytes.size() >> 20, MAX_BYTES >> 20]
return out
var parts := unpack(bytes)
if parts.has("error"):
out["reason"] = String(parts["error"])
return out
var j: Dictionary = parts["json"]
var bin: PackedByteArray = parts["bin"]
out["json"] = j
out["bin"] = bin
for key: String in ["buffers", "images"]:
for e: Variant in j.get(key, []):
if e is Dictionary and (e as Dictionary).has("uri"):
out["reason"] = "external file reference in %s (not allowed)" % key
return out
var nodes: Array = j.get("nodes", [])
if nodes.size() > MAX_NODES:
out["reason"] = "too many nodes (%d, limit %d)" % [nodes.size(), MAX_NODES]
return out
var images: Array = j.get("images", [])
if images.size() > MAX_IMAGES:
out["reason"] = "too many textures (%d, limit %d)" % [images.size(), MAX_IMAGES]
return out
var tris := triangles(j)
out["triangles"] = tris
if tris < 0:
out["reason"] = "malformed mesh data"
return out
if tris > MAX_TRIANGLES:
out["reason"] = "too many triangles (%d, limit %d)" % [tris, MAX_TRIANGLES]
return out
for i in images.size():
var dims := image_size(j, bin, i)
if dims.x <= 0:
out["reason"] = "texture %d: unreadable or unsupported format" % i
return out
if dims.x > MAX_TEXTURE_PX or dims.y > MAX_TEXTURE_PX:
out["reason"] = "texture %d too large (%dx%d, limit %d)" % [i, dims.x, dims.y, MAX_TEXTURE_PX]
return out
out["ok"] = true
return out
## Split a .glb into its JSON and BIN chunks, or {error}.
static func unpack(bytes: PackedByteArray) -> Dictionary:
if bytes.size() < 20:
return {"error": "not a .glb (too short)"}
if bytes.decode_u32(0) != MAGIC:
return {"error": "not a .glb (bad magic)"}
if bytes.decode_u32(4) != 2:
return {"error": "unsupported glTF version %d" % bytes.decode_u32(4)}
if bytes.decode_u32(8) != bytes.size():
return {"error": "truncated or padded .glb (header %d, file %d)" % [bytes.decode_u32(8), bytes.size()]}
var jlen := bytes.decode_u32(12)
if bytes.decode_u32(16) != CHUNK_JSON or 20 + jlen > bytes.size():
return {"error": "malformed .glb (no JSON chunk)"}
var parsed: Variant = JSON.parse_string(bytes.slice(20, 20 + jlen).get_string_from_utf8())
if not (parsed is Dictionary):
return {"error": "malformed .glb (JSON does not parse)"}
var bin := PackedByteArray()
var at := 20 + jlen
if at + 8 <= bytes.size():
var blen := bytes.decode_u32(at)
if bytes.decode_u32(at + 4) == CHUNK_BIN and at + 8 + blen <= bytes.size():
bin = bytes.slice(at + 8, at + 8 + blen)
return {"json": parsed, "bin": bin}
## Build a .glb from parts (4-byte padded, as the spec requires). For gates, which
## craft hostile files out of a valid one.
static func pack(json: Dictionary, bin: PackedByteArray) -> PackedByteArray:
var jb := JSON.stringify(json).to_utf8_buffer()
while jb.size() % 4 != 0:
jb.append(0x20)
var bb := bin.duplicate()
while bb.size() % 4 != 0:
bb.append(0)
var total := 12 + 8 + jb.size() + (8 + bb.size() if bb.size() > 0 else 0)
var out := PackedByteArray()
out.resize(20)
out.encode_u32(0, MAGIC)
out.encode_u32(4, 2)
out.encode_u32(8, total)
out.encode_u32(12, jb.size())
out.encode_u32(16, CHUNK_JSON)
out.append_array(jb)
if bb.size() > 0:
var h := PackedByteArray()
h.resize(8)
h.encode_u32(0, bb.size())
h.encode_u32(4, CHUNK_BIN)
out.append_array(h)
out.append_array(bb)
return out
## Triangles DRAWN: every node that references a mesh counts that mesh again, since
## that is what the GPU will render. -1 if an index does not resolve.
static func triangles(j: Dictionary) -> int:
var meshes: Array = j.get("meshes", [])
var accessors: Array = j.get("accessors", [])
var per_mesh: Dictionary = {}
var total := 0
for n: Variant in j.get("nodes", []):
if not (n is Dictionary) or not (n as Dictionary).has("mesh"):
continue
var mi := int(n["mesh"])
if mi < 0 or mi >= meshes.size():
return -1
if not per_mesh.has(mi):
var t := 0
for p: Variant in (meshes[mi] as Dictionary).get("primitives", []):
var prim: Dictionary = p
var ai := int(prim["indices"]) if prim.has("indices") else int((prim.get("attributes", {}) as Dictionary).get("POSITION", -1))
if ai < 0 or ai >= accessors.size():
return -1
var c := int((accessors[ai] as Dictionary).get("count", 0))
match int(prim.get("mode", 4)):
4: t += c / 3
5, 6: t += maxi(0, c - 2)
_: pass # points / lines draw no triangles
per_mesh[mi] = t
total += int(per_mesh[mi])
return total
## An image's (width, height) from its header bytes, or (0, 0) when the bytes cannot
## be found or the format is not PNG / JPEG / WebP. Never decodes.
static func image_size(j: Dictionary, bin: PackedByteArray, index: int) -> Vector2i:
var img: Dictionary = (j.get("images", []) as Array)[index]
if not img.has("bufferView"):
return Vector2i.ZERO
var views: Array = j.get("bufferViews", [])
var vi := int(img["bufferView"])
if vi < 0 or vi >= views.size():
return Vector2i.ZERO
var v: Dictionary = views[vi]
if int(v.get("buffer", 0)) != 0:
return Vector2i.ZERO
var off := int(v.get("byteOffset", 0))
var n := int(v.get("byteLength", 0))
if off < 0 or n <= 0 or off + n > bin.size():
return Vector2i.ZERO
return header_size(bin.slice(off, off + mini(n, 65536)))
static func header_size(b: PackedByteArray) -> Vector2i:
# PNG: 8-byte signature, then the IHDR chunk: width, height big-endian at 16 / 20.
if b.size() >= 24 and b[0] == 0x89 and b[1] == 0x50 and b[2] == 0x4E and b[3] == 0x47:
return Vector2i(_be32(b, 16), _be32(b, 20))
# JPEG: walk the markers to the first SOFn (not DHT C4, JPG C8, DAC CC).
if b.size() >= 4 and b[0] == 0xFF and b[1] == 0xD8:
var i := 2
while i + 9 < b.size():
if b[i] != 0xFF:
return Vector2i.ZERO
var m := b[i + 1]
if m == 0xFF:
i += 1
continue
var seg := (b[i + 2] << 8) | b[i + 3]
if m >= 0xC0 and m <= 0xCF and m != 0xC4 and m != 0xC8 and m != 0xCC:
return Vector2i((b[i + 7] << 8) | b[i + 8], (b[i + 5] << 8) | b[i + 6])
i += 2 + seg
return Vector2i.ZERO
# WebP (KHR_texture_webp): RIFF....WEBP, then VP8 / VP8L / VP8X.
if b.size() >= 30 and b.slice(0, 4).get_string_from_ascii() == "RIFF" and b.slice(8, 12).get_string_from_ascii() == "WEBP":
var fourcc := b.slice(12, 16).get_string_from_ascii()
if fourcc == "VP8 ":
return Vector2i(b.decode_u16(26) & 0x3FFF, b.decode_u16(28) & 0x3FFF)
if fourcc == "VP8L" and b[20] == 0x2F:
var bits := b.decode_u32(21)
return Vector2i((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)
if fourcc == "VP8X":
return Vector2i((b[24] | (b[25] << 8) | (b[26] << 16)) + 1, (b[27] | (b[28] << 8) | (b[29] << 16)) + 1)
return Vector2i.ZERO
static func _be32(b: PackedByteArray, at: int) -> int:
return (b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]