i asset viewer

This commit is contained in:
2025-12-30 00:25:33 -06:00
parent af31307d02
commit afb393cca2
9 changed files with 1661 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

228
loaders/ikv_loader.py Normal file
View File

@@ -0,0 +1,228 @@
class IKVToken:
__slots__ = ("type", "val")
def __init__(self, t, v=None):
self.type = t
self.val = v
def ikv_lex(src):
n = len(src)
i = 0
def is_ws(c):
return c in " \t\r\n"
while i < n:
c = src[i]
if is_ws(c):
i += 1
continue
if c == "/" and i + 1 < n and src[i + 1] == "/":
i += 2
while i < n and src[i] != "\n":
i += 1
continue
if c == "#":
i += 1
while i < n and src[i] != "\n":
i += 1
continue
if c in "{}[],":
i += 1
yield IKVToken(c)
continue
if c == '"':
i += 1
out = []
while i < n:
ch = src[i]
if ch == "\\" and i + 1 < n:
nx = src[i + 1]
if nx == "n":
out.append("\n")
i += 2
continue
if nx == "r":
out.append("\r")
i += 2
continue
if nx == "t":
out.append("\t")
i += 2
continue
if nx == "\\":
out.append("\\")
i += 2
continue
if nx == '"':
out.append('"')
i += 2
continue
out.append(nx)
i += 2
continue
if ch == '"':
i += 1
break
out.append(ch)
i += 1
yield IKVToken("STRING", "".join(out))
continue
j = i
while j < n and (not is_ws(src[j])) and src[j] not in "{}[],":
j += 1
w = src[i:j]
i = j
yield IKVToken("WORD", w)
yield IKVToken("EOF")
class IKVParser:
def __init__(self, src):
self.tokens = ikv_lex(src)
self.cur = next(self.tokens)
def _eat(self, t):
if self.cur.type != t:
raise ValueError(f"IKV parse: expected {t}, got {self.cur.type}")
self.cur = next(self.tokens)
def _maybe(self, t):
if self.cur.type == t:
self.cur = next(self.tokens)
return True
return False
def parse(self):
if self.cur.type == "WORD" and self.cur.val == "ikv1":
self._eat("WORD")
if self.cur.type in ("STRING", "WORD"):
self.cur = next(self.tokens)
if self.cur.type != "{":
raise ValueError("IKV parse: expected { after header")
return self._parse_object()
if self.cur.type == "{":
return self._parse_object()
return self._parse_object_flat()
def _parse_object(self):
self._eat("{")
obj = {}
while True:
if self._maybe("}"):
break
if self._maybe(","):
continue
if self.cur.type != "STRING":
raise ValueError("IKV parse: object key must be string")
key = self.cur.val
self._eat("STRING")
val = self._parse_value()
obj[key] = val
self._maybe(",")
return obj
def _parse_array(self):
self._eat("[")
arr = []
while True:
if self._maybe("]"):
break
if self._maybe(","):
continue
arr.append(self._parse_value())
self._maybe(",")
return arr
def _parse_value(self):
if self.cur.type == "{":
return self._parse_object()
if self.cur.type == "[":
return self._parse_array()
if self.cur.type == "STRING":
s = self.cur.val
self._eat("STRING")
return s
if self.cur.type == "WORD":
w = self.cur.val
self._eat("WORD")
if w == "true":
return True
if w == "false":
return False
if w == "null":
return None
try:
if any(ch in w for ch in ".eE"):
return float(w)
return int(w, 10)
except Exception:
return w
raise ValueError("IKV parse: unexpected token")
def _parse_object_flat(self):
obj = {}
while self.cur.type != "EOF":
if self._maybe(","):
continue
if self.cur.type != "STRING":
raise ValueError("IKV parse: expected string key")
key = self.cur.val
self._eat("STRING")
obj[key] = self._parse_value()
self._maybe(",")
return obj
def parse_ikv_text(txt):
return IKVParser(txt).parse()
def pack_value_from_index_gen(index, generation):
return ((int(generation) & 0xFFFF) << 16) | (int(index) & 0xFFFF)
def extract_handles_recursive(node):
out = []
def maybe_add_value_type_meta(d):
if not isinstance(d, dict):
return
if "value" in d and "type" in d and "meta" in d:
v = d["value"]
t = d["type"]
m = d["meta"]
if isinstance(v, int) and isinstance(t, int) and isinstance(m, int):
out.append((int(v) & 0xFFFFFFFF, int(t) & 0xFFFF, int(m) & 0xFFFF))
def maybe_add_index_gen(d):
if not isinstance(d, dict):
return
if "index" in d and "generation" in d and "type" in d and "meta" in d:
idx = d["index"]
gen = d["generation"]
t = d["type"]
m = d["meta"]
if isinstance(idx, int) and isinstance(gen, int) and isinstance(t, int) and isinstance(m, int):
v = pack_value_from_index_gen(idx, gen)
out.append((int(v) & 0xFFFFFFFF, int(t) & 0xFFFF, int(m) & 0xFFFF))
def visit(x):
if isinstance(x, dict):
if "ihandle" in x and isinstance(x["ihandle"], dict):
maybe_add_value_type_meta(x["ihandle"])
maybe_add_index_gen(x["ihandle"])
maybe_add_value_type_meta(x)
maybe_add_index_gen(x)
for vv in x.values():
visit(vv)
elif isinstance(x, list):
for vv in x:
visit(vv)
visit(node)
seen = set()
uniq = []
for h in out:
k = (h[0] | (h[1] << 32) | (h[2] << 48))
if k not in seen:
seen.add(k)
uniq.append(h)
return uniq

