lecodes-assets 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
package/README.md CHANGED
@@ -24,6 +24,7 @@ lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools
24
24
  lecodes assets doctor hero.glb --fix-scale # bake a unit fix (see Scale below)
25
25
  lecodes assets doctor props/*.glb --lightmap-uv -o props/ # + TEXCOORD_1 lightmap UVs (xatlas) for `lecodes lightmap bake`
26
26
  lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
27
+ lecodes assets avatar girl.glb --meta SK_Girl.fbx.meta # a humanoid's bones in the Unity/VRM canon (vs Unity's own map)
27
28
  lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
28
29
  lecodes assets convert Barrel_v1.FBX --list-nodes # multi-variant prop file: what is in it
29
30
  lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one variant, re-origined on the ground
@@ -31,6 +32,9 @@ lecodes assets convert Barrel_v1.FBX --split props/ # every top-lev
31
32
  lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
32
33
  lecodes assets probe hero.glb --filmstrip # a CPU-skinned PNG strip per clip (no GPU)
33
34
  lecodes assets scene Map_v8.unity -o level.json # Unity scene → placed prefabs with world TRS
35
+ lecodes assets unreal ls "Stairs Set/StairsSet" # what an UNREAL pack holds (see below)
36
+ lecodes assets unreal tex T_Manny_01_D.uasset -o manny_d.png # a texture's SOURCE image (needs Oodle, see below)
37
+ lecodes assets convert SKM_Manny.uasset --clips Anims/**/*.uasset -o manny.glb # Unreal mesh + clips → GLB
34
38
  ```
35
39
 
36
40
  ## Props, modular characters, scenes — the Unity-pack workflow
@@ -176,10 +180,111 @@ that decides quality is what the two REFERENCE poses are and what happens where
176
180
  against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
177
181
  (facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
178
182
 
179
- What it does not do yet: foot locking on the target beyond what the donor's feet do (an in-place
180
- donor still treadmills), stride warping when a leg is far too short for the donor's reach (reported
181
- as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios, bipeds/arms (the
182
- auto-map is quadruped-shaped; a humanoid map is best written by hand or by name).
183
+ - **Second pass on proportions** (humanoid plan phase 3, 2026-08-27; the quadruped path shares the
184
+ planting and the root-motion switch):
185
+ - **A humanoid foot = contact + orientation** (2026-08-27). The leg's IK effector is the ankle,
186
+ but its goal comes from the BALL of the foot (the canonical `Toes` bone): the ball goes where
187
+ the donor's ball goes (leg and foot each scaled by their own ratio, stance-corrected), the foot
188
+ keeps the donor's rotation on the target's own flat bind, and the ankle follows from the two.
189
+ Rigs put the ankle at different places over the ball — a boot's foot bone dives 56° to the ball,
190
+ X-bot's 27°, both soles flat: driving the ball with the foot as an IK link let the solver pick
191
+ the foot's pitch (50° off the donor in the swing), transferring the ankle's position AND the
192
+ foot's rotation lifted the boot 10 cm off the ground at toe-off. Now the stance foot is flat,
193
+ toe-off has the heel up and the toes on the ground, and the report says `contact <ball>` when
194
+ it differs from the effector. What the two feet's shapes cost at full extension (a few cm) is
195
+ silent; `reach` reports what the LEG cannot do.
196
+ - **Foot planting** (default; `--no-plant`). While a donor foot stands still on its ground (under
197
+ 15 % of a leg length per second, within 6 % of a leg length of its stance height, ≥ 3 frames),
198
+ the target's foot goal is held at ONE world point — the mean of where the FK would have put it
199
+ over the span, which splits the slide between landing and lift-off; a 4-frame ease at each end
200
+ hides both halves, a span across a cycle's seam is one plant held modulo the clip's travel. A
201
+ span whose mean sits more than 5 % of a leg length from some frame's FK goal is a shuffle, not a
202
+ plant, and is left alone. Per-leg ratios and the hips' own sway otherwise slide a planted foot a
203
+ few cm per step. The planted point is the contact (the ball rolls under a rising heel; the ankle
204
+ moves). Reported per leg (`planted N frames`) and per clip as spans.
205
+ - **Where the travel goes** (`--root-motion auto|hips|root|none`). A rig with a root ABOVE the hips
206
+ gets the donor's horizontal travel on the root and the vertical bob on the hips (`root`, the
207
+ default when such a root exists — a character controller reads the root's travel and must not
208
+ bob with it); `hips` puts everything on the hips as the donor had it; `none` strips the travel
209
+ for an in-place clip (feet still planted in the travelling frame, so they slide back under the
210
+ body like a treadmill). A donor whose hips bob relative to their own root (MoCap Central:
211
+ `root` travels, `pelvis` bobs) has that bob re-applied on the target's hips.
212
+ - **Hands as effectors** (`--ik hands`): the same ratio-scaled goal as a foot, measured from the
213
+ upper arm, chain capped at upper + lower arm, for clips where the hands meet or hold.
214
+ - **Twist helpers** (`--twist N`, default 0.5; `off` = ride rigidly). A helper at fraction `t`
215
+ along its limb bone takes `f·(t·twistOut − (1 − t)·twistIn)` about the bone's axis on top of
216
+ riding it — near the joint it counter-rotates the bone's own axial turn (the shoulder skin stays
217
+ with the clavicle), near the far end it follows the child's (the wrist follows the hand's
218
+ pronation). Helpers come from the avatar's `twist` map (shape classifier or `.avatar.json`).
219
+ - The stance correction inside a foot goal (where THIS rig rests its foot under its hip, against
220
+ where the donor does) is shrunk — never the donor's vector — when it would put the goal past the
221
+ leg's length (a running push-off on a rig whose hips sit higher above its feet); that removed
222
+ the "out of reach" residue on Humanoid_F.
223
+
224
+ What it does not do yet: stride warping when a leg is far too short for the donor's reach (reported
225
+ as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios; for humanoids
226
+ the knee/elbow bend-plane roll of the UPPER arm and the legs —
227
+ [docs/humanoid-retarget-plan.md](../../docs/humanoid-retarget-plan.md).
228
+
229
+ ### Humanoids — avatars
230
+
231
+ A humanoid rig is described by its **avatar**: its bones named in the Unity/VRM humanoid canon
232
+ (`Hips`, `Spine`/`Chest`/`UpperChest`, `Neck`, `Head`, `Jaw`, `LeftEye`…, `LeftShoulder`/`LeftUpperArm`/
233
+ `LeftLowerArm`/`LeftHand`, `LeftUpperLeg`/`LeftLowerLeg`/`LeftFoot`/`LeftToes`, `Left Thumb Proximal`…
234
+ — 55 bones). An avatar belongs to ONE rig; a humanoid map is two avatars joined on the canon, so
235
+ each rig is mapped once and any pair retargets.
236
+
237
+ ```
238
+ lecodes assets avatar girl.glb # read the avatar off the skeleton's shape
239
+ lecodes assets avatar girl.glb --meta SK_Girl.fbx.meta -o girl.avatar.json # check vs Unity's map, write Unity's
240
+ lecodes assets retarget girl.glb --from Swat.fbx --auto-map # both rigs' avatars from shape → map.json
241
+ lecodes assets retarget girl.glb --from Swat.fbx --avatar girl.avatar.json --map auto --clips Walking
242
+ ```
243
+
244
+ `avatar` reads the structure off the bind pose (`src/retarget/humanoid.ts`): the hips are the joint
245
+ two chains hang from that reach the ground on opposite sides; the hands are the lateral joints fanning
246
+ out into short chains (fingers) — a weapon socket or IK target on the hand is shed; the spine is the
247
+ centre path from the hips to where the arms meet; the head is the last branch above the arms (hair
248
+ rigs branch below it, so a `head` name breaks that tie); the toes say which way is forward, and so
249
+ which side is left. Spine bones: 1 → `Spine`, 2 → `+Chest`, 3 → `+UpperChest`, more → first /
250
+ arc-middle / arm-attach with the rest **bridged**; a second neck bone and finger metacarpals bridge
251
+ too. **Twist/roll helpers** (leaf children on their parent's axis — `upperarm_twist_01_l`,
252
+ `calf_twist_02_r`) are recorded as `twist: { bone: canonical }`, never mapped. Names only break ties
253
+ (thumb vs index when both are named; a `jaw` child) and the report says when they did. Everything
254
+ else (hair, cloth, `ik_*`, bag, breasts) is listed as "not part of the body" and keeps its bind.
255
+
256
+ **A person's word rides in the avatar** (`.avatar.json` `transfer`, written by the character studio,
257
+ read by `retarget --avatar`): `align: { <canonical bone>: "keep" | "align" }` — this bone is BUILT
258
+ that way (keep the bind, whatever the angle) or POSED (align to the donor, the 8° threshold
259
+ notwithstanding; a standing leg too); `roll: { Left?, Right? }` — degrees added to the measured palm
260
+ roll per arm, for a finger that lands almost right; and the transfer settings (`rootMotion`,
261
+ `plant`, `hands`, `twist`) the CLI takes as defaults — flags still win. The report lists
262
+ `under 8° kept` bones with their angle so the decision can be checked, and `avatar transfer: …`
263
+ says what the file asked for.
264
+
265
+ **The humanoid transfer** (`retarget` with both avatars — `--map auto`, `--avatar`, or a map.json
266
+ that carries `avatars`) keeps the world-delta machinery above and decides the reference by canonical
267
+ ROLE: hips, spine, neck, head, face and (standing) legs keep their bind; shoulder → upper arm →
268
+ forearm → hand and every phalanx align to the donor's bind directions with explicit references (the
269
+ hand to its finger bases), but only past an **8° threshold** — under it a mismatch is joint placement
270
+ and stays, over it is posture (an A-pose against a T-pose, a bent elbow) and turns. Direction fixes
271
+ two of three degrees of freedom; the **palm roll** — where the palm faces, the thing no direction can
272
+ see — is read off the knuckle line (index base → little base) across the forearm, and the forearm
273
+ chain turns until the target's line lies where the donor's does. Metacarpals ride the hand rigidly.
274
+ The donor is referenced to its bind (its clips are authored on it); a target `root` above the pelvis
275
+ that shares the donor's hips takes the hips' travel only. An identity retarget is exact
276
+ (`tests/humanoidRetarget.test.ts`, key reduction off); Mixamo Walking on the zoo lands arm directions
277
+ within 3° and palms within 10° on every frame.
278
+
279
+ A Unity model importer's `.meta` carries Unity's own humanoid map: `--meta` diffs the shape result
280
+ against it — the zoo in the plan agrees on every bone but the Chest/UpperChest picks on a 5-bone spine
281
+ — and `-o` with `--meta` writes Unity's map as the `.avatar.json`. `retarget --avatar` / `--donor-avatar`
282
+ accept either file; without them both rigs classify from shape (a biped↔quadruped pair is refused).
283
+
284
+ Bind space: IBMs live in the MESH's geometry space. When an exporter parks the Z-up→Y-up turn on a
285
+ geometry helper node (Female Runner's `Humanoid_F`) or ×100 on the mesh node (Maya "actor"), `rig.ts`
286
+ folds that node's world transform in front of `inv(IBM)` — kept only when it brings the bind closer to
287
+ the node rest pose.
183
288
 
184
289
  ### Donors whose clips are not in the FBX
185
290
 
@@ -196,6 +301,24 @@ lecodes assets retarget fox.glb --from Cat.fbx --map fox-from-cat.map.json --a
196
301
  Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
197
302
  takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
198
303
 
304
+ **Skeleton-only clip files** — the other pack convention (Female Runner, MoCap Central, Mixamo
305
+ "without skin"): each clip is an FBX of nodes + curves and NO mesh, so it has no skin to classify
306
+ and no bind to measure from. `--donor-rig <model.fbx|.glb>` names the rig it was animated on; the
307
+ clips bind to it by bone name (exact, then without the `namespace:` prefix — `rebindClips` in
308
+ `rig.ts`) and the model's bind, skin and avatar carry the motion:
309
+
310
+ ```
311
+ lecodes assets retarget vex.glb --from MCU_af_Loco_Walk_Fwd.FBX --donor-rig rig_MCUnity.fbx --map auto -o vex-mc-walk.glb
312
+ ```
313
+
314
+ Whether a clip file IS the character's own rig (copy the tracks by name, the Mixamo flow) or only
315
+ shares its naming convention (two Unreal-style rigs — retarget) is decided by `sameSkeleton`:
316
+ ≥ 80 % of the names must match AND every matched bone ≥ 5 cm must keep its local offset's
317
+ DIRECTION (the child in its parent's frame — fixed by the rig, unlike the node rotations, which
318
+ a clip file carries posed at its first frame). A Mixamo character against another's clip stays
319
+ within ~20°; MoCap Central's rig against IdaFaber's turns the pelvis by 88° — a copied clip would
320
+ lay the character down.
321
+
199
322
  ### What a short spine cannot borrow
200
323
 
201
324
  Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
@@ -221,6 +344,104 @@ CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb
221
344
 
222
345
  For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
223
346
 
347
+ ## Unreal packs — `.uasset` skeletons, clips, meshes
348
+
349
+ An Unreal asset-store pack is a Content folder of **uncooked editor packages**: one `.uasset` per
350
+ asset with everything inline. `convert`, `probe` and `retarget` read them directly — no Unreal
351
+ install, no FBX export step:
352
+
353
+ ```
354
+ lecodes assets unreal ls "Stairs Set/StairsSet" # triage: classes, rigs, what is unreadable and why
355
+ lecodes assets probe .../A_Stairs_WalkFwd_Up_Loop.uasset # engine, data model, skeleton, keys, root travel
356
+ lecodes assets convert .../SKM_Manny.uasset --clips .../Animations/Mannequin/**/*.uasset -o manny.glb
357
+ lecodes assets convert .../A_Stairs_WalkFwd_Up_Loop.uasset -o walk.glb # clip-only GLB (its skeleton + the clip)
358
+ lecodes assets convert hero.glb --clips .../A_Stairs_WalkFwd_Up_Loop.uasset # merge one onto YOUR character by bone name
359
+ lecodes assets retarget vex.glb --from .../SKM_Manny.uasset --anim .../Walk/*.uasset --map auto --root-motion hips -o vex-stairs.glb
360
+ ```
361
+
362
+ **Both data models are read.** An `AnimSequence` keeps its raw motion in one of two places, and a
363
+ pack's choice is not yours: `AnimDataModel` is bone tracks (what an FBX import writes), while a clip
364
+ authored or edited in **Sequencer** (UE 5.2+, through a Control Rig) keeps the same motion as
365
+ MovieScene channels — one FK control per bone, nine `FMovieSceneFloatChannel`s each (3 translation,
366
+ 3 rotation as an `FRotator` in degrees, 3 scale). `sequencer.ts` evaluates those onto the clip's own
367
+ frame grid, so callers never learn which model a file used. The grid lands exactly on the authored
368
+ keys (checked across a pack's 48 Sequencer clips: 0 ms offset, 0 value error), and the channels'
369
+ constant values reproduce the skeleton's reference pose to 1e-4 cm and 0.001° — which is what pins
370
+ the channel order and the `FRotator` convention.
371
+
372
+ **What is read.** `SkeletalMesh` (LOD geometry, UVs, skin weights, bone list — from the editor
373
+ import data the FBX importer left behind), `Skeleton` (bones + reference pose), `AnimSequence`
374
+ (the raw uncompressed bone tracks of its `AnimDataModel`, plus fps, length, root-motion flag,
375
+ additive type, sync markers / notifies), and `Texture2D` — the SOURCE image the artist imported,
376
+ not the compressed mips a cooked build ships (see **Textures** below).
377
+
378
+ **Where a clip's skeleton comes from.** An AnimSequence names it only as an object path
379
+ (`/Game/StairsSet/Demo/Mannequins/Meshes/SK_Mannequin`), where `/Game/` is the pack's Content
380
+ folder — which nothing on disk marks. The path is turned into a relative file name and looked for
381
+ walking up from the clip; the first directory that answers IS the Content root. `--skeleton
382
+ <SK_*.uasset>` names it outright, `--content-root <dir>` names the folder. A `SKM_*.uasset` works
383
+ as a skeleton too (its own bone list is the bind reference).
384
+
385
+ **Coordinates.** UE is centimetres, X-forward/Y-right/Z-up and left-handed; glTF is metres, Y-up
386
+ and right-handed. A `.uasset` GLB has to be interchangeable with one made from the same pack's FBX
387
+ (both feed `--clips` and the retargeter), so the conversion reproduces what UE's FBX exporter plus
388
+ ufbx do: mirror Y on every local transform, and turn the ROOT bone −90° about X. Checked against
389
+ the Stairs pack's own `SourceFiles.zip`: all 267 channels of a clip match by bone name to
390
+ **1e-5 m / 0.02°** (`tests/unreal.test.ts`), and the recipe's composition is pinned by a unit test —
391
+ the per-bone rules must add up to the global `(x, z, y)·0.01` map on world points.
392
+
393
+ **Limits** (each one says so at the point it bites, rather than producing a broken GLB):
394
+
395
+ | Not supported | What you get |
396
+ |---|---|
397
+ | UE 5.4+ / UE 5.5+ packages, UE4 packages, cooked assets | refused by version, with the engine that saved the file |
398
+ | meshes whose LODs were generated in-engine (no import data — the Stairs pack's UE4 mannequin) | refused; only the skeleton of such a mesh can be read |
399
+ | morph targets, curves, sockets, physics assets, Oodle-compressed MESH bulk | reported, not guessed at |
400
+
401
+ Twelve bones per vertex become four (top by weight, renormalised) — the trim is reported as a
402
+ percentage of redistributed weight. Triangle winding is decided from the data (the mirror reverses
403
+ handedness) rather than hidden behind a double-sided material.
404
+
405
+ **Textures.** A texture asset keeps its source image (usually the original PNG) inside an
406
+ `FCompressedBuffer`, compressed with **Oodle** — proprietary, so nothing is shipped here. The
407
+ decoder is loaded from a library the machine already has:
408
+
409
+ ```sh
410
+ export LECODES_OODLE=".../Engine/Binaries/ThirdParty/Oodle/Win64/oo2core_9_win64.dll" # any UE install
411
+ # or drop the file into ~/.lecodes/oodle/ — Unreal installs are also found automatically
412
+ lecodes assets unreal tex T_Manny_01_D.uasset -o manny_d.png # one texture, out
413
+ lecodes assets convert SKM_Manny.uasset -o manny.glb # textures wired to the materials
414
+ ```
415
+
416
+ Without it the conversion still runs and says so once; only the materials stay grey. With it, each
417
+ slot's `MaterialInstanceConstant` is read and its `TextureParameterValues` are wired to glTF:
418
+ base colour, normal, and ORM (UE packs occlusion/roughness/metallic into R/G/B, which is exactly
419
+ what glTF reads). Two fixes are applied on the way, both required and both verified against the
420
+ render: a `TSF_BGRA8` source has its red and blue channels the other way round (without the swap the
421
+ mannequin comes out blue), and UE authors normal maps DirectX-style, so the green channel is flipped
422
+ for glTF (`--no-normal-flip` keeps them as they were). Textures are capped at `--max-size` (2048)
423
+ and re-encoded — JPEG unless the alpha channel is actually used.
424
+
425
+ What is NOT wired: everything the material graph would have to be evaluated to understand. The UE5
426
+ mannequin's material names seven textures — a bent normal, an aniso/AO/paint mask, a clear-coat
427
+ mask, an MSR pack, a tangent map — and only the base colour and the normal map have an honest glTF
428
+ slot. The rest are listed in the report (`not in glTF: …`) rather than guessed into one. Scalar and
429
+ vector parameters (colour tints, roughness powers) are read but not applied for the same reason:
430
+ gear comes out in its neutral colours.
431
+
432
+ **Retargeting from a UE rig.** `--from x.uasset` builds the rig directly. The UE5 mannequin weights
433
+ its limbs to the twist helpers (`thigh_twist_01_l`) and leaves `thigh_l` itself without a single
434
+ vertex, so "part of the body" is the skinned subtree's INTERIOR, not just the bones carrying
435
+ weights — otherwise the humanoid classifier finds no legs and refuses the rig. Unskinned ends
436
+ (`ik_foot_root`, `ik_hand_gun`, `weapon_l`, `interaction`, `center_of_mass`, `root`) stay out of the
437
+ body, which is what they should be.
438
+
439
+ Stair clips carry vertical root travel (0.40 m per loop on the Stairs pack's walk-up). `--root-motion
440
+ hips` keeps the climb on the character; `auto`/`root` treat vertical motion as hips bob and would
441
+ swallow it. Feet below the origin plane on a walk-DOWN clip are reported as "toe under the ground" —
442
+ expected for stair content, where the ground is not flat.
443
+
444
+
224
445
  ## Scale
225
446
 
226
447
  `probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
@@ -301,6 +522,16 @@ GLB on the Filament wasm engine, binds every clip through the native Animator, s
301
522
  pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
302
523
  against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
303
524
 
525
+ The Unreal reader was verified against two real packs — *Stairs Set UE 5.0-5.4* (UE 5.0: 159
526
+ packages, 84 AnimSequences on two mannequins, a 26 MB zlib-chunked mesh bulk) and a UE 5.3 modular
527
+ character pack (796 packages, raw bulk, all 48 clips Sequencer-authored) — every package parsed or refused
528
+ with a reason, and the clips compared channel-by-channel against the pack's own FBX sources.
529
+ `tests/unreal.test.ts` runs those fixtures when `LECODES_UE_PACK` (or the default pack folder)
530
+ exists, plus format/math unit tests that always run. The texture path was checked against the
531
+ predecessor Python extractor with the same Oodle library: the images come out BYTE-IDENTICAL on
532
+ both packs (67 MB, 21 MB and 1 MB payloads), and the converted mannequin renders with its own
533
+ materials through `packages/viewer`'s Filament check.
534
+
304
535
  Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
305
536
  conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
306
537
  space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
@@ -317,7 +548,11 @@ src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebui
317
548
  src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
318
549
  src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
319
550
  src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · lightmapUv (xatlas wasm) · validateGlb (mirror of viewer's)
320
- src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (skeleton classification + chain pairing) · unityAnim.ts (Unity .anim YAML reader)
551
+ src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (quadruped classification + chain pairing) · humanoid.ts (humanoid canon, biped classifier, Unity .meta avatars, avatar↔avatar map) · unityAnim.ts (Unity .anim YAML reader)
552
+ tools/humanoid-survey.ts bind-pose survey of the humanoid zoo (arm drop / elbow / facing / proportions per rig)
553
+ tools/humanoid-phase3.ts every zoo clip onto every zoo target: planted frames, reach, twist, root motion (--strips for filmstrips)
554
+ src/retarget/ghost.ts a rig's skeleton alone with one clip — the character studio's donor "ghost"
555
+ src/unreal/ package.ts (uncooked UE5 .uasset: summary/names/imports/exports/tagged properties/bulk data) · skeleton.ts · anim.ts (both data models) · sequencer.ts (MovieScene ControlRig channels) · mesh.ts (FSkeletalMeshImportData) · convert.ts (the UE→glTF coordinate recipe) · toDocument.ts · clipSource.ts (--clips merging) · rig.ts (retargeter) · resolve.ts (object path → file) · texture.ts (FCompressedBuffer + FTextureSource) · oodle.ts (the machine's own oo2core, via bun:ffi / koffi) · materials.ts (material instances → glTF slots) · probe.ts · list.ts (unreal ls) · convertAsset.ts · index.ts
321
556
  src/unity/ unpack.ts (.unitypackage streaming) · materials.ts (.mat → glTF materials, ORM packing) · png.ts · scene.ts (.unity → level JSON)
322
557
  src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
323
558
  src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames