Skip to content

Commit 801ce9c

Browse files
LinuxJessiclaude
andcommitted
Models: .lex -> glTF binary (Blender-ready); four texture-decoder fixes it exposed
lex.py (new) parses the VU1 display lists — VIF UNPACK/MSCNT batches, in-stream material blocks, three skin-weight encodings — and writes a glTF 2.0 binary per model with the decoded atlas embedded, UVs scaled by the model's declared TEX0 size and wrapped per material rect, vertex colours, and an identity-bound skin of named weight groups. Companion face files are merged into the body's .glb and still written alone. Format per Lakuwu's xenotool, re-implemented; counts verified against it (KOS-MOS 3,290 tris / 46 groups). `browse --kinds models`, a GUI checkbox, docs/BLENDER.md for the import workflow. Opening Cherenkov and Albedo in Blender exposed four decoder bugs, all fixed and regression-checked over every model-bound atlas on the disc (756 of 1,019 change; model-palette decodes 790 -> 940, corner-scan fallbacks 155 -> 14; every opacity drop inspected): * two-part atlases composed their second sub-image over the first (placement rule refitted on all 166 multi-sub layouts; 42 character atlases had been monotone noise since v1.0.0; the FORMATS "hama.lex" caveat was this bug); * material blocks spliced into the vertex streams were never read (Cherenkov's sleeves) — lex_materials now comes from lex.parse_lex; * true-colour hair sheets are taken from the model's pal-0xFF rects instead of a per-block noise guess (Albedo's white hair); * eyelash materials (UV type 0x7F, 4bpp sheets, undecoded) get their real rect and are flagged in the export. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 86010c7 commit 801ce9c

11 files changed