156
loaders/imesh_loader.py Normal file
View File

@@ -0,0 +1,156 @@
import struct
IMSH_MAGIC = b"IMSH"
IMSH_VERSION = 2
IMSH_HEADER_STRUCT = struct.Struct("<4sIIII" + "IHH" + "I" + "Q")
IMSH_SUBMESH_STRUCT = struct.Struct("<IIQ" + "IHH" + "ffffff" + "IIQ")
IMSH_LOD_STRUCT = struct.Struct("<IIQQ")
IMSH_HEADER_SIZE = IMSH_HEADER_STRUCT.size
IMSH_SUBMESH_SIZE = IMSH_SUBMESH_STRUCT.size
IMSH_LOD_SIZE = IMSH_LOD_STRUCT.size
MODEL_VERTEX_STRIDE = 48
def _h_from(value, typ, meta):
return (int(value) & 0xFFFFFFFF, int(typ) & 0xFFFF, int(meta) & 0xFFFF)
def _h_is_zero(h):
return (h[0] | h[1] | h[2]) == 0
def load_imesh_from_bytes(data, base_off=0):
n = len(data)
if base_off < 0 or base_off > n:
raise ValueError("IMSH: bad base_off")
if base_off + IMSH_HEADER_SIZE > n:
raise ValueError("IMSH: too small")
(magic, version, flags, submesh_count, reserved0,
model_value, model_type, model_meta,
_pad0,
submesh_table_offset) = IMSH_HEADER_STRUCT.unpack_from(data, base_off)
if magic != IMSH_MAGIC:
raise ValueError("IMSH: bad magic")
if version != IMSH_VERSION:
raise ValueError("IMSH: bad version")
submesh_table_offset = int(submesh_table_offset)
if submesh_table_offset < 0 or submesh_table_offset >= (n - base_off):
raise ValueError("IMSH: submesh table offset out of range")
smt_off = base_off + submesh_table_offset
need = smt_off + int(submesh_count) * IMSH_SUBMESH_SIZE
if need > n:
raise ValueError("IMSH: submesh table out of range")
handle = _h_from(model_value, model_type, model_meta)
refs = []
submeshes = []
for i in range(int(submesh_count)):
o = smt_off + i * IMSH_SUBMESH_SIZE
(sm_flags,
material_name_len,
material_name_offset,
mat_value, mat_type, mat_meta,
aabb_min_x, aabb_min_y, aabb_min_z,
aabb_max_x, aabb_max_y, aabb_max_z,
lod_count,
sm_reserved0,
lods_offset) = IMSH_SUBMESH_STRUCT.unpack_from(data, o)
material_name_len = int(material_name_len)
material_name_offset = int(material_name_offset)
lod_count = int(lod_count)
lods_offset = int(lods_offset)
mat_handle = _h_from(mat_value, mat_type, mat_meta)
if not _h_is_zero(mat_handle):
refs.append(mat_handle)
else:
mat_handle = None
mat_name = None
if material_name_len > 0:
mo = base_off + material_name_offset
me = mo + material_name_len
if mo < base_off or me > n:
raise ValueError("IMSH: material name out of range")
mat_name = data[mo:me].decode("utf-8", errors="ignore")
if lod_count <= 0:
raise ValueError("IMSH: lod_count=0")
lods_abs = base_off + lods_offset
lods_need = lods_abs + lod_count * IMSH_LOD_SIZE
if lods_abs < base_off or lods_need > n:
raise ValueError("IMSH: lod table out of range")
lods = []
for li in range(lod_count):
lo = lods_abs + li * IMSH_LOD_SIZE
vcount, icount, voff, ioff = IMSH_LOD_STRUCT.unpack_from(data, lo)
vcount = int(vcount)
icount = int(icount)
voff = int(voff)
ioff = int(ioff)
if vcount <= 0 or icount <= 0:
raise ValueError("IMSH: empty lod")
vb = base_off + voff
ib = base_off + ioff
vbytes = vcount * MODEL_VERTEX_STRIDE
ibytes = icount * 4
if vb < base_off or vb + vbytes > n:
raise ValueError("IMSH: vertices out of range")
if ib < base_off or ib + ibytes > n:
raise ValueError("IMSH: indices out of range")
lods.append({
"vertex_count": vcount,
"index_count": icount,
"vertices_offset": voff,
"indices_offset": ioff,
"vertices_size": vbytes,
"indices_size": ibytes,
"vertex_stride": MODEL_VERTEX_STRIDE,
})
submeshes.append({
"flags": int(sm_flags),
"material_handle": mat_handle,
"material_name": mat_name,
"aabb_min": (float(aabb_min_x), float(aabb_min_y), float(aabb_min_z)),
"aabb_max": (float(aabb_max_x), float(aabb_max_y), float(aabb_max_z)),
"lod_count": lod_count,
"lods_offset": lods_offset,
"lods": lods,
})
info = {
"version": int(version),
"flags": int(flags),
"submesh_count": int(submesh_count),
"submesh_table_offset": int(submesh_table_offset),
"decode_offset": int(base_off),
"header_size": int(IMSH_HEADER_SIZE),
"submesh_record_size": int(IMSH_SUBMESH_SIZE),
"lod_record_size": int(IMSH_LOD_SIZE),
"vertex_stride": int(MODEL_VERTEX_STRIDE),
}
return {
"handle": handle,
"refs": refs,
"submeshes": submeshes,
"info": info,
"blob": data,
"base_off": int(base_off),
}

