Skip to content

Commit a67fa8f

Browse files
LinuxJessiclaude
andcommitted
Skeletons: decode .jnt and export a real armature
parse_jnt reads the 64-byte node table (type, parent at +30, local position at +32, Euler rotation at +48 applied Rz*Ry*Rx); the skin's weight-group ids map onto the rotation-type nodes in order, verified on eight models by weighted vertex centroids (hips/knees/ankles/toes at 0.98/0.54/0.16/0.01 m on KOS-MOS). to_glb builds the node tree with local TRS, inverse bind from the rest-pose world matrices, and attaches companion (face) skeletons, which already sit in body space. Blender imports KOS-MOS with 121 bones and every joint mapped. Known limit, documented: the file's rest pose is a T-pose with hair raised while the mesh is modelled arms-down; no Euler order/sign/ composition closes it, so arm and hair pivots are off until the modelled pose's source is found. Eyelash sheet status recorded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3c390f9 commit a67fa8f

6 files changed

Lines changed: 296 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ The kit ships no game data. You need your own copy of the disc.
1616
* **Models open in Blender.** Every character, enemy, weapon, mech, prop
1717
and map on the disc comes out as a `.glb` with its texture inside.
1818
File ▸ Import ▸ glTF 2.0 and you have the mesh, its UVs, vertex
19-
colours and skin-weight groups. Faces are merged into their bodies.
19+
colours, skin weights and an armature built from the game's own
20+
skeleton files. Faces are merged into their bodies.
2021
How-to and what to expect: [docs/BLENDER.md](docs/BLENDER.md).
2122
* **Character textures look right.** Opening models in Blender showed
2223
four texture bugs that had been there since the first release, and
@@ -37,9 +38,10 @@ The kit ships no game data. You need your own copy of the disc.
3738
FLAC, with MIDI and SoundFont files alongside, at the right tempo.
3839
The story of how: [docs/MUSIC.md](docs/MUSIC.md).
3940

40-
Still on the list: the skeleton pose (models import with weight groups
41-
but no posed armature), the eyelash textures (stored 4 bits per pixel,
42-
not decoded yet), and a few map meshes in formats the model parser
41+
Still on the list: the arm and hair bones sit in the skeleton's T-pose
42+
rather than where the mesh hangs (legs, torso, head and face line up),
43+
the eyelash textures (a shared 4-bit sheet the game uploads from a file
44+
not yet identified), and a few map meshes in formats the model parser
4345
skips. The full change log is in [docs/UPDATES.md](docs/UPDATES.md).
4446

4547
## Running it

‎browse.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,9 +1566,17 @@ def bump(key: str, n: int = 1) -> None:
15661566
atlas_cache[atlas] = ((dec[0], dec[1], png_bytes(dec[0], dec[1], dec[2]))
15671567
if dec else None)
15681568
tex = atlas_cache[atlas]
1569+
skeleton = None
1570+
jnt = src.with_suffix(".jnt")
1571+
if jnt.is_file():
1572+
try:
1573+
skeleton = lexmod.parse_jnt(_load(jnt, bump))
1574+
bump("models_with_skeleton")
1575+
except lexmod.LexError:
1576+
bump("models_bad_jnt")
15691577
glb = lexmod.to_glb(model, tex[2] if tex else None,
15701578
(tex[0], tex[1]) if tex else (1024, 256),
1571-
name=src.stem)
1579+
name=src.stem, skeleton=skeleton)
15721580
dest = mdir / src.relative_to(dump).with_suffix(".glb")
15731581
dest.parent.mkdir(parents=True, exist_ok=True)
15741582
dest.write_bytes(glb)
@@ -1582,7 +1590,8 @@ def bump(key: str, n: int = 1) -> None:
15821590
log(f"models: {stats.get('models_glb', 0)} .glb written "
15831591
f"({stats.get('models_textured', 0)} textured, "
15841592
f"{tri_total:,} triangles; {stats.get('models_companions_merged', 0)} "
1585-
f"companions merged; {stats.get('models_unparsed', 0)} unparsed, "
1593+
f"companions merged; {stats.get('models_with_skeleton', 0)} with a "
1594+
f"posed skeleton; {stats.get('models_unparsed', 0)} unparsed, "
15861595
f"{stats.get('models_with_warnings', 0)} with warnings)")
15871596

