docs(skill): the surface table, rig anchors, and asserting the consequence

The skill is this project's own instructions, and three commits made it wrong:
it described a runtime that re-guessed what every surface was, a lab that only
tuned weapon holds, and a suite that could not see any of the last five defects.
It also still listed a seventh character that is not in skins.json.

Records what is new — the surface table and how it is decided, the layered
tuning files and why a fixed rotation on the weapon mount is the wrong answer,
the rig lab — and what the work taught:

  * assert the CONSEQUENCE. Asking a model which clip it is playing reads a
    variable it set on itself, and says "Idle" just as happily when nothing is
    ticking. Asking whether an anchor saved says nothing about whether the gun
    moved.
  * four of the last five real defects came from looking at a PNG. Every one of
    them passed every assertion.
  * the line-work rule now exists twice, at build time and as the runtime
    fallback, and they must be changed together or a model with a surface table
    starts rendering differently from one without.

Also records momo's broken idle and the stray Icosphere under known-unsolved,
with what is worth suspecting first in each case, since neither is fixed.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-27 15:00:42 -04:00
co-authored by Claude Opus 5
parent 99a2ee131d
commit 1e6f3001ac
4 changed files with 199 additions and 21 deletions
+103 -14
View File
@@ -72,9 +72,69 @@ Blender is required (`BLENDER_PATH`, or auto-found under
| Subdivide cloth panels | `retarget.py::subdivide_cloth_panels` | A skirt being able to bend at all |
| Retarget clips as rest-relative deltas | `retarget.py::retarget_clip` | Limbs not being twisted by foreign bone roll |
| Leave cosmetic bones unkeyed | `export_optimize_animation_keep_anim_armature=False` | Physics owning the cloth |
| Classify every SURFACE | `tools/surface_map.py` | The runtime never re-guessing what a surface is |
| Write the rig sidecar | `retarget.py::describe_rig` | The runtime never re-guessing anatomy |
| Cel look | `LevelMaterials.apply_character_look` | The model's own line-work not being re-lit |
| Cel look, per surface class | `LevelMaterials.apply_character_look` | Hair not reading as a solid dark cap |
| Cloth + hair | `characters/spring_bones.gd` | Clothes reading as clothes |
| Per-character judgement calls | `characters/tuning_store.gd` | Art direction not becoming another constant |
## The surface table
The sidecar carries a `surfaces` list saying what each mesh surface IS — `body`,
`cloth`, `hair`, `accessory`, or `linework` (the model's own ink shell, which is
not a surface of the character at all). It is keyed on the MATERIAL name, because
every character in this game arrives with its meshes called `Object_7` through
`Object_32` while material names survive the glTF round trip intact.
`SkinSurfaces` reads it and `apply_character_look` acts on it: hair takes a much
thinner outline than the body, cloth a heavier one and a crisper terminator,
accessories the heaviest. `SkinnedPlayerModel.surfaces_of(cls)` answers the
question for anything else that needs it.
Backfill a character that predates it, without re-importing:
```bash
blender --background --python tools/surface_map.py -- \
assets/characters/skins/<name>.glb
```
New imports get it from `describe_rig`, built from the same chains the solver
uses, so the surface table and the cloth solver can never disagree about which
bones are a skirt.
## Per-character judgement
Anything derivable from the skeleton is derived. What is left is genuinely an
artist's call, and it lives in layered JSON rather than in a constant:
| File | Scope | Class |
|---|---|---|
| `assets/characters/weapon_holds.json` | character + weapon | `WeaponHoldTuning` |
| `assets/characters/rig_anchors.json` | character | `RigAnchors` |
Both layer `defaults``skins.<skin>._all``skins.<skin>.<subject>` through
`TuningStore`. An absent file means "use what the code derives", so nothing here
is required for the game to run. Adding a knob is adding a row to a `KNOBS` spec
table — the lab builds its whole UI from those.
`RigAnchors` is where "the grip sits here in the palm" lives. A hand bone's
origin is the WRIST; how far down the palm a grip belongs depends on the
character's hand and cannot be derived. It defaults to identity, and identity is
exactly the derived mount. Do **not** put a fixed rotation on the weapon mount
instead — a bone attachment is expressed in the BONE's axes, no two rigs agree on
those, and that constant is why the hand mount points were once wrong on every
character.
## The rig lab
```bash
godot --path . res://debug/rig_lab.tscn
```
Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
(character + weapon) and the ANCHORS (character), and save. Click a surface class
to isolate it — that is how the classifier gets checked: click `hair` and
anything else still standing was misclassified.
## Read before you touch anything
@@ -129,13 +189,30 @@ is a list of things that cost a debugging cycle to learn.
## After importing
```bash
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
godot --headless --path . -s res://debug/surface_class_check.gd # every surface classified
godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster
godot --headless --path . -s res://debug/rig_anchor_check.gd # anchors move the weapon
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
```
And LOOK at it, which is where four of the last five real defects were found:
```bash
godot --path . -s res://debug/roster_capture.gd -- <dir> # every character, one shot each
godot --path . -s res://debug/ui_capture.gd -- <dir> # every menu screen
godot --path . res://debug/rig_lab.tscn -- shot <png> <skin>
```
`surface_class_check` fails on any surface that falls through to the heuristic
rather than resolving from the table. That is deliberate: a model whose names
stopped matching still RENDERS, because the fallback catches it — it just
quietly loses its per-class art direction, which is exactly the kind of
regression nothing else would report.
What "good" looks like on Taila, for calibration:
| Measure | Good | Bad |
@@ -149,15 +226,16 @@ What "good" looks like on Taila, for calibration:
## Characters currently shipping
| Skin | Source | Meshes | Cloth chains |
The six in `skins.json`, with what `surface_class_check` reports:
| Skin | Source | Cloth chains | Surfaces |
|---|---|---|---|
| taila | rigged, Sketchfab CC-BY | 18 | 35 |
| kiyoko | VRoid, CC-BY | 13 | 20 |
| aria | VRoid, CC-BY | 15 | 15 |
| momo | VRoid, CC-BY | 5 | 9 |
| hikari | VRoid, CC-BY | 13 | 10 *(zero-length — do not simulate)* |
| miku | unrigged source, auto-rigged | 1 | 19 *(hair grown)* |
| mannequin | Quaternius CC0, from the animation library | 1 | 0 *(no costume)* |
| taila | rigged, Sketchfab CC-BY | 35 | 18 — body 6, cloth 7, hair 1, linework 4 |
| kiyoko | VRoid, CC-BY | 20 | 13 — body 8, cloth 3, hair 2 |
| aria | VRoid, CC-BY | 15 | 15 — body 9, cloth 4, hair 2 |
| momo | VRoid, CC-BY | 9 | 5 — body 2, cloth 1, hair 2 |
| miku | unrigged source, auto-rigged | 0 | 4 — body 3, hair 1 *(one mesh, four surfaces)* |
| mannequin | Quaternius CC0, from the animation library | 0 | 2 — body 2 |
## Known-unsolved
@@ -170,3 +248,14 @@ What "good" looks like on Taila, for calibration:
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
still runs its legs ~40° off the direction of travel.
- **momo's idle pose is wrong** — arms overhead and a pinched waist. Every
assertion passes on her: she loads, animates, classifies and mounts a weapon.
It shows up only in `roster_capture`. Her source rig is the reason to suspect —
its bones are auto-named (`Item_O_Sphere_005`, `Unused_Noname_004`,
`Root_001_001`), so the role resolver has almost nothing to go on, and a limb
role claimed by the wrong bone would look exactly like this. Start by dumping
her resolved roles against her skeleton.
- **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent,
no vertex groups. It rides in from the animation library. Harmless, and now
skipped by construction rather than by name in `surface_map`, but the export
should not be producing it.