Lines changed: 1107 additions & 48 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ reimplemented from the docs alone.
272272
| [docs/SUBTITLES.md](docs/SUBTITLES.md) | authoring and burning FMV cutscene subtitles — what's verified vs. what still needs a PCSX2/hardware check |
273273
| [docs/FORMATS.md](docs/FORMATS.md) | byte-level format reference (TOC, ARX, XTX, LEX, FL00, audio, movies) with verification evidence |
274274
| [docs/MUSIC.md](docs/MUSIC.md) | how the sequenced score works — SMD/SWD, the unstripped IOP driver, MIDI/SoundFont/WAV export — with the fun facts the disc gave up |
275+
| [docs/BLENDER.md](docs/BLENDER.md) | the models in Blender — import, what the objects/materials/vertex groups mean, what the `.jnt` gap means |
275276
| [docs/JAVA.md](docs/JAVA.md) | the headline find: cutscenes as JDK 1.1 Java, and how to decompile them |
276277
| [docs/FINDS.md](docs/FINDS.md) | easter eggs and dev leftovers — staff folders on the retail disc, debug tools in shipped cutscenes, Gamera |
277278
| [docs/HISTORY.md](docs/HISTORY.md) | what this disc records about Monolith Soft in 2002 |
@@ -285,6 +286,10 @@ Decoded and converted by `browse`:
285286
resolved via the paired `.lex` model materials, the consuming overlay's
286287
texture descriptors (extract with `--code` to enable), or corner-scan;
287288
candidates are ranked by how coherently they render.
289+
* **Models** (`.lex` → `.glb`) — the VU1 display lists decoded to
290+
triangles with normals, vertex colours, skin weights and the atlas
291+
embedded; one glTF binary per model, straight into Blender. (Format
292+
per Lakuwu's xenotool; counts verified against it.)
288293
* **Streamed voice** (`.vds`/`.vdm`/`.vda` → WAV) — headerless PS2 SPU
289294
ADPCM, stereo interleaved every 0x400 bytes, 48 kHz. (If a decode ever
290295
sounds slow/echoey/choppy, the interleave is wrong — not the rate.)
@@ -307,8 +312,8 @@ Decoded and converted by `browse`:
307312

308313
Not done yet: real titles for the 58 layer-1 movies (needs the playback
309314
table in the ELF), an SMD synth, full palette coverage for multi-palette
310-
sprite atlases, `.lex` mesh geometry export (xenotool does OBJ),
311-
`.esd`/`.esp`/`.jnt` decoders (battle `.sed` sample extraction is done;
315+
sprite atlases, the `.jnt` skeleton (models export with weight groups
316+
but no posed armature), `.esd`/`.esp` decoders (battle `.sed` sample extraction is done;
312317
the `seds` program metadata — which sample plays on which event — isn't). Formats and evidence:
313318
[docs/FORMATS.md](docs/FORMATS.md).
314319

‎browse.py‎

Lines changed: 203 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090

9191
import arx
9292

93-
ALL_KINDS = ("textures", "audio", "banks", "battle_audio", "images", "text", "movies")
93+
ALL_KINDS = ("textures", "models", "audio", "banks", "battle_audio", "images", "text", "movies")
9494

9595
VOICE_RATE = 48000 # from xeno.Sound.streamPlay(id, 48000, ...) in the .evt scripts
9696

@@ -104,20 +104,24 @@ def _png_chunk(tag: bytes, payload: bytes) -> bytes:
104104
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
105105

106106

107-
def write_png(path: Path, width: int, height: int, rgba: bytes) -> None:
107+
def png_bytes(width: int, height: int, rgba: bytes) -> bytes:
108108
raw = bytearray()
109109
stride = width * 4
110110
for y in range(height):
111111
raw.append(0) # filter: none
112112
raw += rgba[y * stride : (y + 1) * stride]
113-
path.write_bytes(
113+
return (
114114
b"\x89PNG\r\n\x1a\n"
115115
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
116116
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw), 6))
117117
+ _png_chunk(b"IEND", b"")
118118
)
119119

120120

121+
def write_png(path: Path, width: int, height: int, rgba: bytes) -> None:
122+
path.write_bytes(png_bytes(width, height, rgba))
123+
124+
121125
# ---------------------------------------------------------------------------
122126
# XTX -> PNG
123127
# ---------------------------------------------------------------------------
@@ -363,14 +367,56 @@ def _candidate_score(idx: bytearray, W: int, H: int, pal: list[bytes]) -> float:
363367

364368

365369
def lex_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
366-
"""(palx, paly, umin, umax, vmin, vmax) per mesh material of a .lex model.
367-
368-
Reads only the fixed-offset mesh headers (palette byte at +0x125, UV info
369-
at +0x130 — layout from xenotool's lex_file.h); the extra material blocks
370-
embedded in the VIF vertex streams are not chased.
371-
"""
370+
"""(palx, paly, umin, umax, vmin, vmax) per material of a .lex model —
371+
the mesh-header materials AND the material blocks spliced into the VIF
372+
vertex streams (``lex.parse_lex``; a mesh switches material mid-stream:
373+
Cherenkov's jacket mesh draws its sleeves from a second palette that
374+
only the in-stream block names). True-colour materials (pal 0xFF) are
375+
skipped — they have no palette. Falls back to the header-only read if
376+
the stream parse fails."""
372377
if lex[:4] != b"lex\x00" or len(lex) < 0xB0:
373378
return []
379+
try:
380+
import lex as lexmod
381+
model = lexmod.parse_lex(lex)
382+
mats = []
383+
for m in model.materials:
384+
if not m.has_texture or m.true_color:
385+
continue
386+
pal_hi, pal_lo = m.pal >> 4, m.pal & 0xF
387+
palx = (pal_hi % 2) * 256 + (pal_lo // 2) * 32 + (m.pal2 >> 7) * 16
388+
paly = (pal_hi // 2) * 32 + (pal_lo % 2) * 16
389+
mats.append((palx, paly, m.umin, m.umax, m.vmin, m.vmax))
390+
if mats:
391+
return mats
392+
except Exception:
393+
pass
394+
return _lex_header_materials(lex)
395+
396+
397+
def lex_truecolor_rects(lex: bytes) -> list[tuple[int, int, int, int]]:
398+
"""(umin, umax, vmin, vmax) in CANVAS pixels of every true-colour
399+
(pal 0xFF) material of a .lex — regions stored as raw CT32 on the
400+
canvas (hair sheets), to be drawn as such instead of through a palette."""
401+
if lex[:4] != b"lex\x00":
402+
return []
403+
try:
404+
import lex as lexmod
405+
model = lexmod.parse_lex(lex)
406+
except Exception:
407+
return []
408+
# only the 0xFF UV encoding (canvas-coordinate rects: the character
409+
# hair sheets); the 0x0A family used by map models gives texel-space
410+
# rects that mis-scale here (MC_SYU01's skyline became noise), and the
411+
# 0x7F eyelash sheets are 4bpp, not CT32
412+
return [(m.umin, m.umax, m.vmin, m.vmax) for m in model.materials
413+
if m.true_color and m.has_texture and m.umax > m.umin
414+
and m.uv_type == 0xFF]
415+
416+
417+
def _lex_header_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
418+
"""Header-only material read (fixed-offset mesh headers: palette byte at
419+
+0x125, UV info at +0x130 — layout from xenotool's lex_file.h)."""
374420
(nmesh,) = struct.unpack_from("<I", lex, 0x44)
375421
if not (0 < nmesh <= 4096):
376422
return []
@@ -390,9 +436,9 @@ def lex_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
390436
if t == 0x00:
391437
continue
392438
if t == 0xFF:
393-
w, x1, x = b[0] & 0xF, (b[1] >> 3) & 1, b[1] >> 4
394-
h, y1, y = b[2] >> 4, b[3] >> 7, b[4] & 0xF
395-
umin, vmin = x * 64 + x1 * 32, y * 64 + y1 * 32
439+
w, h = b[0] & 0xF, b[2] >> 4
440+
umin = b[1] * 4
441+
vmin = (b[4] & 0xF) * 64 + (b[3] >> 7) * 32
396442
umax, vmax = umin + (w + 1) * 16, vmin + (h + 1) * 16
397443
else: # 0x0a and friends
398444
umin = (b[0] & 0x3F) << 4
@@ -446,9 +492,19 @@ def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
446492
placed: list[tuple[int, int, int, int]] = [] # (w, h, x0, y0) per sub
447493
for w, _, h, gs_off, addr in subs:
448494
px = data[addr + 32 : addr + 32 + w * h * 4]
449-
block = gs_off // 4096
450-
x0 = (block % (bufw // 2)) * 64
451-
y0 = (block // (bufw // 2)) * 32
495+
# sub-image placement in the canvas: gs_off counts 32 per pixel
496+
# column and clen*32 per 32-pixel row band — i.e. 4096 per 128-px
497+
# column step, 65536 (clen 512) / 32768 (clen 256) per 128-row
498+
# step. Fitted on all 166 multi-sub layouts on the retail disc:
499+
# the 42 two-part character atlases (andrew, jr1, momo2, virgil1
500+
# …: second part at 8192/12288 -> x 256/384) only decode under
501+
# their own .lex palettes with this placement, and the parked
502+
# 16x16 CLUT tiles at 4096 land at x=128, where the .lex
503+
# materials point (the old 64-px block formula put them at 64 —
504+
# the "hama.lex points at (128,0) but the tile is at (64,0)"
505+
# caveat was this bug, not a VRAM-vs-file mismatch).
506+
y0 = (gs_off // (clen * 32)) * 32
507+
x0 = (gs_off % (clen * 32)) // 32
452508
if x0 + w > clen or y0 + h > clen:
453509
continue
454510
for y in range(h):
@@ -757,6 +813,40 @@ def paint(pal, u0, u1, v0, v1):
757813
if best_pal is not None:
758814
paint(best_pal, u0, u1, v0, v1)
759815

816+
# true-colour materials named by the model (pal 0xFF): their rect is a
817+
# raw CT32 region of the canvas — draw it as such, no heuristic needed
818+
# (Albedo's white hair rendered half through a skin palette when the
819+
# per-block noise test below fired on only some of its blocks)
820+
# — guarded by the same coherence comparison as the block pass: two
821+
# map atlases (MC_KAS11, MC_SYU01) declare pal-0xFF rects over
822+
# ordinary paletted art, which reads as noise taken as CT32.
823+
truecolor = [r for lx in lexes for r in lex_truecolor_rects(lx)]
824+
for cu0, cu1, cv0, cv1 in truecolor:
825+
cu0, cv0 = max(0, cu0), max(0, cv0)
826+
cu1, cv1 = min(clen, cu1, W // 2), min(clen, cv1, H // 2)
827+
if cu1 <= cu0 or cv1 <= cv0:
828+
continue
829+
on = _rgba_noise(out, W, cu0 * 2, cu1 * 2, cv0 * 2, cv1 * 2)
830+
cn = _rgba_noise(canvas, clen, cu0, cu1, cv0, cv1)
831+
if not (cn < on):
832+
continue
833+
# and the region must hold pixels: a rect that is mostly alpha-0
834+
# as CT32 (MC_SYU01's declared rects) is not a hair sheet
835+
opaque = sum(1 for y in range(cv0, cv1, 2) for x in range(cu0, cu1, 2)
836+
if canvas[((y * clen) + x) * 4 + 3])
837+
if opaque * 4 < 0.4 * (cu1 - cu0) * (cv1 - cv0):
838+
continue
839+
for y in range(cv0 * 2, cv1 * 2):
840+
drow = y * W * 4
841+
crow = (y // 2) * clen
842+
for x in range(cu0 * 2, cu1 * 2):
843+
o = (crow + x // 2) * 4
844+
out[drow + x * 4] = canvas[o]
845+
out[drow + x * 4 + 1] = canvas[o + 1]
846+
out[drow + x * 4 + 2] = canvas[o + 2]
847+
out[drow + x * 4 + 3] = _ALPHA_SCALE[canvas[o + 3]]
848+
painted.append((cu0 * 2, cu1 * 2, cv0 * 2, cv1 * 2))
849+
760850
# raw CT32 regions: a canvas can mix 8bpp paletted art with TRUE-COLOUR
761851
# pixel regions (KOS-MOS's hair-strand sheets — issue #1's last holdout).
762852
# No palette can ever render those. Per 64px block: if the current render
@@ -1397,6 +1487,104 @@ def bump(key: str, n: int = 1) -> None:
13971487
f"{stats.get('textures_embedded_arx', 0)} ARX-packed members "
13981488
f"decompressed) -> textures_png/_embedded/ + embedded_textures.csv")
13991489

1490+
if "models" in kinds:
1491+
import lex as lexmod
1492+
1493+
mdir = browse / "models"
1494+
srcs = list(_dump_files(dump, (".lex",)))
1495+
xtxs = {s.parent: {x.stem.lower(): x for x in s.parent.glob("*.xtx")}
1496+
for s in srcs}
1497+
rect_index = scan_ovl_descriptors(browse / "code")
1498+
cbp_index = {}
1499+
for name, descs in rect_index.items():
1500+
cbps = cbp_index.setdefault(name, [])
1501+
for d in descs:
1502+
if d[4] not in cbps:
1503+
cbps.append(d[4])
1504+
# companions: a .lex with no atlas of its own (kosmos_face.lex —
1505+
# face, eyes, mouth, lashes) binds onto the sibling atlas whose stem
1506+
# is the longest prefix of its own; its meshes are merged into that
1507+
# body's .glb (and still written on their own)
1508+
companions: dict[Path, list[Path]] = {}
1509+
for s in srcs:
1510+
stem = s.stem.lower()
1511+
if stem in xtxs[s.parent]:
1512+
continue
1513+
owners = [t for t in xtxs[s.parent] if stem.startswith(t)]
1514+
if owners:
1515+
body = s.with_name(xtxs[s.parent][max(owners, key=len)].stem + ".lex")
1516+
if body.is_file():
1517+
companions.setdefault(body, []).append(s)
1518+
log(f"models: converting {len(srcs)} .lex -> glTF binary "
1519+
f"({sum(len(v) for v in companions.values())} companion models "
1520+
f"merged into their bodies) ...")
1521+
atlas_cache: dict[Path, Optional[tuple[int, int, bytes]]] = {}
1522+
tri_total = 0
1523+
for i, src in enumerate(srcs, 1):
1524+
data = _load(src, bump)
1525+
if not data:
1526+
continue
1527+
try:
1528+
model = lexmod.parse_lex(data)
1529+
except lexmod.LexError as e:
1530+
bump("models_unparsed")
1531+
log(f" skip {src.relative_to(dump)}: {e}")
1532+
continue
1533+
for comp in sorted(companions.get(src, [])):
1534+
cdata = _load(comp, bump)
1535+
try:
1536+
cmodel = lexmod.parse_lex(cdata) if cdata else None
1537+
except lexmod.LexError:
1538+
cmodel = None
1539+
if cmodel is not None:
1540+
lexmod.merge_companion(model, cmodel, comp.stem + "/")
1541+
bump("models_companions_merged")
1542+
if not model.triangle_count:
1543+
bump("models_empty")
1544+
continue
1545+
# atlas: own .xtx, else the sibling whose stem is the longest
1546+
# prefix of this stem (kosmos_face -> kosmos.xtx), decoded with
1547+
# every .lex that binds onto it (same rule as the textures job)
1548+
stem = src.stem.lower()
1549+
siblings = xtxs[src.parent]
1550+
owners = [t for t in siblings if stem.startswith(t)]
1551+
atlas = siblings[max(owners, key=len)] if owners else None
1552+
tex = None
1553+
if atlas is not None:
1554+
if atlas not in atlas_cache:
1555+
lexes = [_load(atlas.with_suffix(".lex"), bump)] if atlas.with_suffix(".lex").is_file() else []
1556+
for lx in sorted(atlas.parent.glob("*.lex")):
1557+
ls = lx.stem.lower()
1558+
if (ls != atlas.stem.lower() and ls not in siblings
1559+
and ls.startswith(atlas.stem.lower())
1560+
and max((t for t in siblings if ls.startswith(t)), key=len) == atlas.stem.lower()):
1561+
lexes.append(_load(lx, bump))
1562+
xdata = _load(atlas, bump)
1563+
dec = decode_xtx(xdata, [l for l in lexes if l],
1564+
cbp_index.get(atlas.name.lower()),
1565+
rect_index.get(atlas.name.lower())) if xdata else None
1566+
atlas_cache[atlas] = ((dec[0], dec[1], png_bytes(dec[0], dec[1], dec[2]))
1567+
if dec else None)
1568+
tex = atlas_cache[atlas]
1569+
glb = lexmod.to_glb(model, tex[2] if tex else None,
1570+
(tex[0], tex[1]) if tex else (1024, 256),
1571+
name=src.stem)
1572+
dest = mdir / src.relative_to(dump).with_suffix(".glb")
1573+
dest.parent.mkdir(parents=True, exist_ok=True)
1574+
dest.write_bytes(glb)
1575+
tri_total += model.triangle_count
1576+
bump("models_glb")
1577+
bump("models_textured" if tex else "models_untextured")
1578+
if model.warnings:
1579+
bump("models_with_warnings")
1580+
if i % 100 == 0:
1581+
log(f" {i}/{len(srcs)} ({src.relative_to(dump)})")
1582+
log(f"models: {stats.get('models_glb', 0)} .glb written "
1583+
f"({stats.get('models_textured', 0)} textured, "
1584+
f"{tri_total:,} triangles; {stats.get('models_companions_merged', 0)} "
1585+
f"companions merged; {stats.get('models_unparsed', 0)} unparsed, "
1586+
f"{stats.get('models_with_warnings', 0)} with warnings)")
1587+
14001588
if "audio" in kinds:
14011589
adir = browse / "audio"
14021590
srcs = list(_dump_files(dump, (".vds", ".vdm")))

‎cli.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
python cli.py extract --iso GAME.iso --out OUTDIR
1010
[--chain 0|1] [--glob 'movie\\*'] [--no-carve] [--code]
1111
python cli.py classes --iso GAME.iso --out OUTDIR
12-
python cli.py browse --out OUTDIR [--kinds textures,audio,banks,battle_audio,images,text,movies]
12+
python cli.py browse --out OUTDIR [--kinds textures,models,audio,banks,battle_audio,images,text,movies]
1313
[--rate 48000]
1414
python cli.py verify --out OUTDIR
1515
python cli.py patch --iso GAME.iso --out MODDED.iso
@@ -477,8 +477,8 @@ def main() -> int:
477477
if name in ("classes", "browse", "verify"):
478478
p.add_argument("--out", required=True)
479479
if name == "browse":
480-
p.add_argument("--kinds", help="comma list: textures,audio,banks,"
481-
"battle_audio,images,text,movies")
480+
p.add_argument("--kinds", help="comma list: textures,models,audio,"
481+
"banks,battle_audio,images,text,movies")
482482
p.add_argument("--rate", type=int, help="voice sample rate (default 48000)")
483483
if name == "patch":
484484
p.add_argument("--out", required=True,

0 commit comments

Comments
 (0)