15881597
if "audio" in kinds:

‎docs/BLENDER.md‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,17 @@ in `_rgba` and blend; everything else uses a cut-out alpha.
3838
shading tint. If a model looks too dark, unplug the Color Attribute node
3939
from Base Color in the material.
4040

41-
**Vertex groups** named `j<number>` on every skinned part, with an
42-
armature called `skeleton`. The weights are the game's own. The bone
43-
positions are not decoded yet, so the armature is a flat list of named
44-
joints at the origin: you can weight-paint or build a rig on top of it,
45-
but you cannot pose the model as imported.
41+
**An armature** named after the character, built from the `.jnt`
42+
skeleton file: the real joint hierarchy with the game's rest pose,
43+
plus the face file's own small skeleton for eyes and mouth. Skin weights
44+
are the game's own, as vertex groups named `j<number>` (rotation joints)
45+
with the offset, translate and bone-end nodes in between. Torso, legs,
46+
head and face bones sit on the mesh. Arm and hair bones do not: the
47+
file's rest pose is a T-pose with the hair raised, while the mesh is
48+
modelled with arms and hair down, and the pose that matches the mesh
49+
has not been found in the files yet. So the import looks right, the
50+
legs and head pose correctly, and posing an arm pivots at the T-pose
51+
shoulder until that is solved.
4652

4753
**Scale.** The game works in metres, Y up, same as glTF. KOS-MOS stands
4854
1.73 m tall and lands upright on Blender's floor with no scaling.
@@ -67,9 +73,8 @@ the head.
6773

6874
## Known gaps
6975

70-
* **No skeleton pose yet.** The joints come through as named vertex
71-
groups; their positions and hierarchy live in the `.jnt` files, which
72-
are the next thing to decode.
76+
* **Arm and hair bones are in the file's T-pose**, not where the mesh
77+
hangs (see above). Everything else on the skeleton lines up.
7378
* **No eyelashes.** Every character has a lash mesh, but its texture is
7479
not in the character's own atlas: the rect it points at is only the
7580
video-memory slot a shared 4-bit lash sheet is uploaded into at

‎docs/FORMATS.md‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,8 @@ Palette byte -> canvas tile: palx = (pal>>4 % 2)*256 + (pal&0xF / 2)*32 +
351351
material blocks (a mesh switches palette mid-stream: Cherenkov's
352352
jacket draws its sleeves from a second CLUT only the in-stream block
353353
names).
354-
Not decoded: the `.jnt` skeleton (xenotool reads a parent index per
355-
0x40-byte block but neither the pose nor the id mapping is solved);
356-
vertex formats 0x30/0xA0 (2 map files); weight formats 515/517.
354+
Not decoded: vertex formats 0x30/0xA0 (2 map files); weight formats
355+
515/517. The `.jnt` skeleton is decoded (next section).
357356
Export (`lex.py: to_glb`): glTF 2.0 binary, one primitive per
358357
(mesh, material), PNG atlas embedded, COLOR_0 vertex colours, a flat
359358
skin of identity-bound joints named `j<id>` so Blender gets the vertex
@@ -367,6 +366,43 @@ old note that `simajiri/hama.lex` "points at (128,0) while the file's
367366
CLUT tile sits at (64,0)" was the sub-image placement bug above — with
368367
the correct placement the tile IS at (128,0).)
369368