68
loaders/itex_loader.py Normal file
View File

@@ -0,0 +1,68 @@
import struct
import time
import zlib
ITEX_MAGIC = 0x58455449
ITEX_VERSION = 1
ITEX_HEADER_SIZE = 56
ITEX_STRUCT = struct.Struct("<IHHIIIIIIIIIHHII")
def load_itex_from_bytes(data, base_off=0):
if len(data) < base_off + ITEX_HEADER_SIZE:
raise ValueError("ITEX: too small")
(
magic,
version,
header_size,
width,
height,
channels,
is_float,
has_alpha,
has_smooth_alpha,
uncompressed_size,
compressed_size,
handle_value,
handle_type,
handle_meta,
reserved0,
reserved1
) = ITEX_STRUCT.unpack_from(data, base_off)
if magic != ITEX_MAGIC or version != ITEX_VERSION or header_size != ITEX_HEADER_SIZE:
raise ValueError("ITEX: bad header")
if width == 0 or height == 0 or channels == 0:
raise ValueError("ITEX: bad dims")
if compressed_size == 0 or uncompressed_size == 0:
raise ValueError("ITEX: bad sizes")
payload_off = base_off + header_size
end = payload_off + compressed_size
if end > len(data):
raise ValueError("ITEX: truncated payload")
comp = data[payload_off:end]
t0 = time.perf_counter()
pixels = zlib.decompress(comp)
t1 = time.perf_counter()
if len(pixels) != uncompressed_size:
raise ValueError("ITEX: decompressed size mismatch")
h = (int(handle_value) & 0xFFFFFFFF, int(handle_type) & 0xFFFF, int(handle_meta) & 0xFFFF)
info = {
"width": int(width),
"height": int(height),
"channels": int(channels),
"is_float": int(is_float),
"has_alpha": int(has_alpha),
"has_smooth_alpha": int(has_smooth_alpha),
"compressed_size": int(compressed_size),
"decompressed_size": int(uncompressed_size),
"decompress_ms": (t1 - t0) * 1000.0,
"decode_offset": int(base_off),
}
return h, info, pixels

