Skip to content

Commit f1fca10

Browse files
LinuxJessiclaude
andcommitted
Eyelashes: the lash material's rect is a true-colour alpha gradient; draw it raw
Lash materials (UV type 0x7F, pal 0xFF) point at a 32x32 canvas strip holding a dark navy gradient fading to transparent, which the lash polygons stretch into strokes. The decoder rendered it through a palette, and the true-colour pass's coherence/opacity guard (right for hair sheets) rejected it. Lash rects are now drawn as raw CT32 unconditionally; KOS-MOS has her lashes back in Blender. 136 model atlases change, all by the lash strip only (opacity drop <= 9%). The "4-bit sheet" theory is retired: with PCSX2's verified GS PSMT4 tables the plane-packed HUD regions read as noise, so they are 8-bit swizzled data with two images packed per byte, not GS 4-bit textures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 67cf020 commit f1fca10

7 files changed

Lines changed: 43 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The kit ships no game data. You need your own copy of the disc.
2020
own skeleton files, every bone on the mesh. Faces are merged into
2121
their bodies.
2222
How-to and what to expect: [docs/BLENDER.md](docs/BLENDER.md).
23-
* **Character textures look right.** Opening models in Blender showed
23+
* **Character textures look right, eyelashes included.** Opening models in Blender showed
2424
four texture bugs that had been there since the first release, and
2525
all are fixed: 42 character atlases that came out as beige noise
2626
(Cherenkov, Jr., MOMO, Shion and Virgil variants among them), sleeves
@@ -40,8 +40,8 @@ The kit ships no game data. You need your own copy of the disc.
4040
The story of how: [docs/MUSIC.md](docs/MUSIC.md).
4141

4242
Still on the list: animation (the motion packs are located but not
43-
decoded, so bones pose but do not play), the eyelash textures, and a
44-
few map meshes in formats the model parser skips. The full change log is in [docs/UPDATES.md](docs/UPDATES.md).
43+
decoded, so bones pose but do not play) and a few map meshes in formats
44+
the model parser skips. The full change log is in [docs/UPDATES.md](docs/UPDATES.md).
4545

4646
## Running it
4747

‎browse.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,9 @@ def lex_truecolor_rects(lex: bytes) -> list[tuple[int, int, int, int]]:
409409
# hair sheets); the 0x0A family used by map models gives texel-space
410410
# rects that mis-scale here (MC_SYU01's skyline became noise), and the
411411
# 0x7F eyelash sheets are 4bpp, not CT32
412-
return [(m.umin, m.umax, m.vmin, m.vmax) for m in model.materials
412+
return [(m.umin, m.umax, m.vmin, m.vmax, m.uv_type == 0x7F) for m in model.materials
413413
if m.true_color and m.has_texture and m.umax > m.umin
414-
and m.uv_type == 0xFF]
414+
and m.uv_type in (0xFF, 0x7F)]
415415

416416