369+
## JNT skeletons (`lex.py: parse_jnt`) — hierarchy and rest pose
370+
371+
```
372+
Header 0x20: "JNT\0"; u16 @4 (3); u16 block_count @6; u16 @8 (74 on
373+
every file — not a count); u16 @0xA; u16 @0xC; u16 @0xE; u32 table
374+
offset @0x10 (0xF0); u16 @0x14; u16 @0x16; name[8] @0x18 ("KOSMOS").
375+
0x20..table: 52 u32 — [17..42] maps the model's 26 mesh bones (lex
376+
bone_idx) to type-1 blocks near the end of the table (0 = none).
377+
Blocks: 64 bytes each at `table offset`:
378+
+0 float, +8 float per-joint constants (limits?), often 0
379+
+16 u16 type 1 offset node, 2 ROTATION JOINT, 3 translate,
380+
4 bone end (pos.x = length), 6 name record
381+
+18 u16 flag 0..3 / 6, meaning unknown (not a rotation order)
382+
+30 u16 parent block index; self or out of range = root
383+
+32 float3 position local, metres
384+
+48 float3 rotation local Euler radians, applied as Rz * Ry * Rx
385+
type 6: ascii group[8] @+0 ("BODY"/"FACE"), label[16] @+8 ("KUBI")
386+
Skin joints = the type-2 blocks in order: the k-th weight-group id of the
387+
model (ids are contiguous per model) is the k-th rotation block —
388+
checked on 8 models (46/46, 36/36, 26/26, 47/47 …).
389+
World = parent_world * T(position) * R(rotation). Verified by the
390+
vertex centroids weighted to each joint: hips/knees/ankles/toes land at
391+
0.98/0.54/0.16/0.01 m on KOS-MOS, mirrored; face .jnt joints already
392+
land in body space (eyes within 2 cm of the eye meshes).
393+
CAVEAT: the file's rest pose is a T-pose with the hair raised; the
394+
mesh is modelled arms-down, hair-down. Torso, legs, head and face
395+
joints coincide with the mesh; arm and hair joints sit 0.3–1.2 m from
396+
their vertices. No Euler order, sign or T/R composition closes that gap
397+
(brute-forced), so the modelled pose is not stored as joint transforms
398+
in .jnt or .lex (the lex's 26 matrix pairs are identity). The glTF
399+
export binds with inverse(rest world), which leaves the mesh exactly as
400+
stored; posing an arm therefore pivots at the T-pose shoulder.
401+
Candidates for the bind data: the type-1 "post" blocks the 0x20 table
402+
points at (big rotations, parented into the arm/hair chains), the
403+
+0/+8 floats, or an animation file not located yet.
404+
```
405+
370406
## FL00 event containers / Java (`evt.py`)
371407

372408
`.evt` = FL00 wrapper of real Java class files, format 45.3 (JDK 1.1,

‎docs/UPDATES.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
Newest first: what changed, why, and what it means for you.
44

5+
## 2026-10-14 — skeletons: the `.jnt` files decoded
6+
7+
Models now import into Blender with a real armature instead of a flat
8+
list of joints. The `.jnt` file is a tree of 64-byte nodes: a type
9+
(offset, rotation joint, translate, bone end, name), a parent index, a
10+
local position and a local rotation. The skin's weight groups are the
11+
rotation joints in order, which checks out on every model tried. The
12+
face file's own small skeleton comes along for eyes and mouth.
13+
14+
What lines up: torso, legs, head and face. KOS-MOS's hips, knees,
15+
ankles and toes land at 0.98, 0.54, 0.16 and 0.01 m, mirrored, and her
16+
eye joints are within two centimetres of the eye meshes. What does not:
17+
arms and hair. The file's rest pose is a T-pose with the hair raised,
18+
the mesh is modelled arms-down, and no rotation convention closes that
19+
gap, so the pose matching the mesh is stored somewhere not found yet.
20+
The export binds the mesh so it looks exactly as before; posing an arm
21+
pivots at the T-pose shoulder until that is solved. Byte layout and the
22+
evidence: [FORMATS.md](FORMATS.md) § JNT skeletons.
23+
24+
**Eyelashes, status.** Still missing. The lash material's rect points
25+
at a video-memory slot, not at pixels in the character's atlas (the
26+
slot holds a thin striped column for every character checked), and no
27+
model file uploads a 4-bit texture there, so a shared lash sheet is
28+
uploaded by engine code from a file not yet identified. Next lead: a
29+
RAM dump of the running game around that upload.
30+
531
## 2026-10-14 — models in Blender, and the texture fixes that came with them
632

733
**Models.** `browse` has a new kind, **Models**: every `.lex` on the disc

0 commit comments

Comments
 (0)