121
loaders/material_loader.py Normal file
View File

@@ -0,0 +1,121 @@
from .ikv_loader import parse_ikv_text, extract_handles_recursive
def try_load_material_from_bytes(data):
try:
txt = data.decode("utf-8", errors="strict")
except Exception:
try:
txt = data.decode("utf-8", errors="ignore")
except Exception:
return None
s = txt.lstrip()
if not (s.startswith("ikv1") or s.startswith("{") or s.startswith('"')):
return None
obj = parse_ikv_text(txt)
root_handle = None
if isinstance(obj, dict) and "ihandle" in obj and isinstance(obj["ihandle"], dict):
d = obj["ihandle"]
if all(k in d for k in ("value", "type", "meta")) and all(
isinstance(d[k], int) for k in ("value", "type", "meta")
):
v = int(d["value"]) & 0xFFFFFFFF
t = int(d["type"]) & 0xFFFF
m = int(d["meta"]) & 0xFFFF
if t != 0:
root_handle = (v, t, m)
refs = extract_handles_recursive(obj)
if root_handle is not None:
rk = root_handle[0] | (root_handle[1] << 32) | (root_handle[2] << 48)
refs = [h for h in refs if ((h[0] | (h[1] << 32) | (h[2] << 48)) != rk)]
info = {}
if isinstance(obj, dict):
if "shader_id" in obj:
info["shader_id"] = obj.get("shader_id")
if "flags" in obj:
info["flags"] = obj.get("flags")
return {
"handle": root_handle,
"refs": refs,
"obj": obj,
"info": info,
"text_len": len(txt),
}
def pack_value_from_index_gen(index, generation):
return ((int(generation) & 0xFFFF) << 16) | (int(index) & 0xFFFF)
def extract_handles_recursive(node):
out = []
def is_valid_triplet(v, t, m):
v = int(v) & 0xFFFFFFFF
t = int(t) & 0xFFFF
m = int(m) & 0xFFFF
if t == 0:
return False
return True
def maybe_add_value_type_meta(d):
if not isinstance(d, dict):
return
if "value" in d and "type" in d and "meta" in d:
v = d["value"]
t = d["type"]
m = d["meta"]
if isinstance(v, int) and isinstance(t, int) and isinstance(m, int):
if is_valid_triplet(v, t, m):
out.append((int(v) & 0xFFFFFFFF, int(t) & 0xFFFF, int(m) & 0xFFFF))
def maybe_add_index_gen(d):
if not isinstance(d, dict):
return
if "index" in d and "generation" in d and "type" in d and "meta" in d:
idx = d["index"]
gen = d["generation"]
t = d["type"]
m = d["meta"]
if (
isinstance(idx, int)
and isinstance(gen, int)
and isinstance(t, int)
and isinstance(m, int)
):
if (idx == 0 and gen == 0) or int(t) == 0:
return
v = pack_value_from_index_gen(idx, gen)
if is_valid_triplet(v, t, m):
out.append((int(v) & 0xFFFFFFFF, int(t) & 0xFFFF, int(m) & 0xFFFF))
def visit(x):
if isinstance(x, dict):
if "ihandle" in x and isinstance(x["ihandle"], dict):
maybe_add_value_type_meta(x["ihandle"])
maybe_add_index_gen(x["ihandle"])
maybe_add_value_type_meta(x)
maybe_add_index_gen(x)
for vv in x.values():
visit(vv)
elif isinstance(x, list):
for vv in x:
visit(vv)
visit(node)
seen = set()
uniq = []
for h in out:
k = h[0] | (h[1] << 32) | (h[2] << 48)
if k not in seen:
seen.add(k)
uniq.append(h)
return uniq

1088
main.py Normal file

File diff suppressed because it is too large Load Diff