417417
def _lex_header_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
@@ -820,22 +820,26 @@ def paint(pal, u0, u1, v0, v1):
820820
# — guarded by the same coherence comparison as the block pass: two
821821
# map atlases (MC_KAS11, MC_SYU01) declare pal-0xFF rects over
822822
# ordinary paletted art, which reads as noise taken as CT32.
823+
# Eyelash rects (UV type 0x7F) are drawn unconditionally: the sheet is
824+
# a mostly transparent dark alpha gradient the lash polygons stretch
825+
# into strokes, which the coherence/opacity guard would reject.
823826
truecolor = [r for lx in lexes for r in lex_truecolor_rects(lx)]
824-
for cu0, cu1, cv0, cv1 in truecolor:
827+
for cu0, cu1, cv0, cv1, is_lash in truecolor:
825828
cu0, cv0 = max(0, cu0), max(0, cv0)
826829
cu1, cv1 = min(clen, cu1, W // 2), min(clen, cv1, H // 2)
827830
if cu1 <= cu0 or cv1 <= cv0:
828831
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
832+
if not is_lash:
833+
on = _rgba_noise(out, W, cu0 * 2, cu1 * 2, cv0 * 2, cv1 * 2)
834+
cn = _rgba_noise(canvas, clen, cu0, cu1, cv0, cv1)
835+
if not (cn < on):
836+
continue
837+
# and the region must hold pixels: a rect that is mostly alpha-0
838+
# as CT32 (MC_SYU01's declared rects) is not a hair sheet
839+
opaque = sum(1 for y in range(cv0, cv1, 2) for x in range(cu0, cu1, 2)
840+
if canvas[((y * clen) + x) * 4 + 3])
841+
if opaque * 4 < 0.4 * (cu1 - cu0) * (cv1 - cv0):
842+
continue
839843
for y in range(cv0 * 2, cv1 * 2):
840844
drow = y * W * 4
841845
crow = (y // 2) * clen

‎docs/BLENDER.md‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ face file are prefixed with that file's name.
3232

3333
**Materials.** Named after the region of the texture they use, like
3434
`mat3_u256-320_v128-256`. They all share one image. Hair materials end
35-
in `_rgba` and blend; everything else uses a cut-out alpha.
35+
in `_rgba` and eyelashes in `_lash`; both blend, everything else uses a
36+
cut-out alpha.
3637

3738
**Vertex colours** in the `Col` attribute. That is the game's baked-in
3839
shading tint. If a model looks too dark, unplug the Color Attribute node
@@ -75,12 +76,6 @@ the head.
7576

7677
## Known gaps
7778

78-
* **No eyelashes.** Every character has a lash mesh, but its texture is
79-
not in the character's own atlas: the rect it points at is only the
80-
video-memory slot a shared 4-bit lash sheet is uploaded into at
81-
runtime, and that sheet has not been found on the disc yet. The
82-
lashes are in the file with correct UVs; their material is named
83-
`…_lash-4bpp-undecoded` so it is easy to hide.
8479
* **No animation.** Not located on the disc yet.
8580
* Two map meshes use vertex formats the parser skips, and blend shapes
8681
are ignored. Anything skipped is listed in the `.glb`'s `extras`.

‎docs/FORMATS.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,13 @@ marks a raw CT32 region (KOS-MOS meshes 31-36 = the hair sheets).
206206
Known residual: kosmos x384-512 y128-256 is plane-packed 4bpp (each
207207
index byte's two nibbles are two SEPARATE 4bpp images — visor-HUD
208208
symbol sheet + keypad panel, clean in grayscale) whose 16-colour CLUTs
209-
are not in the file; needs live-RAM palette capture. Left garbled.
209+
are not in the file. These are NOT GS PSMT4 textures: read through
210+
PCSX2's verified PSMT4 block/column tables the region is noise; it is
211+
PSMT8-swizzled data packed two images per byte by the artists. The
212+
eyelashes (materials of UV type 0x7F, pal 0xFF) are unrelated to this:
213+
their rect (umin = b1*4, vmin = (b4&0xF)*64 + (b3>>7)*32, 32x32 canvas
214+
px) holds a true-colour dark alpha gradient; `decode_xtx` draws it raw
215+
(unguarded) and the lash polygons stretch it into strokes.
210216
Lead (2026-09-25): the lex VIF streams carry GS `TEX0` A+D writes
211217
(u64 value + u64 reg 0x06/0x16). `kosmos.lex` draws PSMT8 from
212218
tbp 14336/10752 (two VRAM slots), tbw 16, 1024x256, with CLUT pointers

‎docs/UPDATES.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ capture tool (`docs/research/pine_pose.py`) stays in the kit as the way
2424
that was verified; nobody needs to run it. Byte layout and the evidence:
2525
[FORMATS.md](FORMATS.md) § JNT skeletons.
2626

27-
**Eyelashes, status.** Still missing, and the RAM session narrowed it
28-
further: every 4-bit texture upload chain in memory (152 of them, in a
29-
field scene and in a face close-up) belongs to the battle-effect sprite
30-
set (REC, DEF, STOP, lightning, flares); none targets a character
31-
atlas. So the lash pixels are not uploaded as a separate 4-bit tile
32-
either. The remaining hypothesis is that the lash material reads the
33-
character atlas itself through a 4-bit view; the GS PSMT4 layout tables
34-
needed to test that were reconstructed from memory and render the whole
35-
atlas as noise, so the tables themselves need checking against PCSX2's
36-
source before that can be settled.
27+
**Eyelashes: solved.** Every character's lash mesh (UV type `0x7F`,
28+
flagged true-colour) draws a small true-colour strip of the atlas that
29+
the decoder had been rendering through a palette: a dark navy gradient
30+
fading to transparent, which the lash polygons stretch into strokes.
31+
The decoder now draws those rects as raw true colour unconditionally
32+
(the coherence guard that protects hair sheets would reject a mostly
33+
transparent strip), and KOS-MOS has her lashes back
34+
(`docs/images/kosmos-face.png`). The long detour through "4-bit sheets"
35+
was wrong: the regions on her atlas that split into two clean 4-bit
36+
images are 8-bit-swizzled data with two images packed per byte by the
37+
artists; read with PCSX2's verified GS 4-bit layout they are noise, so
38+
the game never treats them as 4-bit textures.
3739

3840
## 2026-10-14 — models in Blender, and the texture fixes that came with them
3941

‎docs/images/kosmos-face.png‎

853 KB
Loading

‎lex.py‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class LexMaterial:
5252
pal2: int
5353
has_texture: bool
5454
color: tuple = () # 12 floats: three RGBA material colours
55-
uv_type: int = 0 # UVInfo type byte (0x7F = eyelash sheet, 4bpp)
55+
uv_type: int = 0 # UVInfo type byte (0x7F = eyelash material: true-colour alpha gradient)
5656

5757
@property
5858
def true_color(self) -> bool:
@@ -611,7 +611,7 @@ def add_accessor(view: int, ctype: int, count: int, atype: str,
611611
for i, m in enumerate(model.materials):
612612
mat = {"name": f"mat{i}_u{m.umin}-{m.umax}_v{m.vmin}-{m.vmax}"
613613
+ ("_rgba" if m.true_color else "")
614-
+ ("_lash-4bpp-undecoded" if m.uv_type == 0x7F else ""),
614+
+ ("_lash" if m.uv_type == 0x7F else ""),
615615
"pbrMetallicRoughness": {"metallicFactor": 0.0,
616616
"roughnessFactor": 0.9},
617617
"doubleSided": True,
@@ -781,10 +781,6 @@ def add_accessor(view: int, ctype: int, count: int, atype: str,
781781
"inverseBindMatrices": iba})
782782

783783
warnings = list(model.warnings)
784-
if any(m.uv_type == 0x7F for m in model.materials):
785-
warnings.append("eyelash material(s) reference a 4bpp sheet the "
786-
"texture decoder does not read yet; the lashes "
787-
"sample whatever the atlas PNG shows there")
788784
gltf = {
789785
"asset": {"version": "2.0",
790786
"generator": "Xenosaga1PythonExtractor lex.py"},

0 commit comments

Comments
 (0)