OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / glb_check.gd

220 lines · gd ref:
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]