Compare commits

...
Author SHA1 Message Date
Nicholas Butzke bb34764d59 leg jump swap ani fix 2026-08-10 12:57:41 -04:00
Nicholas Butzke 0ce626ad42 jump ani fixing and transition animations 2026-08-10 12:50:00 -04:00
Nicholas Butzke 833e936bcd ani fixed 2026-08-10 12:17:59 -04:00
Nicholas Butzke 9358746582 ani 2026-08-09 01:31:44 -04:00
Nicholas Butzke 922983429e big 2026-08-02 02:20:02 -04:00
Nicholas ButzkeandClaude Opus 5 61669627db chore(debug): track the UID Godot generated for menu_capture.gd
Godot writes a .uid sidecar for every script and this one landed after the
commit that added debug/menu_capture.gd. All 140 of the others are in version
control; an untracked one drifts to a different UID on the next machine that
imports the project.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 13:09:15 -04:00
Nicholas ButzkeandClaude Opus 5 2efc21b18d feat(menu): PLAY is one press, and the level cards are photographs
Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.

PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.

This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.

The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:

  - framing each map from OUTSIDE by merging every VisualInstance3D's AABB
    produced five tiny dioramas floating on a table, and one solid black
    rectangle. That is a minimap, and a minimap is not a photograph.
  - standing at a spawn point fixed three maps and left two black: fps_blockout
    is genuinely dark and most of its spawns face an unlit wall, and
    procedural_arena builds its geometry at runtime so no fixed offset is
    reliably inside it.
  - so the tool now RENDERS several vantages and scores each result — mean
    luminance times its standard deviation, because brightness alone picks the
    empty sky and variance alone picks a high-contrast corner of a dark room.
    Their product picks a photograph. A black rectangle passes any check that
    only asks whether the camera ended up somewhere sensible.

Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.

`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.

The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.

spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:40:50 -04:00
Nicholas ButzkeandClaude Opus 5 986179854d feat(modes): matches now end, and something is at stake when they do
There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.

globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:

  Deathmatch        25 frags or 10 minutes.
  Team Deathmatch   two squads to 50, teams balanced by COUNT on join (a 4v4
                    that loses three from one side must refill the short side;
                    round-robin on join order leaves it 4v1 forever), friendly
                    fire off and enforced on the server before damage is even
                    broadcast — a mode where the damage lands but the kill does
                    not count is worse than either.
  Gun Game          every kill promotes you a rung and swaps your weapon. The
                    score IS the rung and the limit IS the ladder's length, so
                    finishing the ladder and reaching the score limit are the
                    same event and only one win condition exists.

`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.

Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.

ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.

Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.

spawn smoke 0, game modes 30/30, movement 11/11.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:28:32 -04:00
Nicholas ButzkeandClaude Opus 5 f1a4f7df52 feat(emotes): five dances, built like animation, behind a radial dial
The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.

Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It floats. It has no weight, no
accent, and no sense that one part of the body is driving and the rest is
following. Four principles fix it, and all four are cheap:

  OVERLAP        the body is a chain. Hips lead, spine follows a beat later,
                 head last. One subtraction — `beat - lag * i` — and the spring
                 solver then carries it out through the hair and skirt for free,
                 because the dance layer runs before it.
  ACCENT         a dance HITS poses. `shape` bends the wave so it hangs at the
                 extremes and snaps between them, which is what a key-and-
                 breakdown pass produces by hand.
  WEIGHT         the HIPS translate, not just rotate. A body that never leaves
                 its own axis reads as a puppet on a stick.
  CONTRAST       Robot deliberately breaks all of the above — zero lag,
                 quantised motion — and reads as mechanical precisely because
                 the other four do not.

Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.

The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.

debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:

  - correlating the hips' TRANSLATION against the head's position relative to
    them compared two different quantities at different periods; it ranked the
    Robot, whose lag is zero by construction, as the most overlapped routine.
  - a signed scalar `angle * sign of the axis's largest component` is
    DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
    so smooth Two-Step measured a full-range jump per frame, which is exactly
    what quantised motion looks like.
  - a bone's GLOBAL rotation carries every ancestor's, so the head correlates
    with the hips at lag zero however delayed the head itself is.
  - and the hips and head are driven by different channels anyway.

Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.

RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.

spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:20:33 -04:00
Nicholas ButzkeandClaude Opus 5 a13ae50f95 feat(rig): every weapon gets its own hold, so the silhouette names the gun
`_apply_rifle_hold` did exactly what its name said, to everything. A knife, an
AWP and a rocket launcher were all solved as a rifle — stock in the shoulder
pocket, support hand out along the barrel, muzzle on the aim line — so in third
person every character stood the same way whatever they carried, and the only
thing distinguishing a sniper from a shotgun was the ~30 cm of gun mesh in
their hands. At the distance an enemy is usually seen that is nothing.

It costs more than looking wrong. A character's pose is the fastest available
answer to "what is about to happen to me": a tube on a shoulder means take
cover, a blade held low means they have to close, a rifle at low ready means
they have not seen you. One hold throws all of that away. It is also the thing
HoYoverse's team say they chase in Zenless Zone Zero — characters read by
silhouette first, and they refuse to settle on one construction method because
one method limits how distinguishable the results can be.

weapons/weapon_hold_profiles.gd gives each of the twelve weapons a style, and a
style is not a bundle of slider values. Three of its differences cannot be
expressed on the rifle solve at all, and those are the ones that carry:

  support   WHERE the off hand goes and HOW IT IS TURNED there — wrapped round
            a handguard, cupped against the firing fist, hooked under a tube
            palm-up, or released entirely. Sending a hand somewhere new without
            re-orienting it gives a hand teleported to the new spot still shaped
            for the old one.
  mount     whether the weapon's rear sits IN the shoulder pocket, ON TOP of the
            shoulder, or nowhere near it.
  head      whether the head comes down to the stock, or leans away to clear a
            tube. The cheek weld is the sniper silhouette, and its inverse is
            what says a launcher is resting on that shoulder.

A blade RELEASES the off arm back to the animation, so it swings with the run
cycle instead of gripping a handguard that is not there — most of what makes a
one-handed weapon read as one-handed — and closes a full fist, because an index
left straight along a knife handle reads as a mistake, not as discipline.

Layered strictly UNDER the existing tuning, so aria's hand-tuned AK-47 hold is
byte-for-byte what it was. WeaponHoldTuning.default_for is weapon-aware now,
which it had to be: the rig lab SAVES every knob it shows, so without it,
opening the lab on the knife and pressing save would silently overwrite the
blade profile with the rifle spec and put the character back to holding a knife
like an AK with nothing to indicate it had happened.

debug/weapon_hold_check.gd asserts the consequence, not the plumbing — storing
an enum and reading it back proves nothing. It measures where the hands and head
ACTUALLY end up, from inside the modifier pass (outside it, Godot restores the
local poses and every weapon reports an identical rifle) and in the shoulder's
own frame, because the hold breathes and two samples of the SAME weapon
otherwise differ by more than two different weapons do. 21 weapon pairs, all
distinguishable; the knife's off-hand weight measured at 0.01.

Two findings only measuring produced. Pushing `gun_fore` further out for the
sniper does NOTHING — the reach solver slides the support hand back down the
handguard until the arm can get there, so it landed at 0.388 m against the
rifle's 0.387. Raising the whole weapon is what makes a scoped rifle read.

And one only LOOKING produced, via debug/hold_capture.gd: the shotgun's barrel
passed through the character's chest. Every assertion passed — the hands were
exactly where they had been asked to go — but +x is toward the centreline, so
dropping the pocket and pushing it across at once swings the muzzle into the
torso.

rig_anchor_check still reports aria: her hand-tuned wrist rotates the anchor
offset. Pre-existing, and this halves it — it was 2 failures on main, now 1.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:05:43 -04:00
Nicholas ButzkeandClaude Opus 5 c4bcbc7fd1 feat(ui): the first-person HUD is one thing, in the game's own hand
The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.

Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.

What each piece now says, rather than merely shows:

  ui/crosshair.gd    one drawn reticle instead of five rectangles, so it can
                     BLOOM — open with speed, airtime and each shot, snap shut
                     on ADS. That is the accuracy readout of the whole game and
                     five ColorRects could not express it. Every stroke is
                     drawn twice, ink underneath, because a 2 px white line
                     disappears over pale concrete exactly when aim matters.
                     The hit confirmation is the same cross at 45 degrees, so
                     it lands where the eye already is.
  ui/vital_bar.gd    segmented, so remaining health can be COUNTED rather than
                     estimated, with a drain ghost that holds the old value for
                     a beat — the gap between fill and ghost is the size of the
                     hit, which a bar that merely gets shorter never tells you.
  ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
                     tinted JPEG with a 12 px number under it. A shape changing
                     size is readable in peripheral vision; 12 px type is not.
  chain meter        promoted out of the debug panel. Movement is this game's
                     first stated pillar and chaining is its skill expression,
                     so the count is a score, not a diagnostic.

The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.

debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.

DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.

spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 11:51:22 -04:00
Nicholas ButzkeandClaude Opus 5 414026f001 feat(ui): every control state is checked for readability, not trusted
The palette has two light accents and one very dark one, and a control
changes its FILL on hover and press. Paper text that reads at 18:1 on the
resting near-black chip inverts to paper-on-yellow the moment the pointer
arrives, which is 1.1:1 — invisible. That is the standard way a stylised UI
becomes unreadable, and it was live on the OptionButton dropdown that every
settings row uses: PopupMenu draws its papaya hover fill but keeps
`font_color` unless `font_hover_color` is set, and it was not set.

So the label now follows the fill. `ink_for(fill)` picks the legible glyph
colour by contrast ratio, and every state's text, icon and outline is derived
from its own fill through it — including the states nobody remembers exist:
`hover_pressed` on a toggle (a CheckButton read as OFF while you touched it),
icon colours on a CheckBox that is all icon, and the list hover that used to
be the same papaya as list SELECTION, so the row you pointed at looked like
the row you had chosen.

debug/ui_contrast_check.gd interrogates the BUILT theme rather than the
palette — a table compared against itself agrees by construction and catches
nothing — and fails below the WCAG floor. It found the PopupMenu gap, a Tree
hover asking for a `font_hovered_color` Godot does not have, and the health
bar's readout at 2.4:1 over its own fill.

That last one is not fixable as a colour pair: a centred readout straddles a
hot papaya fill and a near-black trough, and no single colour beats both. What
carries it is the heavy ink outline this theme puts on every glyph, which is
its first stated rule and the same mechanism that keeps menu text legible
straight over the 3D scene. The check models that as a fallback route —
outline vs backdrop 3:1, glyph vs outline 4.5:1, at least 4 px — granted only
where the backdrop genuinely varies, and substituting two ratios for one
rather than waiving the requirement.

Disabled text moves from 3.2:1 to 5.2:1 on the way past. A greyed-out
"Start Match" is information; an illegible smudge is not.

Also styled, because the theme had simply never mentioned them and Godot's
defaults are grey-on-grey: scrollbars, Tree, SpinBox, ProgressBar, tooltips,
LineEdit read-only and selected text.

108 pairs checked, all passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 02:39:25 -04:00
Dotts f0b0d19847 Merge pull request 'Feat/outline thickness and tp weapon hold' (#22) from feat/outline-thickness-and-tp-weapon-hold into main
Reviewed-on: #22
2026-07-27 23:22:53 -07:00
Nicholas ButzkeandClaude Opus 5 742d68e318 chore(characters): Aria's AK-47 hold, tuned in the lab
The first tuning pass saved through the rig lab, and the first content in
assets/characters/weapon_holds.json. Aria holding the AK-47 at low ready: weapon
scaled to 0.53, both wrists set, both elbow poles placed, and both hands moved
along and off the barrel line.

Authored by hand at the sliders, not derived — which is the whole point of the
file. Every value here is one the code cannot work out from the skeleton, and
the layering means it applies to this character and this weapon only; every
other character still gets what the code derives.

Also picks up the .uid Godot generated for debug/wrist_gun_check.gd, which was
committed a moment before its companion existed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 02:12:28 -04:00
Nicholas ButzkeandClaude Opus 5 e97de9aafd fix(weapons): the wrist turns the hand, and the gun stays on the aim line
The weapon is a child of a BoneAttachment3D on the trigger hand, so the two were
welded by construction. Every degree `wrist_r` turned swung the barrel the same
degree off the aim line — and took with it every control that could have
corrected for it, because they are all expressed relative to that same hand.
There was no combination of sliders that aligned a hand to a gun, which is the
one thing the knob exists for.

Both outcomes are now computed where the hand's local pose is set: the rotation
the hand would take without the wrist offset, and the one it takes with it. The
hand gets the second; the difference between them is exactly the counter-rotation
the weapon mount needs, in the hand's own local frame, and
`SkinnedPlayerModel._hold_weapon_still` applies it to the mount each frame. The
gun ends up precisely where the solver put it.

That also gives the two controls a clean split, which is what makes them usable
together:

  TRIGGER / SUPPORT WRIST (hold)     turns the HAND, gun stays on the aim line
  Grip roll / pitch / yaw (anchors)  turns the GUN inside the hand

Identity when `wrist_r` is untuned, so a character nobody has tuned mounts its
weapon exactly as before.

Applied in `_process` rather than inside the modifier pass on purpose. The gun's
mount is not something the skeleton owns, and the compensated value only changes
when a slider moves or the ADS blend travels, so one frame of lag is a fraction
of a degree; reaching into the modifier to touch a scene node would be worse.

wrist_gun_check asserts both halves, because only asserting the first is how
this shipped broken: the hand must TURN, or the knob does nothing, and the gun
must NOT, or the knob cannot be used. Across both poses and all three axes the
hand turns 28.2-28.7 degrees for a 0.5 rad knob and the gun moves 0.1-0.6 —
against the ~28 it would move if it were still following the wrist. The residue
is the arm's own IK settling, since the hand's rotation feeds the chain that
places the shoulder.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 01:43:36 -04:00
Nicholas ButzkeandClaude Opus 5 a97ccca13c feat(rig lab): wrists in three axes, and sliders that belong to the pose on screen
Three things the lab could not do.

WRISTS. Each hand had one scalar, a twist about the barrel. That is the only
axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is
solved — which is true of the support hand, was never true of the trigger hand,
and in neither case left a way to cock a wrist forward or break it inward. Both
now take pitch, yaw and roll, applied in the GUN's frame so the three sliders
mean the same thing whether the muzzle is down at low ready or level down the
sights. Zero is exactly the old behaviour, since the roll term defaulted to zero
too.

HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at
which each hand sits, and they were labelled by what they measure rather than by
whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel
shifts for the same two hands, so the four controls that place a hand read as
four controls that place a hand.

POSES. The hold's knobs are now per pose, and the lab shows one pose's at a
time. Half of them mean something different at low ready than down the sights;
showing both sets at once meant every slider on screen was for one of two poses
with nothing saying which. Selecting a pose rebuilds the panel.

Two poses, not four, and deliberately: the runtime blends between exactly two
holds on `ads`. Running and Crouched are locomotion states that still use the
low-ready hold, so they edit the same numbers — and the heading says so, rather
than letting someone tune "Running" and wonder why standing still changed.
Offering four independent tunings would be inventing a capability the code does
not have, and the fourth would silently do nothing.

`pitch` is the case that forced the design: down the sights the muzzle follows
the CAMERA, so there is nothing there to tune. It exists at low ready and
nowhere else, and a spec table where a knob names the poses it applies to is
what lets that be said instead of shipping a control that does nothing.

hold_pose_check asserts both halves — that no pose shows another's knobs, that
aiming offers no muzzle pitch, that the heading names the hold being edited, and
that all twelve wrist axes turn the hand they name.

Its first version reported every wrist axis as moving the hand by 0.0 degrees,
which is precisely the answer it would have given if the wrists had never been
implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot
restores every bone's local pose after the modifier pass. The repo has a
reference section about exactly this and it still cost a cycle. Measured through
a PoseProbe, every axis turns its hand ~20 degrees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 01:31:08 -04:00
Nicholas ButzkeandClaude Opus 5 b1c8bab714 feat(rig lab): drag the anchors themselves, not just the gun under them
The lab could move the GUN and not the anchor points the hands are solved onto.
`grip_offset` slides the weapon around inside the fist; `gun_fore` and
`gun_stock` are distances ALONG the barrel, so the trigger and support hands
could travel up and down the weapon's own axis and nowhere else. Nothing could
take a hand off that axis, which is what a handguard below the bore, an angled
foregrip, or a pistol whose grip is nowhere near its barrel line all need.

Two things fix that.

`grip_shift` and `fore_shift` give the two hand anchors real three-dimensional
freedom, expressed in the GUN's own across/up/along frame so a sideways nudge
stays sideways as the weapon pitches between low ready and ADS. Zero is exactly
the old behaviour. Their z overlaps the along-axis distances, which is redundant
and deliberate: keeping those separate is what lets the reach solver slide the
support hand back down the handguard without undoing a considered sideways
offset.

And the markers are now draggable. They already showed the anchors; now they
are handles. The one under the mouse swells and draws through the body — depth
testing is right for judging whether a hand reached its target and wrong for a
handle, because at any useful framing the hands occlude all three.

Verified three ways, and each one had to be rebuilt once:

  anchor_shift_check first compared absolute positions and reported a 3.5 mm
  error that was the character BREATHING — there is a sin() on the muzzle pitch,
  so no anchor is ever in the same place twice. Measuring each anchor relative
  to the one it hangs off, rotated into the current gun basis, cancels the
  breathing, the ADS blend and the recoil exactly. 48 checks, six characters,
  both poses.

  anchor_drag_check asserts the drag writes the knob the MOUSE asked for,
  derived independently from the camera: 0.00-0.01 mm on all three. It does not
  assert the marker lands under the cursor, because it does not — the anchors
  hang off the shoulder and the arm chasing them moves the shoulder, so a drag
  settles at 0.77x-1.13x. Small enough to ignore interactively.

  That feedback first read as 1.5x-1.8x, because the cases were compounding on
  each other, and waiting LONGER for the pose to settle made it worse rather
  than better — which is the opposite of how a settling error behaves and is
  what gave it away.

The buttstock case also failed for a while on a bug entirely in the test: it
read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The
lab has a note about that trap in `_reset`. It is just as easy to walk into from
a test, and now has one there too.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 01:05:37 -04:00
Nicholas ButzkeandClaude Opus 5 700d0925d7 tools: measure rest poses, and diagnose momo
momo's idle plays with her arms overhead and her waist pinched, and every
assertion in the suite passes on her.

The first suspicion was the retarget's rest-relative delta: it applies "what the
clip does to the LIBRARY's rest" to THIS rig's rest, which quietly assumes the
two rests are alike. rest_pose_check.gd tests that, and disproves it — miku's
arms rest 41° off the library's and taila's 32°, and both animate correctly. The
delta retarget handles a rest-pose difference, which is what it is for. Recorded
in the reference so nobody spends that hour again.

Writing the tool reproduced this project's own recurring mistake in miniature.
Measuring "the direction from a bone to its first child" reported kiyoko's and
aria's legs 71° off the library — because a thigh's first child is as likely to
be a skirt bone as a shin, and it was measuring the hang of a skirt panel.
Pointing it at the next limb BY ROLE dropped both to 1°.

What momo actually has: `Root_001` through `Root_007` are in her driven_bones,
and they are her HAIR roots — Hair_A is dominated by Root_001_001, Root_007 and
Root_005. The animation is keying bones the spring solver is supposed to own,
which is non-negotiable #2 broken by the role resolver rather than by a clip.
The surface table corroborates it: her hair surfaces report 1.8% and 9.4% chain
share, because most of their vertices belong to bones in no chain at all.

Not fixed here. `Root_00N` matches no COSMETIC stem, and adding "root" to the
stems would cost a rig whose actual root is called `Root` its hips. The
structural fix is that a bone whose geometry is dominated by a mesh classified
`hair` is a hair bone whatever it is called — which the surface table makes
answerable at build time, and did not when momo was imported. It needs a Blender
re-run and re-verification of all six characters.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 15:03:33 -04:00
Nicholas ButzkeandClaude Opus 5 1e6f3001ac 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]>
2026-07-27 15:00:42 -04:00
Nicholas ButzkeandClaude Opus 5 99a2ee131d style(ui): the last hand-rolled colours join the theme
Five screen titles in the main menu were white on a black outline and the HUD
used Color.BLACK outlines and a hardcoded grey. That was the old comic theme's
contrast trick, and next to a papaya wordmark it read as a different game's
menu. They take UITheme.PAPAYA, INK and PAPER_DIM now, so every screen —
main menu, pause menu, character picker, settings, HUD and rig lab — is one
palette with one source.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:57:31 -04:00
Nicholas ButzkeandClaude Opus 5 08ae85b286 feat(characters): rig anchors, and a lab that edits any tuning group
The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.

RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.

Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.

The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.

The lab is a rig lab now, and it grew three things:

  ANCHORS   a second knob group. It cost a spec table — everything in the lab is
            written against "which specs, which file, keyed on what" rather than
            twice against the two groups, so anchors got sliders, live preview,
            reset, save and clipboard for free. A third group is a third row in
            GROUPS.
  CLIP      audition one animation on its own. The four pose buttons are the
            states the game drives; watching a whole clip end to end is how you
            see where a retarget went wrong, and there was no way to do it.
  SURFACES  what the importer decided each surface IS, and a click to isolate a
            class. Isolating is how the decision gets CHECKED: click `hair` and
            anything else still standing was misclassified. Hidden by swapping
            in a transparent material rather than hiding the node, because a
            mesh is not one class — Miku's body, face and hair are three
            surfaces of one mesh.

And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.

debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.

Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:56:03 -04:00
Nicholas ButzkeandClaude Opus 5 26f7c2c622 feat(ui): one high-voltage theme, and pick your character from the escape menu
The theme was a comic one — cream paper, ink borders, papaya. It is now a
charged one: near-black violet, hot papaya, and a lightning yellow spent
nowhere except the instant a button is pressed. Chips are cut with two sharp
corners and two round ones on a diagonal, which is as close to a skew as a
StyleBoxFlat gets and is the difference between a button that reads calm and
one that reads fast. BoltRule draws the motif itself, struck a third of the way
along its rule rather than centred, so it reads as something that HIT the line.

The pause menu was 900 lines of hand-rolled UI that never referenced UITheme at
all, so it rendered in Godot's default grey. It now applies the theme — and
applies it to its own root Control, not only to the Window, because a Control
inherits from its nearest Control ANCESTOR and this screen hangs off a
CanvasLayer, which is not one. That was invisible at first: the parts built with
UITheme.title() carry their own overrides and looked right next to a list and a
button that did not.

And it now has a Character screen. The roster on the left, the character
themselves on the right, turning — a name in a dropdown is not a character
selection screen. The preview is a real SkinnedPlayerModel in its own world, so
it shows exactly what will spawn: the same cel look, the same per-class
outlines, the same cloth and hair on springs. Selecting applies immediately;
there is nothing destructive to confirm, and applying on selection means the
character behind the menu changes as you arrow the list, which IS the
comparison.

PlayerMovementController.set_skin() is the supported way in. Both halves of a
skin change are easy to do by halves — `synced_skin_id` is what REMOTE peers
rebuild from, and only their _process watches it, so setting the property alone
would change everyone else's view of you and not your own.

Checked rather than asserted. debug/character_picker_check.gd walks the whole
roster and proves each entry loads a skeleton, animations, a surface table and
body surfaces — and that the skeleton is MOVING, because the clip name is a
variable this class sets on itself and reads "Idle" just as happily when nothing
is ticking. debug/ui_capture.gd and debug/roster_capture.gd photograph the
screens and every character, which is how three things were found that no
assertion could see: the theme break above, a preview showing the back of the
character's head, and a turntable that carried on from the last character so the
third one you looked at was side-on.

Known and not fixed here: momo's idle pose is wrong — arms overhead and a
pinched waist. Her rig, not the picker; every other character is correct.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:46:14 -04:00
Nicholas ButzkeandClaude Opus 5 53f175ed6d feat(characters): say what every surface IS, and light it accordingly
A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.

The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.

All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.

What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.

That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.

Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:35:06 -04:00
Nicholas ButzkeandClaude Opus 5 33d07b3717 feat(tools): a weapon hold lab for tuning each character's grip in 3D
I have been guessing at numbers that want an eye on them. This is the
environment to set them instead.

  godot --path . res://debug/weapon_lab.tscn

Pick a character, pick a weapon, pick a pose, drag sliders, press Save.
Thirteen knobs — weapon size, support-hand distance along the barrel, grip
to buttstock, muzzle pitch at low ready, both wrist rolls, three finger
curl amounts, the stock pocket for low-ready and for aiming, and both elbow
poles. Three markers show the points being solved for: red trigger grip,
green support hand, blue buttstock. If a hand is not ON its marker the IK
could not reach it, which is a different problem from the marker being in
the wrong place, and the two used to be indistinguishable.

Results land in assets/characters/weapon_holds.json, resolved in layers so
a number can be set once and contradicted where it matters:

  defaults              every character, every weapon
  skins.<skin>._all     this character, every weapon
  skins.<skin>.<weapon> this character, this weapon

An empty file means "use what the code derives", so the game runs exactly
as before until something is actually tuned. The knobs that CAN be derived
from the skeleton still are — mount rotation, wrist frame, weapon size —
and their sliders read "auto" at zero rather than silently overriding.

Two things this had to get right to be honest rather than merely present.
Slider defaults come from the same table as the code defaults, because a
slider parked at 0 beside a code default of 1.0 means the first touch of
that slider silently switches finger curl off. And the markers are
depth-tested: drawn through the body they look like they are floating in
front of the chest when they are really behind an arm, which is the exact
wrong impression for judging whether a hand is on its target.

`-- shot <path> [skin] [weapon]` renders one framed close-up and quits, so
the lab can be checked without a human at the controls — which is how both
of those bugs were caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:15:53 -04:00
Nicholas ButzkeandClaude Opus 5 2a6b321984 fix(weapons): orient the support hand by frame, not by arc plus a twist
The support hand was upside down on the handguard. Its rotation was built
as a shortest arc from the hand's forearm line to the barrel, plus a
constant 0.5 rad twist. A shortest arc is the MINIMAL rotation between two
directions and says nothing at all about roll, so the entire roll of that
hand came from the constant — and a constant is right only for the one rig
it was tuned against.

Orienting a hand onto something it grips is a frame-to-frame problem, and
saying it that way leaves nothing free to guess. A hand wrapping a cylinder
has its curl axis along that cylinder, or the fingers close across the
handguard rather than around it; and its palm faces the cylinder, which for
a hand supporting from underneath means up. The third axis falls out of the
other two. Map the hand's rest anatomical frame onto that target and the
roll is determined rather than chosen.

The frame is the same one the finger curl already uses — along, palm, curl
— now stored whole instead of just its curl axis.

Verified by render on the mannequin (Rigify names, 0.524 m arm) and Kiyoko
(VRoid names, 0.470 m): fingers wrap the handguard from below and over the
top, stock at the shoulder, arms not crossing, consistent across idle, ADS
and run. L_HAND_TWIST is deleted rather than retuned — it was the bug.

Smoke 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 19:50:25 -04:00
Nicholas ButzkeandClaude Opus 5 daed7ab980 chore(characters): regenerate every sidecar with the finger table
The finger roles only reach the runtime through <model>.rig.json, so a
character whose sidecar predates them keeps flat, open hands however good
the pose layer is. Aria, Momo, Miku and the Mannequin re-imported; all six
skins now carry 10 finger chains.

Verified on the Mannequin, which is untextured and so the clearest
diagnostic of the three checked: stock at the shoulder, trigger hand on the
grip, support hand out on the handguard, both fists closed, arms not
crossing. Its arm is a full 0.524 m against Taila's 0.468 and it scales the
weapon independently — 0.231 m of hand separation against her 0.217 — with
the slide-back loop never firing on either.

Known remaining: the support hand's wrist roll leaves the hand hanging a
little under the handguard rather than wrapping it squarely. The grip
POSITION is right on every character; it is the roll about the barrel that
wants another pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 19:05:36 -04:00
Nicholas ButzkeandClaude Opus 5 682597a0d2 feat(weapons): scale the gun to the arm, and close the hands around it
The hold was wrong on every character in three independent ways, all of
them a constant standing where a measurement belonged.

MOUNT. set_weapon seated the weapon with rotation_degrees = (0, 90, -90).
A bone attachment is expressed in the BONE's axes and no two rigs agree on
those, so one constant mounted the gun differently on every model. It never
needed to be right — the pose layer aims by rotating the wrist until the
weapon's forward lies on the aim line, so the identity means "forward is
the hand bone's -Z", true on any rig, and the wrist absorbs the roll.

SIZE. The set is modelled at real-world scale; an M4 is 0.84 m butt to
muzzle and these characters have 0.47 m arms against an adult 0.52. That
put the handguard 0.66 m from the support shoulder, 0.2 m past reach, so
the support hand was slid back down the weapon until it fitted — on Taila
from an authored 0.35 m to 0.083 m, which puts both fists together at the
grip. Two hands on a pistol, not a rifle.

Fixed by solving the support arm's triangle rather than picking a factor:
its hand must reach stock+fore ahead of the pocket from a shoulder half a
shoulder-width off the axis, so scale the gun to the largest that keeps the
handguard inside that reach. Taila and Kiyoko now come out at their own
scales (0.217 and 0.213 m of hand separation) with the support hand at its
FULL authored handguard distance and the slide-back loop never firing. The
loop also has a floor now: a straight support arm beats no handguard hold.

FINGERS. Nothing posed them — every hand was flat and open, which is the
loudest possible tell that a character is not holding anything. They close
now, about an axis derived from each hand's own anatomy in the rest pose:
along = wrist to middle knuckle, palm = middle knuckle to thumb tip (the
thumb opposes the fingers, so it marks the palm side by construction), curl
= along x palm. The trigger finger gets a much shallower curl than the
rest, because it lies along the trigger.

Finger bones resolve by ROLE across all three naming families met so far —
Rigify DEF-f_index.01.L, VRoid J_Bip_L_Index1, Blender IndexFinger1_L —
ordered by depth below the hand rather than by the number in the name,
which is not consistent between them.

Verified by render on Taila and by measurement on Kiyoko: stock at the
shoulder, trigger hand on the grip, support hand out on the handguard,
fingers wrapped, arms not crossing. Smoke 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 19:02:11 -04:00
Nicholas ButzkeandClaude Opus 5 c8b0337aa7 fix(characters): rig-independent weapon mount, measured facing, revert Miku
WEAPON MOUNT, all models. set_weapon() seated the gun with a fixed
rotation_degrees = (0, 90, -90). A bone attachment is expressed in the
BONE's axes and no two rigs agree on those, so one constant mounted the
weapon differently on every character. It never needed to be right: the
pose layer aims the gun by rotating the wrist until the weapon's forward
lies on the aim line, so the identity means "forward is the hand bone's
-Z", which is true on any rig, and the wrist absorbs the roll. The grip
now sits at the bone origin, so the gun is in the hand rather than at an
offset from a differently-oriented bone. Verified by render on kiyoko:
rifle shouldered, both hands on it.

FACING. flatten_and_scale() now measures toes-versus-ankles and snaps the
character to face Blender -Y, the convention the runtime's blanket flip is
built around. Kiyoko was 180 degrees off. Snapped to the nearest quarter
turn so splayed feet in a rest pose are not read as a turned character.

MIKU. Re-imported without --grow-cloth. Her grown hair chains were the
cause of the stretching: cloth_bones.py clears a vertex's body weights and
re-assigns it to the fitted polyline, so a poor fit does not degrade to
"stiff", it degrades to "torn". She now has no hair simulation — stiff but
correct — until that tool blends against the weights it replaces instead
of destroying them.

ARIA'S SKIRT IS NOT SIMULATED. Worth recording plainly, because it looks
better than Taila's and the obvious conclusion is the wrong one: aria has
ZERO cloth chains. Her skirt never clips because it is rigidly skinned and
follows the legs it is weighted to. There is nothing to port to Taila
except switching her simulation off.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 16:29:04 -04:00
Nicholas ButzkeandClaude Opus 5 27c4117c25 fix(pipeline): stand the character up before scaling; reach bones by role
Fixes the two root causes behind four of the seven reported breakages, and
rejects the source that cannot be fixed.

STAND UP FIRST. flatten_and_scale() now derives the up axis from the
skeleton and rotates the model upright before measuring anything. Aria and
Momo are correct: 1.75 m tall, 1.43 x 0.41 and 1.52 x 0.39 across, verified
by render.

The up vector is measured from the FEET to the HIPS, not from the hips to
the head. The head is not a reliable landmark — the spine walk ends on the
last non-cosmetic bone in the chain, which on a rig with a facial skeleton
can sit BELOW the hips. Momo's did, so the first cut of this fix stood her
neatly on her head: right size, right proportions, upside down. Feet cannot
be mistaken.

REACH BONES BY ROLE. SkinnedPlayerModel gained _role_bone(), and set_weapon
uses it. Four characters could not hold a gun because one hardcoded lookup
knew three spellings and their hands are called "Right wrist" and
"J_Bip_R_Hand" — both resolved perfectly in the sidecar the whole time.

HIKARI IS REJECTED. She now fails the gate: her feet and spine disagree
about which way is up, so the stand-up correction cannot resolve her
either, on top of zero-length cosmetic bones and a second armature that was
smuggling its own clips into the export. That is not a tuning problem, it
is a file that has been through two toolchains. De-registered and removed
rather than shipped broken — which is what the gate is for.

Six GLB skins remain, all passing. Smoke 0 failures, 11/11 movement tests.

Still open, recorded in the skill: kiyoko faces backwards, miku's grown
hair stretches under animation, the mannequin's rifle hold does not
convince, and taila's front skirt clipping.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 16:06:51 -04:00
Nicholas ButzkeandClaude Opus 5 2b3e29dd30 fix(pipeline): check that a character is CORRECT, not merely well-formed
Seven characters shipped "All checks passed" and four were visibly broken
in game — lying on their backs at seven times scale, facing backwards, or
holding a gun that floated near their chest. Nothing in the suite was
wrong; it just never asked the questions that mattered. That distinction
is the whole lesson, and it is now written down in the skill as
references/failure-modes.md, generalised per failure.

Two root causes are measured and certain:

- flatten_and_scale() normalises the bounding box along Blender Z because
  Z is up. For a model that arrives lying along Y that measures the
  character's THICKNESS, so it scales by ~7 and leaves them on their back.
  One assumption, both symptoms. The trap is that the normalised number
  always comes out right — the exporter maps Blender Z to glTF Y, so "is
  the height 1.75" passes on a character who is 7.5 m tall lying down.
  All three casualties are VRM files that went through a Blender
  round-trip and came back with a baked axis rotation.

- SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded
  spellings, which match none of the four non-Rigify rigs — their hands
  resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand".
  When it misses, the weapon is parented to the model root at a fixed
  chest offset, so it is not attached to the character at all. Same class
  of bug as the leg check that name-matched thigh/shin. rig_map.py exists
  so nothing downstream has to guess a bone name; only some consumers read
  the roles it publishes.

Three new hard checks, none needing more than the vertices and the
sidecar:

  character stands up in world space   — against WORLD up, not against the
      model's own proportions. "Is the spine the longest axis?" catches
      nothing: a model rotated as a whole is internally consistent and
      passes it comfortably.
  character is a plausible size / height
  every role the runtime needs is resolved

They separate the four good characters from the three broken ones on the
first run. Also fixed the posture measurement to read vertices rather than
object.bound_box, which is cached and still stale right after an import —
it reported a 1.75 m character as 1.18 m tall.

Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by
two independent mechanisms and verified by neither), miku's grown hair
chains stretch under animation (generated chains are never validated
against the geometry they drive), and the mannequin's rifle hold does not
convince despite resolving correctly.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 16:02:06 -04:00
Nicholas ButzkeandClaude Opus 5 2ce2175d99 docs(skill): record what four non-library rigs taught the role resolver
Names lie and anatomy does not — the four VRoid imports each broke role
resolution differently, and the lesson generalises: anything guessing
anatomy from a bone name needs a structural fallback. Also lists what is
shipping and which characters have chains that do not simulate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:46:23 -04:00
Nicholas ButzkeandClaude Opus 5 fc9e6f4275 feat(characters): four VRoid characters imported and selectable
Kiyoko, Hikari, Aria and Momo, all CC-BY from Sketchfab, licences recorded
beside each skin. Seven GLB characters selectable now.

  kiyoko  13 meshes  20 cloth chains (62 bones)  0.0% cross-leg bleed
  aria    15 meshes  15 cloth chains (37 bones)  0.0%
  momo     5 meshes   9 cloth chains (35 bones)  0.0%
  hikari  13 meshes  10 cloth chains (37 bones)  0.2%, 12 twist bones

These are the first characters imported that were NOT authored against the
library's own bone spelling, and every one of them broke something that
had been quietly wrong all along. All four failures were in code that
guesses anatomy from names, which is exactly what tools/rig_map.py exists
to stop doing:

- LIMB ROLES went to the first role in LIMB_ORDER that matched at all, so
  shin's catch-all "leg" claimed UpperLeg before thigh's exact "upperleg"
  was ever consulted, and the thigh went unassigned. The result depended
  on the order bones arrived in. Claims are now granted longest-stem
  first. This also broke Mixamo (LeftUpLeg/LeftLeg) and had simply never
  been hit, because every character so far used Rigify DEF- names.

- Names cannot settle thigh-vs-shin at all. A bare "leg" is the SHIN on
  Mixamo and the THIGH on a rig whose shin is "knee" — both common, same
  token, opposite bones. RigRoles now walks the leg from the foot upward
  and fills in whatever the names could not, stepping over twist bones.

- verify_character.py looked for legs by the substrings "thigh"/"shin",
  which VRoid spells UpperLeg/LowerLeg. It declared every locomotion clip
  static while the legs animated perfectly, and the cross-leg bleed check
  found no leg vertex groups at all and passed vacuously. Two green-
  looking lies from one missing lookup; both now read the sidecar's
  resolved roles.

- The cosmetic/spring classifier matched whole tokens only, so Momo's
  HairFL / HairFR / HairF_Top tokenised to "hairfl" and matched nothing.
  She imported with six chains, all bust, and no hair. Both classifiers
  now share one rule that also accepts a two-character positional suffix,
  which is short enough that "forearm" and "earring" are still untouched.

Two more pipeline fixes:

- A source model's own clips leaked into the export. Clearing bpy.data
  .actions before the library import is not enough — hikari carried two on
  a second armature's NLA tracks, and NLA_TRACKS export mode ships
  anything in a track anywhere in the file. They export as rest-pose
  statues. Now everything not retargeted is stripped from every object.

- verify_character.py gained a check for cloth chains with no measurable
  extent. A chain whose bones are zero-length is dropped by the runtime
  and simulates nothing, while the sidecar still cheerfully reports it.

Known: hikari's ten cloth chains are all zero-length and her collider fit
found nothing, so her costume does not simulate — her rig has been through
two toolchains and its cosmetic bones are empty terminators. She animates
correctly otherwise. The new check now reports this instead of hiding it.

tools/retarget.py also carries local working-tree changes that predate
this session.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:45:27 -04:00
Nicholas ButzkeandClaude Opus 5 cd0d1b2d99 feat(characters): import the Quaternius mannequin as a selectable skin
A third playable character, built with the pipeline skill from a source
that was already in the repo: the animation library ships a rigged
Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed
no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of
verts at four influences — a clean authored-weight import. Licence
recorded in mannequin.license.json as the other skins do.

It has no cloth chains, correctly: it is a mannequin and has neither hair
nor clothes.

Importing it turned up two real bugs, both of which would have hit any
flat-coloured or single-piece model:

- LevelMaterials.apply_character_look treated ANY untextured surface on a
  character as the model's own outline shell and hid it, so the mannequin
  rendered as a solid black silhouette — its body and joint materials are
  untextured flat colours, not ink. _is_line_work() now asks whether the
  surface is named eyes*, is drawn front-face-culled (the inverted-hull
  setup), or is near-black. Taila and Miku are unaffected: their materials
  are textured and never reach that branch. Verified by render.

- verify_character.py failed the build for having one mesh. That check
  cannot tell "the pipeline joined them" from "the artist authored one
  mesh" — Quaternius' mannequin is one piece on purpose. It is advisory
  now; the join path's two unambiguous signatures, cross-leg bleed and the
  4-influences-everywhere spread, are still hard checks.

Also restored Miku's description, which the re-import had blanked.

3 GLB skins selectable (6 with the built-in colour skins). Smoke 0
failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:25:26 -04:00
Nicholas ButzkeandClaude Opus 5 270d5f0973 chore: track Godot uid files for the new debug tools
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:19:55 -04:00
Nicholas ButzkeandClaude Opus 5 7892669319 feat(pipeline): grow skirt and hair bone chains for a costume that has none
Miku shipped with 0 cloth chains against Taila's 35, so her twin tails
hung off her skull like a helmet. Nothing downstream could fix it: the
spring solver simulates cloth BONES, and a garment with none is welded to
whatever body bone it was weighted to. Every auto-rigged model is in that
state, and her source was an unrigged mesh.

tools/cloth_bones.py builds them, which is the job a technical artist does
by hand on a model like this. It finds the geometry by MATERIAL SLOT — the
artist already answered which surface is hair, and on a joined mesh (what
the auto-rig leaves behind) the slot is the only separation left. Hair is
split into connected islands, because a strand is a connected piece of
surface and clustering by position would merge two ponytails passing near
each other. A skirt is split into radial wedges instead, because a skirt
is ONE connected surface and islands would return the whole thing as a
single piece — the bell-shaped failure. Each clump gets a polyline fitted
down its middle by binning vertices by distance and taking centroids, so
the chain follows the piece's own curve rather than cutting the corner on
a bend, and vertices are re-weighted onto it while the first 22% keeps its
original body weight so the scalp stays on the skull.

On Miku: 19 chains, 57 bones from one `hair` slot. Sidecar 0 -> 19 chains.
Idle stability 0.007-0.018 deg/frame. Mesh intact, verified by render.

Opt-in, via `pipeline.py --grow-cloth`, and run before the retarget so
describe_rig() finds the chains by name exactly as it would an artist's.

Known limits, recorded in the skill: it cannot find a garment sharing a
material with the body (Miku's skirt is on her `body` slot, so she got
hair and no skirt), and grown chains are a fallback — an artist's chains
carry intent that no geometric fit recovers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:19:44 -04:00
Nicholas ButzkeandClaude Opus 5 4559a1adc3 docs(skill): record why Miku has no cloth — the source had no skeleton
The two shipped characters are a controlled comparison: Taila's source
arrived rigged (35 cloth chains, 8 twist bones, 18 meshes, artist
weights), Miku's did not (5 meshes, 0 joints), so Miku was auto-rigged
into one mesh with nearest-bone weights and no cloth chains at all. Her
twin tails and skirt are dead geometry and the destructive load-time
weight repair runs on her every spawn.

None of that is recoverable downstream, which makes picking a source that
already has skirt and hair bones the highest-leverage decision in the
pipeline. Added the no-Blender check for vetting a candidate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 12:47:42 -04:00
Nicholas ButzkeandClaude Opus 5 daf9627ece docs(skill): compact the character pipeline into a reusable skill
Everything about getting an anime-styled character into the game — import,
stylization, the body/garment/hair separation, rigging, retargeting, cloth
and hair physics, and how to measure any of it — collected into
.claude/skills/character-pipeline/.

Organised around the principle the Hoyoverse-class pipelines are built on
and that every failure in this project traced back to: a character is not
one object. It is a body, a set of garments and hair, authored and rigged
separately and moved by different systems. The body is skinned and
animated; the garments and hair are bone chains the animation never
touches and physics moves. The skill's non-negotiables are the four ways
that separation has been destroyed here before — joining meshes, keying
cosmetic bones, skinning cloth to a leg, and running the auto-rig repair
on authored weights.

references/verification.md leads with the trap that invalidated every
cloth measurement ever taken in this repo: Godot restores bone poses after
the modifier pass, so a tool that reads them afterwards measures the
animation and never sees what any modifier did.

Also records what is known-unsolved, with numbers: peak cloth clipping in
a run/slide/dash, no foot IK, no strafe clips.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 12:46:50 -04:00
Nicholas ButzkeandClaude Opus 5 040b595397 feat(anim): point the legs where the character is actually going
The clip library has one forward locomotion cycle and no strafe or
backpedal clips, so a character sidestepping ran forwards on the spot
while sliding sideways. Nothing in the animation said which way they were
travelling and a body lean was carrying the whole burden of telling the
player.

Yaw the HIPS onto the travel direction and unwind it up the spine. The
legs hang off the hips, so the whole stride turns with them and it costs
no new animation; the chest keeps facing roughly where the player aims.
Past about a right angle the hips cannot follow, so the cycle plays in
reverse and the legs point the other way — a real backpedal instead of a
moonwalk. The regime is hysteretic and the yaw is eased, so crossing
between them reads as a pivot, which is what a person does there.

The lean moved into the travel frame with it. Leaning "forward" along the
facing while the legs run off to one side leans them sideways relative to
their own stride, which is what being dragged rather than running feels
like. It is also driven by how hard the character is moving rather than
by signed forward input, so a sidestep leans into its own stride instead
of standing straight up.

debug/travel_dir_check.gd measures it. How far the stride points from the
actual direction of travel:

  forward 9°   fwd-diagonals 5-6°   backpedal 2°   back-diagonal 2°
  pure sidestep 39-42°, which is the deliberate hip cap

Two things worth knowing about that tool. It reads bone poses from inside
the modifier pass, because Godot restores them afterwards and anything
read later is the animation with the pose layer missing. And it averages
over a full stride: a run cycle twists the torso against the hips by tens
of degrees twice per stride, so a single-frame sample measures the clip,
not the layer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 03:16:24 -04:00
Nicholas ButzkeandClaude Opus 5 0dd9d01ac7 fix(cloth): solve the garment instead of repairing it five times
The skirt glitched when the character moved and the thigh still came
through it. Both came from the same place: the solver integrated one
spring per bone and then ran four more passes behind it — resolve the
collision against the target, resolve it again against the answer, relax
the cross-panel links and rebuild every pose from the corrected tips,
then walk a separate ancestor "lift" — each writing bone poses the next
read back and partly undid. The lift wrote poses that were never fed back
into the spring state at all, so every frame began by pulling against a
pose the springs did not know about.

Replaced with one position-based solve, the shape Magica Cloth 2's
BoneCloth uses. Every JOINT is a particle, so a bone's head can move;
predict with inertia in the anchor's frame; relax length, bend, backstop,
the cross-panel links and the colliders together; convert to rotations
once at the end. A contact with no rotational leverage is now resolved by
the panel moving, which is what a bodily chain push, an ancestor lift and
a drape weight were each approximating separately.

Measured, at a dead-still idle and over a movement sweep:

  idle jitter (skirt)      0.53 -> 0.025 deg/frame, worst 24 -> 1.9
  settling after a dash    103 -> 18 mm of leg left inside the skirt
  fall / air / walk         82 -> 40, 96 -> 75, 72 -> 76 mm
  run / slide / dash        unchanged, ~95 mm

The idle buzz and the failure to come home after a hard move are gone —
those were the "glitches out". Peak clipping in a run, a slide and a dash
is NOT fixed and is still around 95 mm.

Four things this turned up on the way:

- debug/cloth_clip_check.gd was measuring the animation, not the render.
  Godot restores bone poses after the modifier pass, so reading them with
  force_update_all_bone_transforms() afterwards sees nothing any modifier
  did. It reported the same ~95 mm with collision fully enabled and with
  it commented out. It now observes from inside the pass. Every number
  ever taken from this tool before now was measuring the wrong pose.

- The collision hulls came from ten farthest-point samples per bone,
  which describe a panel's corners and hem and leave its MIDDLE unsampled
  — exactly where a thigh comes through. Built from the real mesh at load
  time instead.

- The drape term is gone. It was there to move a panel the old solver
  could not, and once the solver could, it was worse in every state but a
  walk and cost 20x in stability: its target sat inside the leg the
  collision was pushing out of, so the two ran against each other forever.

- Cost was 10.9 ms per character. The inner loop rebuilt every capsule
  and reallocated the hull array for every (bone, collider, pass). Now
  2.6 ms at full quality with a distance LOD behind it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 03:07:30 -04:00
Nicholas Butzke 374d9f9822 feat: implement automated 3D character pipeline with retargeting and rig management tools 2026-07-24 20:52:35 -04:00
Nicholas ButzkeandClaude Opus 4.8 afc954e129 fix: no limb vertex may carry opposite-leg weight — thigh collapse gone
The thighs going thin was never the knee bend. It was inner-thigh vertices
keeping ~20% of the weight of the OPPOSITE leg, so a split stride tore them
across the body. Printing the weight composition of the worst-collapsing
vertices is what finally showed it: a right-thigh vertex reading
DEF-thigh.R=0.58 DEF-shin.R=0.21 DEF-thigh.L=0.13 DEF-shin.L=0.08.

Every earlier pass had a guard that let these through. The below-knee pass does
not reach them; the above-knee pass demanded one leg chain be 1.25x nearer than
the other, which excludes everything near the centre line — precisely where the
damage was; and the limb-radius gates are measured from the bone AXIS, so a
vertex on the front or back of a thigh clears them easily.

SkinLegRepair now ends with an unconditional pass: any vertex within a generous
radius of either leg chain is forced onto the leg that actually drives it, at
any height. It runs last so none of the staged rules can reintroduce the
problem.

Measured per vertex, across jump, fall, run and dash:

                     before        after
  Body below 0.80      70            0
  Body worst          0.46         0.82
  ClothCAndW below 0.80  107          0
  ClothCAndW worst    0.66         0.85
  ClothB worst        0.81         0.87

No vertex anywhere on the legs now loses more than 18% of its thickness, down
from 54%. The skirt still deforms freely (0.87-0.88) — clothes flow, limbs hold.

Also fixed in SkinJointHelper along the way: it picked the FIRST joint chain
that matched a vertex rather than the one holding most of its weight, which
bound right-thigh vertices with stray left-leg weight to the LEFT knee's helpers
and dragged them across the body. It now picks the dominant joint and requires
the joint to own at least half the vertex.

Taila snaps 2021 vertices, Miku 659. FSM tests 11/11, spawn smoke test 0
failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 09:03:45 -04:00
Nicholas ButzkeandClaude Opus 4.8 d33f9cd812 docs: record why bone-scale volume preservation does not work here
Tried widening the helper bone to cancel the joint collapse. The geometry is
right — blending two rotations θ apart thins anything perpendicular to the bend
by exactly cos(θ/2), which is |q.w| of the pose rotation, so scaling the helper
across the bone by the reciprocal is the exact correction.

It does not work, because a BONE scale is the wrong granularity. It fattens
every vertex weighted to the helper whether or not that vertex was collapsing:
parts of the thigh ballooned to 167% while the worst-collapsing vertices, which
are not on the helper at all, stayed at 0.83. The idea needs per-vertex
correction and a bone cannot express it. Removed, with the reasoning kept in the
file so it is not retried.

Hip joints were also retested, since two other variables had changed since the
first attempt (one-joint-per-vertex, and the limb gate removed). Same verdict a
second time: upper thigh 0.90 -> 0.84, and the skirt 0.80 -> 0.72. Knees only.

Shipped state is unchanged from the previous commit; this is the reasoning and a
revert of an experiment.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:49:14 -04:00
Nicholas ButzkeandClaude Opus 4.8 1ab7e99231 fix: handle the knee bend with a joint, and record what made it worse
Renames SkinKneeHelper to SkinJointHelper and generalises it to a list of
[parent, child] joints, so the half-angle joint technique is stated once and can
be pointed at any joint. It stays pointed at the KNEES only, and the header now
says why, because "add more joints" is the obvious next idea and it is wrong
here.

Three things were tried this round and measured, on the thigh, per vertex,
against the rest transforms:

  * hip and ankle helpers as well as knees: the knee got WORSE, 0.80 -> 0.50,
    and the skirt went 0.80 -> 0.72. The hip mostly swings rather than folding,
    and linear-blend skinning handles a swing far better than a fold; the helper
    just added another blend for nothing. Reverted.
  * gating the re-weight to vertices near the leg bone chain, to keep the hip
    helper off the skirt: that also excludes the OUTSIDE of the knee, which sits
    further from the bone axis than the limb radius and is exactly the geometry
    that needs helping. Knee went back to 0.50. Removed — with knees only there
    is no skirt to protect against.
  * one joint per vertex (break after the first match): kept, since a second
    job would read the bones the first just rewrote.

Net result on the thigh, worst per height bucket during a jump:

  y=0.60  0.80 -> 0.83     y=0.75  0.84 -> 0.84
  y=0.65  0.81 -> 0.84     y=0.85  0.90 -> 0.90

and the skirt is untouched at 0.80/0.84, still free to move.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:43:56 -04:00
Nicholas ButzkeandClaude Opus 4.8 0bb1db0806 revert: let the skirt move again; keep the rigid-limb work
The previous commit stiffened the skirt onto the hips to stop it flattening at a
wide stride. That was the wrong trade: it made the clothes read as a rigid shell
welded to the pelvis, which is a worse look than the flattening it fixed, and it
broke the model more than the original bug did. Reverted in full.

What stays is the work that makes the HUMANOID hold its shape — below-knee side
snapping, the above-knee limb pass, the torso cap, the half-angle knee helpers,
and the cross-leg triangle drop. Those only ever touch vertices hugging a leg's
own bone chain.

The header now states the rule the passes are actually built around, because it
is what keeps getting violated: limb vertices are cleaned up hard and must not
deform; drape vertices are left alone and must be free to move. It also records
the skirt-stiffening attempt so it does not get retried.

Measured split now, from debug/limb_deform_check.gd: the body holds 0.98 of its
cross-section at the knee while the skirt sits at 0.88 — rigid limbs, moving
cloth. Taila snaps 1404 vertices (down from 4024 with the skirt pass), Miku 434.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:31:25 -04:00
Nicholas ButzkeandClaude Opus 4.8 20b70e5eda fix: hang the skirt from the hips so it stops collapsing at a stride
It was the SKIRT, not the legs. Every leg check I ran hid the skirt to see the
thighs, so I was never looking at what is actually on screen. Rendering the full
model side-on shows it immediately: at rest the skirt is a flared shape with
real volume, and the moment the legs split for a run or a jump it collapses flat
against the body. The lower-body silhouette loses its shape, which reads as the
legs squashing.

Cause: the skirt is weighted to BOTH thighs. At a split stride the two thighs
pull it in opposite directions and linear-blend skinning averages them, so the
skirt is dragged inward and flattened. The earlier repair deliberately left it
that way, on the reasoning that a skirt should be free to drape across both
legs — correct in principle, wrong at these stride angles.

Draping cloth now hangs from the hips: vertices that clear both leg bone chains
keep at most 20% leg influence and the rest goes to DEF-hips, so the skirt holds
its shape and swings as a unit. Limb vertices are still snapped to one leg, so
the two are now modelled differently on purpose.

Bounded ABOVE THE KNEE. The first attempt used distance from the bone chain
alone, and chunky boots sit further from the ankle than the limb radius, so they
read as "drape", lost their leg weight and trailed off the hips — the legs came
out far worse than before. A skirt hem is above the knee; boots and stockings
are not.

Legs unchanged by this: knee cross-section stays 0.99, and the knee helper work
from the previous commit is intact. Taila snaps 4024 vertices, Miku 670.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:20:27 -04:00
Nicholas ButzkeandClaude Opus 4.8 050011666c fix: half-angle helper bones at the knees kill the candy-wrapper collapse
This is the leg squashing. It was at the KNEE, and every previous measurement
missed it because they averaged a ring of vertices, which hides a collapse that
only affects part of the ring.

Measured per vertex instead, against an exact rest-transform baseline: 532 of
Taila's leg vertices lose more than 20% of their distance from the bone axis and
the worst lose 40%, all at y=0.54 — exactly the knee — and worst in the air pose.
That is "squashing while running or jumping, easy to see from the side".

Cause is inherent to linear-blend skinning: it averages TRANSFORMS, not shapes.
A vertex half on the thigh and half on the shin is placed at the average of two
rotations, and at a hard bend that average falls well inside the leg, pinching
it to a ribbon.

SkinKneeHelper adds a bone at each knee, parented to the thigh and holding the
shin's rest transform, driven every frame to HALF the shin's rotation from
inside the skeleton's modification pass. Blend-zone vertices are re-weighted off
the thigh/shin pair and onto the helper, taking equal weight from each side and
reusing the slot that falls to zero — so no extra influence slot is needed and
the weights still sum to exactly 1. The sharpest blend any vertex now sees is
half the knee angle, and halving the angle roughly quarters the collapse.

Result: vertices losing more than 20% of their radius go from 532 to 0 on the
body. The worst anywhere is now 0.79, on the outline shell, which is hidden.
Verified visually too, legs rendered bare from the side through rest, run and
jump: knees hold their volume and the bind is intact.

FSM tests 11/11, spawn smoke test 0 failures, Miku unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 08:04:24 -04:00
Nicholas ButzkeandClaude Opus 4.8 e707229283 fix: stop the torso holding back the top of the thigh
Hiding the skirt and jacket and rendering the legs alone, side-on, finally
showed where the deformation actually is: not the knee, the HIP. The top of the
thigh flattens into a wedge at full stride. That matches the worst number the
corrected measurement reports — cross-section 0.85 at the hip, against
0.97-0.99 at the knee — which had been dismissed as ordinary.

Cause is the usual linear-blend failure at a big rotation: upper-thigh vertices
are weighted between the near-static hips and a thigh swung far out, and the
average of those two transforms collapses the top of the leg. SkinLegRepair now
caps torso (hips/spine) influence on LEG vertices at 15% and hands the excess to
the leg bone that already dominates them. Only vertices within the limb radius
of a leg bone chain are touched, so the skirt keeps swinging from the hips.

Hip cross-section 0.85 -> 0.88, and the flattened wedge is visibly fuller in the
side-on render. This is a real improvement but a modest one; the legs are slim
low-poly geometry and some collapse at a 60 degree hip rotation is inherent to
linear-blend skinning.

Taila now snaps 1217+ vertices, Miku 434.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 07:57:19 -04:00
Nicholas ButzkeandClaude Opus 4.8 1e12c36cca tools: give the limb deform check a baseline that validates itself
The measurement was comparing skinned lengths against BIND-POSE lengths. A
character is never in bind pose, so ordinary posing registered as deformation:
with the animation frozen it still reported 0.42x-2.56x edge ratios, and during
a run it reported edges collapsing to 0.08x and blowing out to 5.2x. Those
numbers were an artefact of the metric, not the model.

The baseline is now built from the skeleton's REST transforms, so every ratio
reads exactly 1.00 on an unposed model and the tool validates itself. Corrected
readings on Taila through the full runtime stack, worst over run, walk, jump,
fall, slide and dash:

  lengthwise stretch  1.07 Body, 1.08 ClothB, 1.09 ClothCAndW, 1.03 shell
  cross-section       0.97-0.99 knee, 0.85 hip, 0.92-0.94 ankle

That is ordinary linear-blend skinning.

Also records, in the header, the two ways this measurement was previously wrong,
because both nearly produced a bad "fix":

  * the bind-pose baseline above;
  * flagging vertices weighted across "non-adjacent" leg bones with
    max_slot - min_slot >= 2, which flags shin+foot+toe — a perfectly normal
    contiguous run. A repair pass built on that was written and reverted before
    it shipped; it was stripping legitimate toe and shin influences.

Checks that did come back clean and are worth not repeating: bone pose bases are
pure rotations to 0.00001, bone lengths never drift from their rest offsets, and
of 4019 coincident-vertex groups not one carries mismatched weights, so there
are no split seams tearing at the joints.

No character or gameplay code changed. skin_leg_repair.gd is byte-identical in
behaviour to the last commit; only its header comment gained a pointer to this
tool.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 07:51:18 -04:00
Nicholas ButzkeandClaude Opus 4.8 204846bf09 tools: measure limb deformation instead of eyeballing it
Adds debug/limb_deform_check.gd, which skins the leg vertices by hand and
compares them against the rest pose across a sweep of run, walk, jump, fall,
slide and dash. It reports lengthwise stretch (bending a knee can only shrink
this, so growth is genuine stretching) and cross-section loss (the linear-blend
"candy wrapper" collapse at a bent joint).

No gameplay or character code changes in this commit. Current readings on
Taila, through the full runtime stack:

  lengthwise stretch   1.05 worst (Body, ClothB, ClothCAndW), 1.03 outline shell
  cross-section        0.93 knee, 0.84 hip, 0.91 ankle

Sanity check on the method: the measured rest span of the leg vertex cloud is
0.818 m against 0.843 m of thigh+shin bone length, so the skinning maths is
producing real positions rather than plausible-looking noise.

That is ordinary linear-blend skinning. Combined with the earlier findings — no
bone pose basis deviating from a pure rotation by more than 0.00001, and no
bone length drifting by more than 0.0000 m — the leg geometry is not squashing
or stretching in any measurable way, so there is nothing here to fix blind.
Committing the instrument so the next round starts from a number.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 07:39:42 -04:00
Nicholas ButzkeandClaude Opus 4.8 a6e009d24a fix: repair thigh weights too, and keep the gun shouldered at low-ready
Legs: the previous pass deliberately stopped at the knee, on the reasoning that
cross-leg weighting above it is legitimate for skirts. It is — but the LEGS
themselves are up there too, and 122 vertices on Taila's thigh mesh carry up to
35% of the opposite leg. Running and jumping are exactly what splits the legs
far enough for that to show, which is why the cuffs came good while the legs
kept squashing.

The repair now runs above the knee as well, but only on vertices that belong to
a leg. Limb membership is decided by distance to that leg's own bone chain, not
by height and not by surface name: a thigh vertex hugs its bone, while a skirt
vertex hangs clear of both chains and is left blended so it can still drape over
the two legs. The radius is half the gap between the thigh bones, so it scales
with the character rather than being a magic number. Taila now snaps 1217
vertices (was 720), Miku 370 (was 214).

Weapons: at low-ready the stock was dropped 0.20 m below the shoulder joint down
to the ribs, with the muzzle pitched 22 degrees down. That pulls the whole
weapon off the shoulder and out of the supporting arm — it read as dangling
from the hands rather than being carried. Low-ready now stays in the shoulder
pocket and merely relaxes out of it (0.07 m drop), with a shallow 9 degree
muzzle tilt. All six weapons now sit in the shoulder with both hands on them.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 02:08:07 -04:00
Nicholas ButzkeandClaude Opus 4.8 f60941d1fb fix: stop the boots being dragged by both legs; keep the support hand on the gun
Found the real cause of the leg stretching, after three wrong guesses.

720 vertices on Taila are weighted to BOTH legs at once below the knee — 262 in
the boot/cuff surface, 208 in the outline shell, 176 in the body, the worst at a
dead-even 49/51 split. A vertex pulled equally by both feet sits halfway between
them and STAYS there while the legs separate, stretching every triangle around
it. That is the band between the ankles and the elongated boot that reads as the
legs being squashed. SkinLegRepair snaps each below-knee vertex to whichever leg
already dominates it, renormalises, then drops the few triangles still spanning
the two legs (48 on Taila, 0 on Miku, which needs 214 vertices snapped).

What finally located it was reproducing the artefact through the FULL runtime
stack in a bright scene. Every previous "clean" render had bypassed the game's
animation stack — playing the clip straight off the GLB's AnimationPlayer with
no AnimationTree and no pose modifier — so it could never show the bug. With the
real stack the boot visibly stretches, and freezing the AnimationTree at the
rest pose renders it perfectly, which proves the pose is fine and the skinning
is not. Confirming measurements, all through the runtime stack during a run:
no bone's pose basis deviates from a pure rotation by more than 0.00001, no
bone's length drifts from its rest offset by more than 0.0000 m, and no
below-knee vertex is influenced by any non-leg bone. There is no squash-and-
stretch in this rig; there never was.

The two earlier attempts at this are both superseded: re-weighting by
triangle-neighbour majority (tore the cloth) and deleting cross-leg triangles
(too narrow — 24 triangles against 720 bad vertices). Hiding the model's own
outline hull, from the last commit, stays: that hull is genuinely broken and
redundant, it just was not the whole story.

Weapon support hand: gun_fore was a single constant 0.35 m for every weapon, so
on a short gun it hung the off hand out past the muzzle with nothing to hold —
that is the "not supported by the arms" case. It is now clamped to 80% of the
weapon's own grip-to-muzzle distance, which only bites on the short guns (MP7
0.35 -> 0.20). All six weapons now sit in the shoulder with both hands on them.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 01:25:04 -04:00
Nicholas ButzkeandClaude Opus 4.8 28dc255d17 fix: hide the model's broken outline hull, reverse the backpedal, slower blends
The "linked ankle cuffs" and the "squashed legs" were the same thing, and it
was never the cloth or the weights: it is the model's OWN outline hull.

Isolating it took rendering the raw GLB in a bright scene and toggling one
surface group at a time. The hull ("FullBlack" and "material", the untextured
surfaces that trace the body and hair silhouettes) is a duplicated shell whose
skin weights do not track the base mesh through a deep bend. During a run it
tears into spikes and stretches sheets across the ankles. Hiding just those
surfaces makes the legs render perfectly clean, with no other change — so the
two previous attempts here were both treating symptoms:

  * re-weighting stray ankle vertices only tore the cloth (reverted last time)
  * deleting cross-leg triangles removed real geometry for no benefit
    (SkinMeshRepair is deleted in this commit — the clean render above was
    produced WITHOUT it)

The hull is also redundant: characters already get an inverted-hull overlay
from apply_toon_recursive AND the screen-space ink_edge pass, so dropping it
costs nothing visually. The EYE cards (EyesFullBlack lashes, EyesInvL, EyesHL
highlight) are real facial features rather than a hull, so those are kept, flat
as before. They are told apart by name prefix, not by guesswork about geometry.

Worth recording: no bone is EVER scaled, in any clip. Measured again here
across the whole Run cycle — worst deviation of a bone's pose basis from a pure
rotation is 0.00000. There is no squash-and-stretch in this rig; it only ever
looked that way because of the shell.

Backpedalling now plays the locomotion cycle in reverse (negative TimeScale)
instead of moon-walking with the forward clip — the library ships no authored
backward run. Directional lean is raised from 0.18/0.30 to 0.30/0.42 rad: with
one forward cycle and no strafe clips, the lean is the only cue for which way
the character is travelling, so it has to be legible rather than subtle.

Blends raised again: base 0.22 -> 0.32, locomotion 0.28 -> 0.40. Verified the
cross-fade genuinely applies rather than snapping — instrumented an idle->run
switch and the thigh ramps gradually over the window instead of stepping on
frame one, with xfade_time reading 0.400 s on the Transition node.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 22:20:02 -04:00
Nicholas ButzkeandClaude Opus 4.8 ec6b8228da fix: cut the welded ankle band, seat the gun in the hand, flash at the barrel
1. Ankle cuffs. The previous pass re-weighted the offending vertices to the
   nearer leg, which was the wrong call: the strip between the ankles is REAL
   geometry spanning the gap (~10 cm of ClothCAndW crossing x = -0.05 .. +0.05
   at ankle height), so re-weighting only tore it in half — a visible seam that
   still stretched. A triangle with one corner weighted to each leg has no
   correct pose; it must stretch the moment the legs separate. SkinMeshRepair
   now deletes those triangles instead (48 on Taila: the cloth band plus its
   outline shell). The cut is limited to BELOW THE KNEE, taken from the
   skeleton's own rest pose rather than a hardcoded height, because above the
   knee cross-leg geometry is legitimate — the skirt and shorts genuinely span
   left-thigh to right-thigh weights at the crotch.

2. The M4 floated because _measure_weapon derived the grip from mesh AABBs, and
   the FBX guns report bind-pose bounds tens of metres across — it measured the
   M4 as 24 m long and pushed the gun 7.5 m in front of the character. Bounding
   boxes are simply not trustworthy for these meshes. The grip was already
   authored elsewhere: WeaponManager places the first-person viewmodel's hands
   at fixed points in weapon space, and every weapon marks its barrel tip with
   muzzle_flash.position. Those move to WeaponGrips (dependency-free, so both
   the weapon system and the character models can use it without dragging each
   other's load order along) and third person now reaches for exactly the
   points the viewmodel uses. Every weapon's grip now lands 0.073 m from the
   hand — the M4 included, down from 2.32 m — with barrel lengths that match
   the models (0.30 m for the MP7, 0.68 m for the DMR).

3. Muzzle flash and tracers were spawned off the viewmodel's muzzle. The
   viewmodel is parented to the camera, so its muzzle sits inside the player's
   head — in third person the flash appeared by the character's shoulder.
   world_muzzle() returns the muzzle of the gun actually in the character's
   hands whenever the character is what the viewer sees, and the networked
   fire-effect RPC now sends that position too, so remote players stop seeing
   tracers leave the shooter's head. Measured: the third-person origin sits at
   the held gun, 0.54 m below the head, instead of on the camera.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 19:16:22 -04:00
Nicholas ButzkeandClaude Opus 4.8 35ada4f34a fix: unlink the ankle cuffs, stop the reload head-dive, soften every blend
Three animation complaints, measured before changing anything.

There is NO squash-and-stretch. Instrumented every bone every frame: the
worst deviation of a pose basis from a pure rotation is 0.00000. Every clip's
position tracks match the skeleton's rest pose to within a millimetre (only
DEF-hips translates, as it should) and no scale key deviates from 1. The
"squishing" is a deep BEND, not a scale: the shared library's PistolReload was
authored for someone watching their own hands, and it dives the head 33° with
the whole neck chain at 67°. At that depth Taila's head/hair weights pinch and
the skull changes shape. Dropping neck+head from the upper-body one-shot's
filter leaves them on the locomotion layer: head dive is now 14° and the neck
chain 48°, and the character keeps looking downrange through a reload, which
is what a shooter wants anyway. Trade-off: Throw/Hit no longer swing the head.

The ankle cuffs are not linked by the rig or the clips — the foot bones swing
independently (left/right Z correlation -0.94). They are linked by SKIN
WEIGHTS: 16 vertices belonging to the LEFT cuff, sitting at x = -0.002 just
across the centre line, are weighted to the RIGHT leg, so the far foot drags a
band of cuff across the gap. Position alone cannot classify them (the cuff
they belong to spans x = 0.00 .. 0.05), so SkinWeightRepair decides by
CONNECTIVITY: a vertex that disagrees with 80%+ of the vertices it shares
triangles with gets its leg influences mirrored. Ankle-height cross-leg
triangles go 24 -> 0 on Taila; the pass is conservative enough that Miku needs
exactly 1 vertex and her face/hair/skirt are untouched (only leg bones are
ever considered). Meshes with blend shapes are skipped rather than rebuilt.

Blending: nothing cuts hard any more. Base cross-fade 0.15 -> 0.22, locomotion
0.28-0.30 (Idle/Walk/Run/Sprint switch constantly as speed drifts across their
thresholds, which is where the snapping showed most), reaction moves raised
off their near-instant values but still snappy, and the upper-body one-shot's
own fades widened to 0.14 in / 0.22 out.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 18:56:21 -04:00
Nicholas ButzkeandClaude Opus 4.8 d8b0c08611 fix: Taila reads like her source render — no white rim, matte hair
Two separate causes behind "too glossy with a thin white outline":

1. The model carries its OWN cel line-work as extra, UNTEXTURED surfaces —
   an inverted-hull outline shell plus eye-line/highlight cards (Taila names
   them FullBlack / EyesFullBlack / EyesInvL / EyesHL). They are authored to
   read as flat black, but the glTF import hands them a default near-white
   albedo, and apply_toon_recursive then LIT them. That is the thin white
   rim: a pale, toon-shaded outline shell tracing every hair strand. They now
   render unshaded flat ink (flat white for the "HL" highlight card).

2. The skin textures already have cel shading painted in. Stacking our hard
   3-tone break on top produced a bright stripe that slid across the hair as
   the camera moved — the "gloss". Characters now get a soft terminator and
   an almost-invisible second step, so the painted shading carries the form.

Both live in a new LevelMaterials.apply_character_look(), called only for
imported character GLBs, so props and level geometry keep exactly the crisp
banding they were calibrated with. The one shared change is that the toon
shader's second-step darkness is now the mid_tone uniform, defaulting to the
0.82 that was hardcoded — no change for existing callers.

Character shadow tint (0.64, 0.56, 0.72) is measured off the Sketchfab
reference render rather than guessed: sampling the hair there, shadow/midtone
lands near (0.63, 0.53, 0.70). Green drops hardest, which is what keeps
copper hair copper instead of washing it to brown.

debug/character_look_capture.gd renders the raw import beside our treatment
under matched lighting — the comparison this was tuned against.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 17:54:41 -04:00
Nicholas ButzkeandClaude Opus 4.8 af8c9831c9 feat: thinner cel outline + procedural model actually holds its weapon
Outline: the inverted-hull width was tuned when the look was heavier and
now reads as a thick marker stroke. Cut every call site to roughly a third
(characters 0.012 -> 0.004, level geometry 0.015 -> 0.005, Neon Alley's
size-scaled clamp 0.02..0.05 -> 0.007..0.018) plus the shader default, so
the ink line stays readable without fattening every silhouette.

Third-person weapon on HumanoidModel: the gun was parented to root_pivot at
a fixed (-0.15, 1.0, 0.4) — floating 40 cm off the chest while both arms
posed at it from a distance. Give the rig a real right-hand node at the end
of the forearm and hang the weapon there, so it travels with the arm. Its
aim is re-derived from the body each frame (hand_r_pivot's global basis is
copied from root_pivot), otherwise the barrel would swing around with the
arm animation. The hold pose is retuned to match: right elbow tucked at the
ribs with a level forearm, left arm crossing to the handguard.

_seat_weapon_in_hand() slides each weapon along its own barrel axis so a
plausible grip lands in the fist regardless of where the artist left the
model origin. It measures with MeshInstance3D.get_aabb(), not
mesh.get_aabb() — the FBX gun parts are skinned, so the Mesh resource still
carries bind-pose bounds tens of metres across.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 17:32:37 -04:00
Dotts 9f60982416 Merge pull request 'Feat/visual audio overhaul' (#21) from feat/visual-audio-overhaul into main
Reviewed-on: #21
2026-07-21 14:09:17 -07:00
Nicholas Butzke 1172e465c1 chore: add UID file for bone dumping debug utility 2026-07-21 17:08:50 -04:00
Nicholas ButzkeandClaude Opus 4.8 0d125dc03f fix: third-person gun actually held — IK to the weapon, real recoil, no flip
Four separate third-person defects, one shared root: the arms were posed at
art-directed ANGLES that merely approximated the gun, and a stuck one-shot
flag kept handing them back to the animation clip.

- The rifle hold was silently DEAD after the first reload or throw. The gate
  trusted AnimationNodeOneShot's `active` parameter, which never clears, so
  clip_owns_arms stayed true forever and the clip drove the arms while the
  gun hung off the hand. `_upper_lock` (a timer we own) is now the
  authority, and the one-shot is explicitly faded out when it expires.
  This alone is why the gun was never held correctly.
- Arms are now solved with real two-bone IK (`_ik_arm`) onto points derived
  from the WEAPON: the stock is anchored in the shoulder pocket, the grip
  and foregrip fall out along the barrel, and the support hand is placed on
  the actual handguard (sliding inboard if the model is too long to reach).
  Verified numerically: the hand lands within 2mm of its target.
- `_measure_weapon` measures the model's AABB along its barrel and re-seats
  it so the hand sits at a realistic pistol-grip point. Weapon models put
  their origin anywhere — the M4's was 10cm from the muzzle end, so parking
  its stock in the shoulder drove the hand INTO the shoulder and folded the
  arm up behind the head.
- Recoil now shows for the OWNER in third person: server_play_fire_effects
  only ever fired for remote shooters, so the local player saw nothing. The
  ammo counter dropping now kicks the model. The kick rides in the gun's aim
  direction so the IK carries BOTH hands up with it, instead of rotating the
  arms and shoving the support hand off the gun.
- Reload no longer flips the gun. The library's pistol-reload rotates the
  wrist the gun is parented to, which turned the rifle upside-down (mag to
  the sky) while the hand reached down for it. The hold now keeps the right
  arm through a reload and the support hand does the magazine work at the
  real mag well, under the receiver.

anim_capture gains recoil shots and lets ADS settle before framing;
debug/dump_bones.gd prints a skin's bone hierarchy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 10:15:54 -04:00
Nicholas ButzkeandClaude Fable 5 df0bf18e0f feat: real per-weapon reload/recoil choreography + knife slash, fully matte hair
Weapons no longer reload by spinning the gun 360 (that was the upside-down
flip; nothing detached). New ViewmodelAnim module drives per-style manual-of-
arms sequences on the existing model_root + named arm pivots, with a temporary
magazine/shell/rocket/cell prop that visibly leaves and returns to the gun:

  - mag     (M4/AK/MP7/DMR): roll gun to present the well, hand rips the mag
            down and away, fresh mag up, seat, charge. Gun stays UPRIGHT.
  - boltmag (AWP): mag swap + a distinct bolt-cycle rock.
  - break   (double barrel): hinge open, flick both shells out, drop fresh
            shells in, snap shut.
  - tube    (rocket launcher): tip the tube in, shove a rocket home, shoulder.
  - cell    (plasma/nail gun): glowing energy cell swapped on the side.

Per-shot viewmodel recoil kick (ViewmodelAnim.apply_kick) scaled by each
weapon's recoil_amplitude, so an AWP slams and an MP7 chatters — replaces the
old flat model behaviour. Weapons expose reload_style; base classes call
play_reload / stop (on unequip) / apply_kick.

Knife: the old barely-visible flick is now a real diagonal slash that
alternates backhand/forehand, whips the blade through screen centre, and
fires the third-person melee arm-swing action (synced to other players).

Hair/materials: specular fully to 0 (even a 2% stepped glint swept as shine
across Taila's hair when the camera moved); rim trimmed to a whisper. Fully
matte cloth/hair now.

Verify: debug/fp_weapon_capture.gd screenshots each weapon's reload phases,
the knife slash, and the fire kick.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 00:26:29 -04:00
Nicholas ButzkeandClaude Fable 5 d46530bd8c fix: color-accurate grade — ginger hair stays ginger, characters stay matte
The saturation grade (1.3) was shifting authored hues — Taila's auburn
ginger hair rendered fire-truck red and the whole game read filtered.
Saturation 1.3 -> 1.08, contrast 1.05 -> 1.03: textures now carry their
own vibrancy. Verified by sampling the rendered hair against the source
texture: lit hue 18-20 deg vs authored ~20 deg.

Character rim/specular cut again (rim 0.15/0.4 -> 0.08/0.32, spec
0.06 -> 0.025 @ 64): hair and cloth read fully matte, no wet sheen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-20 22:55:58 -04:00
Nicholas ButzkeandClaude Fable 5 1ad3563e5a feat: animations follow the mechanics — upper/lower split + real grapple pose
Two fidelity gaps closed:

- Reload/throw/shoot/hit used to REPLACE the whole-body clip, so reloading
  mid-slide snapped the character upright on standing legs. The model now
  runs a runtime AnimationTree (clips -> Transition -> TimeScale -> OneShot)
  whose one-shot layer is FILTERED to upper-body bones: the arms play the
  action while the legs keep sliding/running/falling. Land stays full-body.
  The rifle hold releases exactly while the one-shot node reports active.
- Grapple no longer plays the library's horizontal-swim clip. The zip pose
  is procedural: Fall as the airborne base, the spine pivots to fly along
  the line to the actual anchor point (capped so overhead shots don't fold
  the body), the head sights the anchor, legs trail behind, the FREE left
  hand reaches up the rope — and the right hand keeps holding the rifle.
  The anchor point feeds in from the controller for local and remote
  players (synced_grapple_point).
- anim_capture: deterministic open-ground teleport + two new regression
  shots (reload-while-sliding, grapple zip toward a real anchor).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-20 22:51:04 -04:00
Nicholas ButzkeandClaude Fable 5 87b8ae70df feat: Fortnite-style OTS camera + Alt free-look orbit in third person
- Third-person camera chain is now HeadPivot > OrbitPivot > ShoulderOffset
  (0.55m right, 0.12m up) > SpringArm(2.6m, 14 deg) — the character sits
  slightly left of screen centre over-the-shoulder instead of dead centre
  behind. Aim is unchanged (still the first-person camera).
- Hold Alt in third person to orbit the camera freely around the character
  with the mouse (yaw wraps, pitch clamped -69..+29 deg); the character
  keeps facing and aiming where it was. Release Alt and the camera springs
  back behind the shoulder.
- debug/orbit_capture.gd: screenshots the OTS framing, a driven orbit, and
  the spring-back for visual regression.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:59:44 -04:00
Nicholas ButzkeandClaude Fable 5 b0f26e6dde feat: matte anime materials — kill the glass/mirror look, keep the vibrancy
Everything read as wet plastic or glowing porcelain for two stacking
reasons, both fixed centrally:

- toonify() shipped every character/prop/weapon with a hot rim (0.45 over
  65% of the silhouette) and broad stepped specular (0.25 @ shininess 24) —
  patent-leather highlights on any dark surface. Now a thin rim (0.15/0.4)
  and a small tight glint (0.06 @ 56); shader defaults match.
- The LINEAR tonemapper clipped lit white surfaces (sun + ambient push
  facades past 1.0) and the glow pass bloomed the clip, so buildings glowed
  like porcelain. Switched to FILMIC with white=2.4: near-linear through
  the cel bands (they stay crisp), soft shoulder above 1.0. Glow threshold
  1.1 -> 1.45 so only genuine emissives (neon, tracers, plasma) bloom.
- Saturation 1.22 -> 1.3 compensates filmic mids — vibrancy unchanged.
- Neon Alley: ambient 1.7 -> 1.35 / sun 2.2 -> 2.5 — the shoulder no longer
  hides the ambient lavender cast, so let the sun shape the facades.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:53:05 -04:00
Nicholas ButzkeandClaude Fable 5 d0c746d084 feat: Taila — second anime character through a hardened import pipeline
New selectable skin: Taila (original anime character by Partaevil,
CC-BY, license kept alongside the model), cel-shaded with ink outlines,
running the full 18-clip animation set + the new rifle-hold pose layer.

Pipeline hardening learned the hard way (each of these produced a broken
character before the fix):
- tools/strip_rig.py: strips a foreign rig (VRoid/Mixamo names bake FLAT
  clips in the name-based retarget), keeps only meshes skinned to that rig
  (scene props were joining into the player model), recentres feet-on-origin,
  and reroutes image textures into Principled base color for re-export.
- tools/unlit_to_pbr.py: KHR_materials_unlit anime models carry their albedo
  in emissiveTexture over a black base — Blender 5.1 drops the texture on
  import, rendering the character pitch black. Rewrites the GLB JSON to
  standard textured PBR before Blender ever sees it.

Also dropped the Elle import attempt: her rest pose is seated (posed scene),
which the T-pose autorig cannot use.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:07:17 -04:00
Nicholas ButzkeandClaude Fable 5 6c227b5538 feat: real two-hand rifle hold — armed animations finally read as a shooter
The clips were playing all along, but every locomotion state used unarmed
jog arms while the gun floated at the hip, so nothing looked animated.

- ShooterPoseModifier now REPLACES the arm chain with a direction-based FK
  rifle hold (low-ready at hip, shouldered on ADS) instead of nudging the
  unarmed clip additively; the wrist is solved so the muzzle points exactly
  along the aim line, gun kept upright (no-roll constraint).
- Per-arm release logic: reload/throw/hit one-shots, dance, death, the
  slide ground-brace, wall-run reach and grapple free the authored clips.
- Third-person weapon: fixed re-hide on weapon swap while in third person,
  scaled to character proportions, gripped along the hand.
- Armed idle uses Idle (arms owned by the hold) instead of arms-crossed
  PistolIdle base.
- FP viewmodel arms: character-styled sleeve + teal cuff + skin hand
  instead of the blue slabs.
- debug/anim_capture.gd: front+side screenshots of every movement state,
  one-shot action and ADS for visual regression of the model pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 17:55:05 -04:00
Nicholas ButzkeandClaude Fable 5 39af109893 feat: first-person reload animation on the viewmodel
The weapon dips down-and-inward with a roll when a reload starts, holds
through the reload, and rises as it completes — driven by the weapon's
actual reload_timer so the motion always matches the reload duration.
Complements the reload ring UI and the third-person reload clip.

Smoke 0 failures, movement 11/11.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 12:04:49 -04:00
Nicholas ButzkeandClaude Fable 5 7920aecec5 feat: player animation quality pass — aim follow, reload/throw, recoil
Third-person player characters now animate every action with readable
intent (all networked, cel style preserved):

- Look-around: upper body follows the owner's camera pitch — distributed
  over spine/neck/head so aiming up/down reads on the whole silhouette
  (driven by the already-synced HeadPivot rotation on remotes)
- Reload: the baked PistolReload clip plays as a one-shot whenever the
  equipped weapon starts reloading (local edge-detect poll -> new synced
  action counter replays it on every peer)
- Grenade throw: new Throw clip baked from the library's overhead swing
  (Sword_Attack retarget); triggers on throw, synced the same way
- Shot recoil: visible kick on the shooter's model (shoulders snap back,
  forearms rise, fast decay) driven by the existing fire-effects RPC —
  remote players' shots now look like shots
- Slide: trailing arm braces against the ground for balance on top of the
  feet-first pose
- Armed idle confirmed in capture: weapon held at ready, not arms-down

Plumbing: synced_action/synced_action_seq added to all three player
spawners' replication configs; ACTIONS table on the model maps names to
clips + lock times.

Verified: 18 clips resolve (incl Throw), smoke 0 failures, movement
11/11, acoustics PASSED; third-person run + armed idle captures clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 12:03:30 -04:00
Nicholas ButzkeandClaude Fable 5 5224a455a6 feat: screen-space ink edge pass — drawn outlines on everything
New assets/shaders/ink_edge.gdshader: fullscreen depth+normal discontinuity
detection draws thin ink lines over the whole scene — the renderer-level
line pass anime-styled games use. Works on every model regardless of mesh
topology (kit buildings, cars, heroes, characters), with distance fade so
the skyline stays clean. Attached via LevelEnvironment so every map gets
it for whatever camera renders (first-person, third-person, spectator).

Street capture verified: crisp uniform outlines citywide, no artifacts.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 11:54:55 -04:00
Nicholas ButzkeandClaude Fable 5 911066875b feat: hero batch 3 — pedestrian footbridge + viaduct station platforms
- footbridge.glb: 18m truss footbridge over the central avenue one block
  south of the rail line — X-pattern teal trusses, mesh railings, real
  stair flights at both ends. Deck and stairs are WALKABLE (box deck
  collider + invisible ramp colliders over the stair visuals): a third
  elevated crossing route between the viaduct and the plaza, and a new
  grapple anchor over the avenue
- platform.glb: station platform strips on the viaduct deck flanking the
  crossing — canopy on posts, benches, line signs; dresses the deck route
  players actually run
- Fixed a parse error from an undefined color constant caught by the
  capture loop (blank render -> error -> fix -> verified)

Movement 11/11, smoke 0 failures; full-city aerial verified.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:10:05 -04:00
Nicholas ButzkeandClaude Fable 5 18f50adb78 feat: hero asset batch 2 — yatai, koban, phone booths, bus stops, bikes
Second round of original Blender-kitbashed street furniture
(tools/build_hero_assets.py):

- yatai.glb: ramen cart — noren curtain strips on a hanger bar, counter,
  wheels, three stools, paper lantern, red roof with ink trim; one at
  every market mouth (wood collider)
- koban.glb: police box with teal roof, door, framed window, sign board
  and red lamp — watches the central crossing from the SE plaza corner
- phonebooth.glb: green-framed glass booth on ~25% of retail corners
- busstop.glb: bench + glass canopy + yellow sign disc, alternating
  blocks along the central avenue, both directions
- bicycle.glb: torus-wheeled bikes with frame/seat/basket, parked in
  seeded rows of 2-4 beside building fronts

All toonified with ink outlines; colliders acoustics-tagged (wood/
concrete/glass/metal). Movement 11/11, smoke 0, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:04:54 -04:00
Nicholas ButzkeandClaude Fable 5 7f9a579b63 feat: original kitbashed hero assets built in Blender
First bespoke art authored for the project (tools/build_hero_assets.py,
headless Blender 5.1 -> assets/props/hero/):

- konbini.glb: 8m convenience-store front — fascia + teal sign band with
  ink trim, framed windows with sills and window ads, recessed double
  glass door, angled awning, drink machine, hedge planter. Placed on
  ~1/3 of wide buildings (glass-tagged collider)
- station.glb: viaduct station entrance — canopy on pillars, real stair
  steps with handrails and balusters, sign totem; placed at the central
  avenue crossing of the rail line
- clock.glb: 10m plaza clock tower — tiered base, ringed column, 4 clock
  faces with hands, teal crown; anchors the south-west plaza cell
- torii.glb: proper torii with segmented curved kasagi, shimaki, nuki
  beam and plaque — replaces the box torii in every shrine courtyard
  (invisible pillar colliders keep gameplay identical)

All hero pieces run through the toon pipeline with fine ink outlines.
Every part beveled; flat cel palette matches the city.

Verified: city renders clean with hero pieces placed; movement 11/11,
smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:00:17 -04:00
Nicholas ButzkeandClaude Fable 5 6b3e363dfd feat: protruding double-sided shop signs over the sidewalks
1-2 per building at second-floor height on metal brackets, double-faced
with generated ad graphics — the perpendicular sign clutter that defines
Akihabara/anime-city streets and makes rows read dense from street level.

Verified via street capture; smoke test 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:53:03 -04:00
Nicholas ButzkeandClaude Fable 5 c8a85586bd feat: modeled market stalls, mega wall ads, traffic lights, awnings
Final detail pass — no gameplay object remains a plain box:

- Markets: box stalls replaced with modeled kit stalls (4 styles seeded),
  hidden box colliders keep the wood acoustics; hand carts at the market
  mouths; standing lanterns between stalls
- Shrines: hanging lanterns on the stone pillars, wooden fences flanking
  the torii approach
- Towers: giant mid-height wall ads on the street faces (the anime-city
  mega-ad look), on top of crown boards and corner columns
- Storefront awnings (kit detail piece) on ~40% of buildings
- Traffic signals at every central-avenue crossing, diagonal from the
  streetlights
- Fantasy Town Kit (CC0) installed at assets/props/town

Verified via street capture (wall-scale glyph ads, pharmacy signs,
banner ads, stalls, carts); movement 11/11, smoke 0, acoustics PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:50:28 -04:00
Nicholas ButzkeandClaude Fable 5 648872a16d feat: ad-graphic layer — generated posters, billboards and sign columns
The city's flat color sign panels are now real graphic designs, closing
the street-level "graphic density" that anime cities live on:

- tools/generate_ads.py: procedural ad library (24 textures) — bold
  two-tone layouts (diagonal splits, bands, circles, stripes) with
  fake-glyph text blocks that read as kanji/kana from gameplay distance,
  inked borders, seeded palette system
- Every building gets 1-3 street-level posters plastered on its facade
- Vertical sign columns render a stacked-glyph column texture
- Rooftop billboards, tower crown boards (both faces) and the central
  4-face screen tower all show distinct ad graphics
- Posters under the viaduct arches on every pier
- All panels lit (albedo + emission of the same texture) so they glow
  as signage without blooming out

Verified via street capture (glyph posters, pharmacy-cross signs, ad
boards visible down the avenue); movement 11/11, smoke 0 failures,
acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:44:55 -04:00
Nicholas ButzkeandClaude Fable 5 56db9eea59 feat: parked cars across the city (Kenney Car Kit, CC0)
Streets are now populated: seeded parking lanes hug the curbs of every
N-S street with sedans, taxis, vans, SUVs, hatchbacks, delivery trucks
and the occasional police car (~100+ across the city).

- Wheels attach at the models' wheel-socket empties at spawn
- Metal-tagged box colliders: cars are cover that sounds like cover
- Toonified + darkened base tint (kit albedo clips under the map light)
  with per-car hue jitter for varied paint jobs
- Slight rotation jitter so parking reads natural, not stamped

Verified via street capture (colored cars parked down the avenue, no
clipping/glow); movement 11/11, smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:39:35 -04:00
Nicholas ButzkeandClaude Fable 5 04d91dea31 feat: street-level detail pass — real roads, streetlights, trees, cables
Closes more of the gap to hand-authored city art at eye level:

- Kenney City Kit Roads (CC0) installed: every street in the 10x10 grid is
  now real road meshes — lane-marked straights (single stretched mesh per
  segment) and line-marked crossroad tiles at all 81 intersections, tinted
  to sit under the toon lighting without clipping
- Kit streetlights (curved arms over the road) along both central avenues,
  with slim pole colliders kept for grapples
- Kenney Nature Kit trees replace the cone/box stand-ins in shrine
  courtyards and plaza planters (original kit materials kept — the toon
  swap mangled their vertex colors into cyan)
- Overhead cable bundles sag across the east-west streets (the classic
  urban-Japan sky clutter), drawn as ink lines; central avenue kept clear
- Construction sites get cones + barriers from the roads kit
- Per-building tint jitter so repeated kit models read as distinct shops

Verified via street/aerial captures (dark marked asphalt, green trees,
detailed facades); movement 11/11, smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:33:31 -04:00
Nicholas Butzke f62f4f2503 feat: add Kenney road and nature assets and implement map builder script 2026-07-19 02:32:06 -04:00
Nicholas ButzkeandClaude Fable 5 67040ba12d feat: detailed building models across the city (Kenney City Kit, CC0)
Boxes with lines can't reach the requested fidelity — the city now uses
real modeled architecture, toonified into the cel style:

- Kenney City Kit Commercial (CC0, 41 GLBs) installed at
  assets/props/citykit: 13 mid-rise buildings + 5 glass skyscrapers with
  actual modeled window insets, doors, storefronts, awnings, trim and
  rooftop detail, plus parasol/awning props
- Standard + market blocks: street edges fill with seeded kit buildings
  (measured catalog drives placement, x13 scale -> 11-32m facades) over
  box colliders; Akiba signage layer (corner sign columns, glowing
  segments, roof billboards) attaches aligned to each model's bounds
- Tower blocks: kit skyscrapers (up to ~70m curtain-wall glass) on the
  walkable podium, seeded rotation, crown billboards kept
- Market blocks add cafe parasols among the stalls
- SSAO enabled + exposure recalibrated for the kit's bright albedo
  (sun 2.2 / ambient 1.7); lighter asphalt/paving to match; lighter haze

All kit meshes run through apply_toon_recursive: toon banding over the
kit textures, matching characters/weapons/VFX.

Verified: street + aerial captures (detailed facades at eye level, dense
believable skyline); movement 11/11, smoke 0, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 14:59:52 -04:00
Nicholas ButzkeandClaude Fable 5 b0af2ea9ee feat: Akiba Crossing city — 100 blocks with unique landmarks + detail pass
Scaled the single block to a full 10x10 city grid (48m cells, 16m streets,
640m across) with a hand-authored layout so the fabric stays varied:

Block types:
- S standard retail (70%): paired parametric buildings facing the N-S
  streets, seeded heights/colors per block; ~half get a mid-block alley
  with fire-escape climbs so no two blocks play identically
- T landmark towers: 8-10 floors on a walkable retail podium, mega corner
  sign, double-sided rooftop crown billboard (skyline wayfinding)
- J shrine courtyards: walled court, torii, slide-roof hall, lanterns,
  trees — the green quiet break in the fabric
- M market streets: dense stall rows + lantern strings between low
  buildings (close-quarters cover mazes)
- P plaza cluster at the city centre with planter cover and a giant
  4-face screen tower at the exact middle (the Crossing)
- C construction sites: open slab frame to wallrun, access ramp, container
  stacks, and a 28m crane whose jib is the city's tallest grapple anchor

City systems:
- Brick rail viaduct runs the full east-west width on per-block piers with
  vending arcades under alternating arches + slide ramps at the centre
- Central avenues x=0/z=0 get matched streetlights and zebra crossings at
  every block line; brick wall rings the district; city-haze fog for depth
- Detail pass per building: window bands every floor, water tanks/antenna
  masts seeded on roofs, denser sign stacking

Perf/physics: decoration (signs, windows, leaves, lamp heads) is
render-only MeshInstances; only gameplay surfaces are static bodies, all
acoustics-tagged. Spawns distributed across block types facing centre.

Verified: full-city aerial (all 100 blocks, aligned grid), avenue-level
capture (clean canyon, viaduct vista); movement 11/11, smoke 0 failures,
acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 14:45:48 -04:00
Nicholas ButzkeandClaude Fable 5 a7fa7f207c feat: Akiba Crossing — parametric Akihabara rebuild of the urban map
Complete new approach after the hand-placed layout shipped misaligned:
every element now derives from a street grid and its building's footprint,
so alignment is structural, not luck.

District structure (modeled on Akihabara's Chuo-dori / Denki-gai / JR
viaduct):
- 16m avenue flanked by continuous mid-rise rows; buildings defined ONLY
  by z-interval + floor count (4m quantized floors), packed edge-to-edge
- _building() derives everything from the footprint: parapet lips on the
  roof rim, inset glass storefront, sidewalk canopy, stacked per-floor
  sign bands, a solid vertical corner sign column with glowing segments,
  evenly spaced roof AC units, rooftop billboard on 4+ floor buildings
- Brick rail viaduct crosses the whole block on piers with an under-track
  vending arcade; sidewalk-aligned slide ramps with handrails reach the
  deck on both flanks
- Denki-gai alley cut through both rows (wallrun walls + fire escape
  platforms climbing per floor); red SEGA-style landmark + blue
  electronics building anchor the north corners
- Matched streetlight pairs every 16m with arms over the road; zebra
  crossings at the alley mouth and south block; spawns on sidewalks/deck
  facing the map center

Movement: viaduct high lane + roof-hop onto the 2-floor row, canopy lane
above each sidewalk, alley wallruns + fire escapes, grapple anchors on
lamp arms / sign columns / viaduct edge / billboards, slide ramps.

Acoustics: facades concrete, storefronts glass, canopies wood, viaduct
brick, signs/AC/vending/lights/escapes metal.

Renamed in the level selector: "Akiba Crossing". Verified via aerial and
street captures (aligned rows, clean canyon, viaduct framing); movement
11/11, smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 14:34:31 -04:00
Nicholas ButzkeandClaude Fable 5 233f88aee3 feat: full cel treatment for Neon Alley — flat toon colors + ink outlines
- LevelMaterials.flat(): toon-banded FLAT color material (no greybox grid
  texture) for dressed maps; cached per tint, rim/spec off
- Neon Alley overrides _box_static: every structure renders flat cel color;
  prop-scale pieces (<10m) get an inverted-hull ink outline scaled to their
  size. Building-scale boxes skip the hull — the inflated plates smear into
  black voids at close range (found via fixed street-camera capture)
- Pylon wires render as flat ink lines; warmer wood + hotter vermilion for
  flat shading (the grid multiply used to brighten tints)
- visual_capture: deterministic street-level shot after the aerial for
  style comparisons

Street capture now reads properly BoomerangX: flat lavender asphalt, hard
light bands, torii framed down the underpass, neon strips + sign glow.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 14:23:37 -04:00
Nicholas ButzkeandClaude Fable 5 0813d0b40d feat: Neon Alley — urban-Japan movement map built on the acoustic materials
New map (scenes/maps/neon_alley/, auto-appears in the level selectors):

Layout for movement flow:
- Main N-S street = the long sightline, crossed by a pedestrian bridge
  (slide ramps up both ends, underpass cover below, railed high ground)
- West machiya row: wooden two-story houses with a jump route
  (awning -> balcony -> roof deck) and 25-degree slide roofs facing the street
- East concrete towers with glass storefronts; brick wallrun alley between
  them (AC units to vault); metal fire-escape platform route to the roofs;
  angled rooftop billboard as cover + grapple anchor
- Three power pylons with crossarms over the street chain a grapple route;
  the torii gate crossbeam at the north shrine plaza is a fourth anchor
- South market: wooden stalls with neon canopies + metal vending machines
  = scattered mid cover; shipping container corner piece
- Spawns ring the block and face the map center

Acoustics as level design: every surface is tagged (machiya/stalls/torii
wood, towers concrete, storefronts glass, alley+perimeter brick, escapes/
AC/rails/container metal) so occlusion tells you which structure a shot
came through and reflections differ per street.

Style: sunset sky + fixed dusk-lavender ambient (calibrated for the toon
shader's 1/PI light response), warm low sun, neon sign columns, shop signs,
lantern strings, billboard glow, underpass strip lights.

Verified: aerial + street-level + third-person captures; movement 11/11,
smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 14:18:06 -04:00
Nicholas ButzkeandClaude Fable 5 a51595e3b9 feat: material-aware sound occlusion + early reflections for localization
New Acoustics system (globals/acoustics.gd):
- Occlusion: rays from listener to source collect up to 3 occluding bodies;
  each surface's material adds transmission loss and lowers the lowpass
  cutoff. A gunshot through wood stays warm (-8dB, 1.4kHz), brick muffles
  hard (-15dB, 600Hz), concrete harder (-18dB, 450Hz), metal keeps more
  highs (-11dB, 1kHz), glass barely muffles (-5dB, 2.6kHz). Applied via
  each AudioStreamPlayer3D's attenuation filter.
- Early reflections: loud sounds (gunfire, explosions) probe 8 directions
  for nearby walls; each close surface plays a quiet, delayed (path/343ms),
  material-filtered copy from the mirror point — shots audibly slap back
  off structures so players can triangulate. Sub-30ms echoes are skipped
  (they perceptually merge with the direct sound).
- Surface classification: builder-set acoustic_material meta -> node-name
  keywords -> metallic-material inference -> concrete default; cached.

Integration:
- AudioManager.play_3d applies occlusion at fire time and re-filters all
  playing positional sounds every 0.12s (walking behind a wall mid-sound
  audibly muffles it); flight loops opt in via "acoustic_occluded" group
- Remote players' gunshots were entirely SILENT — rpc_play_fire_effects now
  plays the right weapon's shot at the shooter's position, occluded and
  reflected like everything else
- Remote players' footsteps were also silent — now positional + occluded,
  cadence scaled to their speed
- Test level tagged: brick arena walls, wooden platforms/obstacles, metal
  wall-run corridor/rails/dash platforms; dust2 sandstone reads as brick

Tests: debug/acoustics_test.gd (12 checks: per-material loss ordering,
multi-wall stacking, reflection delay/filter, name classification) — all
pass; movement 11/11; smoke 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 23:48:38 -04:00
Nicholas ButzkeandClaude Fable 5 44460b52ae fix: exactly one bang per bullet — cut bursts and range echoes from gun audio
The library's master sheet revealed the primary AK-47 sample (C_27P) was a
full-auto LONG BURST, and two other slots used burst takes (C_34P, P_22P) —
one trigger pull audibly played several rounds. Beyond that, the single-shot
recordings carry outdoor-range slap-back echoes arriving as discrete pops at
~0.23s/0.33s/0.45s, which also read as extra shots.

- All fire sounds rebuilt from sheet-verified SINGLE-shot takes only
  (AK-47 gets C_28/C_31 + same-caliber SKS as third variation; MP7 gets
  single PPSh takes + Walther PPQ 9mm)
- Every sample now ends before the first echo can play: full level through
  the crack (~0.145s), faded out by 0.21s (times scale with pitch rate)
- Verified with silencedetect: every fire WAV is a single transient with
  no mid-file re-attacks

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 23:36:30 -04:00
Nicholas ButzkeandClaude Fable 5 0155bcdf04 fix: tighten gun audio timing and replace off-character sounds
- Gunshots: strip lead-in silence from every firearm recording (the audible
  delay between trigger and bang) and hard-trim to a single 0.7s crack with
  a fast fade — the outdoor-range echo read as 2-3 extra shots. Big single
  shots (AWP/shotgun/rocket/mortar) keep a 1s tail but still one boom.
- Hit confirm: short fleshy punch thock instead of the bell ("taco bell")
- Dash: clean synthesized air whoosh (bandpassed pink noise swell) instead
  of thruster fire crackle; grapple shoot inherits it
- Jump: push-off foot scuff + faint whoosh instead of the balloon boing
- Wind speed-loop: deep lowpass so it reads as rushing air, not fire
- Grenade: silent in flight — the rocket-engine loop made it sound like it
  was burning; bounces still audible

Verified: reimport + smoke test 0 failures, 11/11 movement tests.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 23:29:34 -04:00
Nicholas ButzkeandClaude Fable 5 a9b0775de3 feat: real gunshots, localized 3D audio, seamless music, better animations
Audio:
- All ballistic weapons now fire real firearm recordings (The Free Firearm
  Sound Library, CC0): AK-47 uses actual AK-47 shots, M4=AR-15, MP7=PPSh,
  AWP=Mosin Nagant, DMR=Savage 10, shotgun=Mossberg/Nova, nailgun=Ruger .22,
  rocket/mortar layered with real booms. Plasma keeps its energy sound.
- Reload is real lever-action foley instead of interface clicks
- Projectile flight loops localized: unit_size 50->6, max_db 6->0,
  max_distance 45 — no more mortar/rocket drone in every player's ear;
  loops also got proper loop_end (was a zero-length loop)
- Flight loops lowpassed into soft whooshes instead of sci-fi engine drones
- Menu music converted to OGG with native full-track looping (47s seamless,
  replaces the broken manual WAV loop points)

Animations (movement-shooter pass):
- Rebaked miku.glb with 4 new clips from the CC0 library: Grapple (swim
  reach — reads as a swing), PistolIdle (armed idle with weapon up),
  PistolShoot, PistolReload
- Armed idle: standing with a weapon now uses PistolIdle instead of
  arms-at-sides
- Slide pose overhaul: legs kick out front (lead/trail thighs, knees
  straighten) — feet-first slide instead of "sitting in a crouch"
- Wall run: forward drive pitch + inner arm reaches out to the wall
- Per-transition blend times: dash/hit/jump cut fast (0.05-0.08s),
  locomotion cross-fades smoothly (0.2-0.25s); CrouchWalk speed-scales

Fixes:
- Third-person camera no longer renders the first-person viewmodel layer
  (the gun/arms floated in front of the third-person view)

Verified: 11/11 movement tests, smoke test 0 failures (17 clips resolved),
third-person run screenshot shows grounded animated model, no floating gun.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 21:49:10 -04:00
Nicholas ButzkeandClaude Fable 5 6c8b0ddd28 feat: cel-shaded look & feel overhaul — real audio, comic UI, themed VFX
Audio (all CC0 — Kenney packs + OpenGameArt, see assets/sounds/SOURCES.md):
- Replace every procedural synth WAV with forged sounds (ffmpeg pitch/layer
  mixes): distinct fire sounds per weapon with 2-3 variations, explosions,
  footsteps x5, land/jump/dash/vault, knife swing, hit/kill confirms,
  bullet impacts, reload, flight loops, UI hover/click/confirm/error,
  match jingles, menu music
- AudioManager.stream_for(): AudioStreamRandomizer per sound id — every
  weapon now gets variation + pitch randomization on each shot
- Explosions and bullet impacts play positional audio; landing has its own
  sound instead of a pitched footstep; ambient wind bed on every map

Visuals:
- ExplosionVFX: shared cel-shaded burst (white-hot stepped core, ink
  shockwave ring, star spikes, flat smoke puffs) replaces the orange
  sphere in both local and remote-replay paths
- Comic star muzzle flashes on all weapons; unified cel tracer bolts with
  ink outlines across hitscan/shotgun/remote paths
- Impact decals: hard-stepped ink-splat gradients instead of soft airbrush
- Toon shading on first-person view models + arms, third-person weapons,
  and the procedural humanoid fallback (no hull outlines on FBX weapons —
  their hard normals tear the inverted hull)
- Fix: giant soft "blob" highlight on floors — toon rim/specular disabled
  on level-geometry materials (pre-existing artifact since the cel commit)
- Fix: own third-person weapon rendered into the first-person camera
  (shadows-only now covers first_person_mode)

UI:
- UITheme: comic theme on the root window — Bangers display font (OFL),
  paper panels with thick ink borders + hard drop shadows, papaya accent,
  themed buttons/inputs/popups; hover/click sounds on all menu buttons
- Main menu: tilted comic wordmark, sunset toon diorama, looping menu music
- Match HUD: themed scoreboard/killfeed/kill counter

Viewports:
- 4x MSAA project-wide + viewmodel/diorama viewports (crisp ink lines)
- Viewmodel camera near plane 0.01; third-person camera FOV syncs settings
- debug/visual_capture.gd: dev tool to screenshot menu + level for review

Verified: 11/11 movement tests, spawn smoke test 0 failures, before/after
screenshot comparison of menu and level.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 18:15:42 -04:00
Dotts 1358183f98 Merge pull request 'Feat/14 movement overhaul' (#20) from feat/14-movement-overhaul into main
Reviewed-on: #20
2026-07-17 10:44:23 -07:00
Nicholas ButzkeandClaude Fable 5 5bcbcfdc3f feat: emotive animations + anime speed lines — hit flinch, dance emote (B)
- SkinnedPlayerModel: generic play_oneshot() (generalizes the Land lock),
  Hit and Dance in the clip table (Dance loops), restartable clips
- Hit flinch: victims visibly react on every peer's screen via the existing
  damage broadcast — no new RPC
- Dance emote on B: toggles while grounded and idle, breaks on any movement;
  synced to remotes via synced_is_dancing (all three spawner configs)
- Anime radial speed-lines overlay (canvas shader): fades in past ~1.2x walk
  speed, spikes on dash, widescreen-corrected clear center
- New input action 'emote' (B) in project.godot

11/11 FSM tests, spawn smoke 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:40:49 -04:00
Nicholas ButzkeandClaude Fable 5 37c15bed1a feat: cel-shaded look — toon shader, ink outlines, stylized environment
- toon.gdshader: banded diffuse with cool-tinted shadows (3 tones), stepped
  specular, rim light, and an optional world-triplanar albedo path so level
  geometry keeps the 0.5 m grid with no UVs
- toon_outline.gdshader: inverted-hull ink outline, applied via
  material_overlay so it works on skinned (deforming) meshes
- LevelMaterials: tinted() now returns toon grid materials; toonify()/
  apply_toon_recursive() convert imported character/prop materials in place
- SkinnedPlayerModel: characters get toon shading + outline on load
- LevelEnvironment: shared stylized environment for all maps — saturated
  anime sky, linear tonemap (filmic/ACES crush cel bands), bloom for
  emissives, saturation+contrast grade, light depth haze; test level and
  dust2 now use it and the procedural arena (which had NO environment)
  gets it plus runtime toonification of its baked geometry

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:37:27 -04:00
Nicholas ButzkeandClaude Fable 5 2c7b5a1aca feat: lighter air control (steer + stronger strafe) and 2-charge dash
- Air: direct steering bends existing velocity toward input without changing
  its magnitude (air_steer_rate), on top of Quake accelerate with raised
  gains (air_strafe_accel 55->90, per-tick cap 1.2->2.5). No-input air drag
  nearly removed so held momentum carries. Steering skips opposing input to
  avoid flipping through zero.
- Dash: charge system on the machine — dash_charges (2) spend instantly,
  each recharges in dash_cooldown (2 s). get_dash_cooldown_remaining() now
  reports time to the NEXT charge (0 while one is banked) so existing HUDs
  keep working; get_dash_charges() added for pips.
- Tests: dash tests updated to the charge API + new coverage for
  spend-2-then-recharge; 11/11 pass, spawn smoke clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:32:01 -04:00
Nicholas ButzkeandClaude Fable 5 198345177a feat: world-grid level materials + real movement test suite
- Generated CC0-style prototype grid textures (0.5 m cells) and a shared
  LevelMaterials factory: world-space triplanar mapping over every code-built
  level (test level, dust2, procedural arena) with the existing color coding
  kept as tints. Readable surfaces at speed instead of flat color boxes;
  materials are cached per tint so identical surfaces batch.
- .gdignore + .gitignore the raw downloaded asset packs in addons/ whose
  overlong paths broke Godot import scans and spammed git warnings.
- Replaced the rotted FSM test file (invalid call(t) on Callables, stateless
  stub nodes, hung forever without quitting) with a real suite: 10 tests
  covering state registration, jump bookkeeping, wall-run momentum
  preservation and upward-carry cap, per-player dash cooldown (regression for
  the old shared static), dash speed stacking, chain soft cap, landing
  events, and slide entry momentum. New headless entrypoint:
  godot --headless --path . -s res://movement/tests/run_fsm_tests.gd

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 12:14:30 -04:00
Nicholas ButzkeandClaude Fable 5 7da7f9f3f2 feat: rebuild character skin with full 13-clip CC0 animation set
The shipped skin GLB only had Idle/Walk/Run/Jump, so Slide, WallRun, Crouch,
Dash, Fall and Land all fell back to the wrong clips. Rebuilt via the Blender
pipeline from the library-skeleton rigged model (the old miku_proper_anim was
on a different skeleton, so retargeting silently baked flat clips — and the
shooter pose layer's DEF-* bone names never matched it either).

miku.glb now ships Idle, Walk, Run, Sprint, Jump, Fall, Land, CrouchIdle,
CrouchWalk, Dash (roll), Death, Hit, Dance from the Quaternius Universal
Animation Library (CC0). Verified per-bone keyframe motion in the export and
rendered pose stills for deformation QA (no crossed arms / stretched feet).

Also removed five stale experimental skin GLBs + extracted textures and the
one-off editor import scripts; skins.json now lists only the working skin.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 12:04:52 -04:00
Nicholas ButzkeandClaude Fable 5 0ba14a9469 feat: overhaul movement for flow — momentum-preserving states, smooth crouch, landing feel
- Quake-style air-strafe accelerate (real speed gain from strafing; external
  speed from dash/rockets never clamped)
- Wall run: keeps entry momentum (incl. upward carry), accelerates along the
  wall instead of hard-setting velocity, gravity fades in over the run
- Slide: slope physics (gravity projected along floor accelerates downhill),
  flat-ground decel instead of multiplicative friction, entry boost, direct
  slide->wallrun and slide->dash transitions
- Dash: cooldown 10s -> 2s, per-player (was a static shared across instances),
  momentum fully kept on exit, FOV punch event
- Grapple: taut pendulum with active rope control (W reels in, S pays out),
  release keeps swing energy
- Wall climb: carries upward momentum in, jump-off kick, shared vault helper
- Crouch capsule: single owner in the machine, smoothly lerped, ceiling check;
  camera eye height follows via crouch factor (no more head snapping)
- Landing: machine emits 'land' events scaled by impact; camera dip + pitched
  down thud; footsteps get pitch variation
- Camera rig: event-driven (land dip, dash FOV kick, vault pitch impulse),
  slide roll tilt
- Model: Jump vs Fall split by vertical velocity, Land one-shot on heavy
  landings, wall-run body lean (synced to remotes via synced_wall_side)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 11:58:14 -04:00
Nicholas ButzkeandClaude Opus 4.8 c4a538a469 chore: add miku_test skin textures and script .uid files
Godot import artifacts that pair with already-committed assets:
- miku_test_Image_0..3.png: textures extracted from miku_test.glb on import
- .uid files for spawn_smoke_test.gd and audio_manager.gd

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 01:31:52 -04:00
Nicholas ButzkeandClaude Fable 5 29fdca3565 feat: shooter animation feel — directional lean, slide, composed airborne, weapon hold
Layer a procedural SkeletonModifier3D (ShooterPoseModifier) on top of the base
clip so the character reads like a movement-shooter avatar:

- Directional lean: banks into the movement direction (right/left/back) and
  blends smoothly for diagonals, driven by velocity relative to facing.
- Slide: leans the torso back and pitches the head up to look forward, instead
  of the base clip's forward-torso/legs-out "spine break".
- Airborne: plays a composed Jump pose (weapon ready) rather than a flailing
  fall.
- Weapon hold: the base clip already keeps the arms down (weapon at the hip);
  on ADS both arms lift and swing in toward centre-front to aim. ADS is read
  from the active weapon and synced (synced_is_ads) so remote players raise
  their weapons too.

The controller feeds movement direction + ADS to SkinnedPlayerModel.set_locomotion()
each frame (works for local and remote via synced velocity/rotation). All
rotations are authored in skeleton space (fwd=+Z, up=+Y, right=-X) and converted
per-bone; tuning constants are at the top of ShooterPoseModifier.

Verified by rendering the poses (idle/strafe/back/slide/ads) in a real Godot
viewport. Smoke test 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 23:23:51 -04:00
Nicholas ButzkeandClaude Fable 5 9939e7e524 fix: correct rig deformation — arms no longer cross body, feet no longer stretch to center
The model animated but deformed wrong: arms crossed the torso and foot heels
stretched to the model's center-bottom. Two root causes, both fixed:

1. Pose mismatch. Sketchfab models are modelled in an A-pose (arms angled
   down) but the Quaternius library rests in a T-pose. Binding the A-pose mesh
   to T-pose arm bones misweighted the arms, and the library clips (which
   rotate arms ~70° down from the T rest) then shoved the already-drooped arms
   across the body.
   Fix: autorig.fit_arms_to_mesh() reorients each arm bone chain along the
   model's real arm direction; merge_animations retargets every clip from the
   library's T-pose rest onto the fitted rest via world-space constraint bake
   (Copy Rotation/Location + nla.bake), so world motion is reproduced
   regardless of the rest difference.

2. Feet-to-center stretch. The library's deform-flagged `root` bone runs up
   the body centre-line; nearest-bone weighting assigned inner-foot/heel verts
   to it, stretching them to center when animated.
   Fix: exclude root/control/mechanism bones from weighting (_NON_DEFORM_HINTS).

Also upgraded nearest-bone weighting from rigid (1 bone/vert) to a smooth
inverse-distance blend across the nearest 4 bones, so joints deform cleanly.
Bone-heat still fails on Miku's joined hair/clothing mesh, so this is the
primary path.

Verified by rendering Idle/Walk/Run poses (Blender): natural stance, arms at
sides swinging correctly, feet planted, proper running stride. Smoke test
still 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 19:38:59 -04:00
Nicholas ButzkeandClaude Fable 5 22cb0a58b7 fix: T-posing skinned models — autorig produced an orphan skin with no weights
The model animated its skeleton but rendered a permanent T-pose: the mesh
wasn't skin-bound. Root cause was in autorig's binding, introduced while
chasing an earlier "skins:0" export:

- The earlier "skins:0" was actually caused by bone-heat weighting failing
  (0 weighted verts → empty skin gets dropped), NOT by the Armature modifier.
- The "fix" then stripped the Armature modifier and used parent_type=ARMATURE.
  That makes the glTF exporter emit a skin OBJECT but with no node.skin
  reference and no per-vertex JOINTS/WEIGHTS — an orphan skin. The mesh then
  renders its bind pose (T-pose) forever while the skeleton animates unseen.

Fix: keep the standard ARMATURE_AUTO result (Armature modifier + vertex
groups). With the nearest-bone fallback ensuring real weights, the exporter
now writes a COMPLETE skin (verified: meshnode.skin=0, JOINTS/WEIGHTS present).

Also:
- skinned_player_model: _ensure_meshes_bound() re-binds any skinned mesh whose
  skeleton NodePath doesn't resolve at load — graceful degradation instead of
  a silent T-pose for imperfect GLBs.
- smoke test now asserts the spawned player's mesh is bound to its skeleton,
  so this class of bug fails the test instead of shipping. 30/30 pass.
- regenerated miku_test.glb with the corrected pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:11:26 -04:00
Nicholas ButzkeandClaude Fable 5 e2fbc424a7 feat: third-person camera toggle (V) + fix first-person showing inside the model
The first-person head-shrink (SkeletonModifier3D) left the camera looking at
the inside of the neck/torso/hair. Replaced it: the owner's model now renders
shadows-only in first person (clean FPS view, still fully animated and visible
to others and in shadows).

Added a third-person toggle (V / toggle_camera_view) so you can actually see
your own animated model — the easiest way to verify a new skin's animations:
- over-the-shoulder SpringArm3D camera (wall-collision aware) on the local
  player; press V to swap, press again to return
- firing still uses the first-person camera, so aim is unchanged
- reveals the owner's model via set_owner_visible() on both SkinnedPlayerModel
  and HumanoidModel; hides the first-person weapon viewmodel while in TP
- death forces first person so the toggle can't fight the death cam

Smoke test extended to cover the toggle (camera boom created, becomes current,
round-trips) — 28/28 checks pass for both color and GLB skins.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:18:16 -04:00
Nicholas ButzkeandClaude Fable 5 64bbbf93c6 fix: make the character pipeline work end-to-end in Blender 5.1 + bundle CC0 animation library
Verified the full chain on a real Sketchfab model (Miku UID -> rigged ->
13 clips -> loads and animates in-engine, 24/24 smoke checks pass).

Fixes found while running it for real:
- sketchfab_import: read .sketchfab_token as UTF-16/BOM too (PowerShell's
  `echo >` writes UTF-16LE, which crashed the UTF-8 reader)
- autorig: rig to the animation library's OWN skeleton when _library.glb
  is present (zero retargeting); robust binding — fall back to rigid
  nearest-bone weights when Blender bone-heat fails on complex meshes
  (Miku's joined hair/clothing gave 0 weighted verts); export as
  parent_type=ARMATURE with no modifier (Blender 5.x only emits a glTF
  skin in that exact form — a lingering Armature modifier gave skins:0);
  export_apply=False (applying modifiers baked away the skin); flatten
  the Sketchfab empty hierarchy before binding
- merge_animations: support a single multi-clip library GLB via
  LIBRARY_CLIP_MAP; version-safe fcurve access for Blender 4.4+/5.x
  slotted actions (Action.fcurves was removed)

Assets/tooling:
- bundle assets/characters/animations/_library.glb — CC0 Quaternius
  Universal Animation Library (13 game clips), + .gdignore so Godot
  skips the pipeline-input folder
- requirements.txt (stdlib-only; documents Blender-python separation)
- pipeline docs updated for the bundled library + swap instructions

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 12:42:31 -04:00
Nicholas ButzkeandClaude Fable 5 d05477c135 feat: Sketchfab-to-game character pipeline, FP/TP animated views, client-auth netcode, audio system
Character pipeline (tools/):
- sketchfab_import.py: search/download via Sketchfab Download API with
  license/attribution tracking (SKETCHFAB_API_TOKEN)
- autorig.py: headless Blender auto-rig — fits a Mixamo-named skeleton
  to unrigged humanoids, binds automatic weights
- merge_animations.py: merges the shared Mixamo-skeleton animation
  library onto any rigged character, strips root motion, canonical names
- pipeline.py: one command chains download -> rig -> animate -> register

In-game skin system:
- SkinManager autoload reads skins.json (auto-written by the pipeline)
- SkinnedPlayerModel rewritten: canonical clips with fallback chains,
  blend times, speed-scaled locomotion, weapon bone attachment
- First-person: full animated body for the owner, head hidden via
  SkeletonModifier3D; third-person: full model for other players
- Skin selector in main menu; skin id synced in multiplayer
- Fixed GLBLoader crash (GLTFDocument.get_animation_count doesn't exist)

Multiplayer sync overhaul:
- Movement is now client-authoritative: the owning peer simulates
  locally (no input round-trip), server keeps health/kills/death
- Remote players interpolate synced_position/velocity with
  extrapolation and snap-on-teleport
- Knockback/impulses routed to the simulating peer

Audio:
- AudioManager autoload: SFX/Weapons/Footsteps/UI/Music buses, pooled
  3D players, variation + pitch randomization, auto-registration from
  assets/sounds

Docs: 3D_ASSET_PIPELINE.md rewritten end-to-end, new ASSET_SOURCES.md
(non-procedural animation/map/sound sources) and SOUND_DESIGN.md.
Verified with debug/spawn_smoke_test.gd (headless: 24/24 checks pass).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 02:28:58 -04:00
Hermes 552338112e remove duplicate scenes/test_level.tscn 2026-06-25 23:30:08 -04:00
Hermes 2537ad19c0 fix: queue_free() returns void - split into remove_child + queue_free 2026-06-25 23:25:31 -04:00
Hermes 11d079b38c restore test level and dust2 from main branch 2026-06-25 17:30:30 -04:00
Hermes a351cf2dc4 restore test level and dust2, fix dust2 builder to not use TestLevelBuilder 2026-06-25 17:27:47 -04:00
Hermes 6b82258cec remove stale scenes/test_level.tscn 2026-06-25 17:22:02 -04:00
Hermes e0844a3d3a remove stale dust2 files referencing deleted TestLevelBuilder 2026-06-25 17:16:06 -04:00
Hermes b23a307201 clean up stale test_level files (project uses LevelRuntime now) 2026-06-25 17:12:58 -04:00
Hermes 1121a2f0ee restore test_level_builder.gd (accidentally deleted) 2026-06-25 17:07:00 -04:00
Hermes d5a8267cd2 rewrite skinned_player_model.gd from scratch with clean indentation 2026-06-25 17:02:24 -04:00
Hermes b2e2ce2856 fix: re-export GLB with dense per-frame keyframes to prevent animation stripping 2026-06-25 16:50:51 -04:00
Hermes 8415ceb926 fix: only override animated bones, not all bones 2026-06-25 16:39:09 -04:00
Hermes d6df49844e fix: explicit type for peak variable 2026-06-25 16:24:28 -04:00
Hermes 9e498370fb replace AP animation with code-driven bone override system 2026-06-25 16:22:58 -04:00
Hermes 03e2c29fa5 fix: call update_skeleton() to force bone transform refresh for rendering 2026-06-25 15:53:48 -04:00
Hermes 252137d10b debug: add error logging to animation creation 2026-06-25 15:51:38 -04:00
Hermes 9da0bc3297 fix: remove _update_skin() call 2026-06-25 15:46:29 -04:00
Hermes f776fe59e7 fix: use TYPE_ROTATION_3D=2 with Quaternion values and clean up dead code 2026-06-25 15:03:43 -04:00
Hermes 7e40085b80 fix: use TYPE_VALUE (0) for all animation tracks, not TYPE_ROTATION (1)
Animation.TrackType in Godot 4.2.1 doesn't have a named enum member for
value 1, so add_track(1) fails. Use add_track(0) for all tracks - the
Animation API accepts both Vector3 and Quaternion values regardless of
track type.
2026-06-25 12:26:21 -04:00
Hermes 6cfa8aa631 fix: use raw integer 1 for TYPE_ROTATION and remove unused param
- Animation.TYPE_ROTATION doesn't exist as enum in Godot 4.2.1
- Use raw integer: add_track(1) for ROTATION
- Remove unused 'length' parameter from _add_rot_track
- Rename 'name' to 'anim_name' to avoid shadowing Node.name
- Remove all extra parameters from _add_rot_track calls
2026-06-25 12:21:56 -04:00
Hermes aa9a957eac fix: use Vector3 euler for rotation tracks, NOT Quaternion
track_insert_key() returns -1 (error) when using Quaternion values
on TYPE_ROTATION (type=1) tracks in Godot 4.2.1.

The correct format is: add_track(1) + track_insert_key(track, time, Vector3(euler))

This applies to both Walk/Run/Jump/Idle animations - all must use
Vector3 euler angles for rotation keyframes.
2026-06-25 12:10:00 -04:00
Hermes 92ebd6c8d2 refactor: use AnimationPlayer with TYPE_ROTATION tracks instead of manual bone poses
After extensive testing discovered:
- set_bone_global_pose_override() does NOT affect the GPU skinning pipeline
- set_bone_pose_rotation() only works when AP is stopped but changes don't
  appear during render because _update_skeleton() runs during render pass
- The AnimationPlayer DOES work in the actual game (position logs prove it)
- GLB animations are stripped (near-zero motion) causing T-pose
- Solution: Create proper Animation resources with type=1 (ROTATION) tracks
  using Quaternion keyframes and play through the AnimationPlayer
- Removed all manual bone pose code that was fighting the render pipeline
2026-06-25 12:05:01 -04:00
Hermes 6e73fd7703 fix: use local pose (rest*offset) instead of global override for skinning
CRITICAL DISCOVERY: set_bone_global_pose_override() does NOT affect
the MeshInstance3D skinning pipeline. It only changes get_bone_global_pose()
return value. The actual skinning uses rest * pose * parent transforms.

The correct approach:
1. Stop AnimationPlayer (ap.stop() + ap.active = false)
2. Set local pose = rest_local_quat * offset_quat
3. This makes the final bone transform = rest * (rest * offset) = rest * offset
4. The skinning pipeline correctly uses this for vertex deformation

Also discovered: set_bone_pose_rotation() only works when AP is stopped.
When AP is active, it overwrites the local pose every frame.
2026-06-25 11:45:23 -04:00
Hermes f085fdf7b7 debug: add logging for parent/SM null checks 2026-06-25 11:38:48 -04:00
Hermes b525db7c53 debug: add logging to _process and _apply_procedural_animation 2026-06-25 11:38:19 -04:00
Hermes 000caa7791 fix: clear global pose overrides when switching to procedural anim 2026-06-25 11:34:30 -04:00
Hermes e4ea177eaf fix: use world-X rotation for locomotion swing via global pose override
After extensive testing, discovered that:
1. set_bone_pose_rotation() sets ABSOLUTE local rotation, not offset
2. Bones have complex non-identity rest poses (e.g. LeftUpperArm euler=(-0.161,-2.760,-2.760))
3. Setting local pose to identity collapses the model
4. Correct approach: use set_bone_global_pose_override() with rotation
   applied around world X axis (forward/back swing) pre-multiplied on
   the rest pose basis

Also cache rest pose local transforms (rotation + position) and use them
when resetting bones or applying position offsets.
2026-06-25 11:33:49 -04:00
Hermes 35decd65b3 fix: use world-space rotation via global pose override
The bone rest poses have complex non-identity rotations (e.g. LeftUpperArm
euler=(-0.161,-2.760,-2.760)). Applying local rotations on top of these
produces incorrect results. Fix by using set_bone_global_pose_override()
with world-space rotation pre-multiplied on the rest pose basis.
2026-06-25 11:24:54 -04:00
Hermes 35cb2ef10c chore: remove debug logging from procedural animation 2026-06-25 11:14:15 -04:00
Hermes f457c01f38 fix: always replace all locomotion animations (Jump was not being detected as broken) 2026-06-25 11:13:39 -04:00
Hermes ea23f1c843 fix: stop AnimationPlayer from overwriting procedural bone poses
AnimationPlayer was still processing GLB animations and overwriting
our manually-set bone rotations. Fix by:
1. Setting animation_player.active = false for procedural anims
2. Resetting bones to rest pose when switching to procedural anim
3. Re-enabling AP only for non-procedural anims (Death, Crouch)
2026-06-25 11:13:07 -04:00
Hermes 30e2a49730 debug: add logging to verify procedural animation execution 2026-06-25 11:11:41 -04:00
Hermes 5d360d256e fix: replace AnimationPlayer tracks with code-driven bone rotation system
Godot 4.2.1 AnimationPlayer cannot rotate runtime-loaded GLB bones via
':rotation' track paths. The AnimationPlayer only updates bone positions,
not rotations, on runtime GLB skeletons.

This fix replaces the entire animation approach:
- Remove AnimationPlayer track-based rotation attempts
- Add code-driven system that stores animation keyframe data in
  dictionaries and applies bone rotations directly via
  skeleton.set_bone_pose_rotation() each frame
- _is_animation_broken() detects Blender GLTF track stripping
- _ensure_locomotion_animations() replaces broken GLB anims with
  procedural data
- _apply_procedural_animation() interpolates euler angles, converts
  to Quaternion via Quaternion.from_euler(), and applies per bone
2026-06-25 10:53:54 -04:00
Hermes 86a13bc782 fix: replace stripped GLB animations with procedural rotation-based animations
Blender's GLTF exporter aggressively strips animation tracks that are
near rest pose. The Miku GLB has 54 tracks per animation but all
keyframe values are essentially rest pose (max delta 0.008 units),
causing the model to appear frozen in T-pose despite the AnimationPlayer
playing animations correctly.

This fix:
- Adds _is_animation_broken() to detect near-zero motion in animations
- Changes _ensure_locomotion_animations() to replace broken animations
  (not just create when missing)
- Uses TYPE_VALUE tracks with ':rotation' suffix for proper bone rotation
- Fixes hips bounce to use position track instead of rotation
- Adds _add_rotation_track, _add_position_track, _add_position_track_catmull
  helper functions
2026-06-25 10:38:19 -04:00
Hermes d6da221583 fix(characters): preserve all animation tracks in GLTF runtime loading
remove_immutable_tracks=true (default) strips bone tracks where keyframes
match rest pose, removing 80%+ of tracks and causing T-pose.
Editor import uses false; runtime must match.

Closes T-pose bug where animations reported playing but model stayed rigid.
2026-06-25 10:04:56 -04:00
HermesandClaude 4.7 d297da4c35 fix: resolve GLB character T-pose by fixing skeleton binding order and skin update timing
Two root causes fixed:
1. Skeleton binding fix was skipped because `skeleton` variable was null at the
   time of the check (assigned AFTER the mesh fix block). Moved skeleton
   discovery BEFORE the mesh binding fix so the MeshInstance.skeleton path
   is correctly set to "..".

2. Skin deformation wasn't triggering because `_update_skin()` (which calls
   `force_update_all_bone_transforms()`) was throttled to 30fps AND ran BEFORE
   the AnimationPlayer's `_process()` due to tree order. AnimationPlayer.advance()
   sets bone poses but does NOT emit `bone_pose_changed`, so the MeshInstance
   never knew to re-compute its vertex buffers. Fixed by using `call_deferred`
   to ensure `_update_skin()` runs AFTER all `_process()` calls (including AP),
   and removing the throttling so it runs every frame.

Co-Authored-By: Claude 4.7 <[email protected]>
2026-06-25 01:29:14 -04:00
Hermes df4c70017c fix: use force_update_all_bone_transforms() instead of undeclared constant
NOTIFICATION_UPDATE_SKELETON is not accessible in GDScript scope.
Use Skeleton3D.force_update_all_bone_transforms() which recomputes the
full bone hierarchy and emits bone_pose_changed signals.

Co-Authored-By: Hermes Agent <[email protected]>
2026-06-25 01:00:34 -04:00
Hermes 43b870465b fix: correct skin deformation and scale for GLB character models
The Miku model T-posed because the previous _update_skin() used
set_bone_pose(i, get_bone_pose(i)) which is a no-op when the pose
is unchanged. Replace with skeleton.notification(NOTIFICATION_UPDATE_SKELETON)
to force the Skeleton3D to recompute bone transforms and notify the
MeshInstance to update vertex buffers.

Also fix scale: the GLB is ~1.2m tall (not 1.6m as documented), so
set scale_factor=1.25 to fit the 1.8m player capsule, and add
position_y_offset=-0.2 to align the model's feet with the player origin.

Co-Authored-By: Hermes Agent <[email protected]>
2026-06-25 00:56:25 -04:00
Hermes c4dc4ca77c fix: force skeleton skin update at runtime for GLTF models
The AnimationPlayer correctly updates bone poses (proven in tests)
but the MeshInstance visual doesn't reflect the changes. Added
throttled _update_skin() that re-applies bone poses every ~33ms
to trigger NOTIFICATION_UPDATE_SKELETON on the Skeleton3D,
which causes the MeshInstance to update its vertex buffers.

Also set AnimationPlayer process_mode to ALWAYS to ensure it
processes regardless of parent node state.
2026-06-25 00:30:28 -04:00
Hermes 73831cb418 fix: force bone pose update for GLTF runtime skin deformation
GLTF runtime loaded models may not trigger automatic MeshInstance
vertex deformation when the AnimationPlayer updates bone poses.
Added throttled _update_skin() that re-applies current bone poses
to trigger NOTIFICATION_UPDATE_SKELETON, ensuring the renderer
sees the updated vertex positions.
2026-06-25 00:26:34 -04:00
Hermes fead2cc055 fix: resolve GDScript warnings for unused vars and shadowed names
- Prefix unused variables with underscore (chest, left_hand, right_hand, l_foot, r_foot)
- Rename 'name' parameter to 'bone_name'/'anim_name' to avoid shadowing Node.name
2026-06-25 00:04:59 -04:00
Hermes 50de6df450 fix: use animated GLB with all locomotion animations and fix scaling
The skinned player model was loading miku_rigged_final.glb which only
contained a single "Idle" animation (1 track, near-rest pose). This caused
"animation 'Jump/Walk/Run' not found" spam and a T-pose appearance.

Switch to miku_rigged_animated.glb which has all 7 animations
(Crouch, Death, Idle, Idle2, Jump, Run, Walk).

Additional fixes:
- Fix scaling: the GLB is already in meters (~1.6m tall), not cm.
  Removed the unconditional 0.01 cm->m conversion that was shrinking
  the character to 1/100th size.
- Fix bone track paths: use "MikuRig/Skeleton3D:" prefix matching the
  actual scene tree structure (AnimationPlayer is sibling of MikuRig).
- Add _ensure_locomotion_animations() to create Walk/Run/Jump/Idle
  procedurally if the GLB is missing them.
- Eliminate per-frame "not found" log spam with rate-limited warnings.
- Update level_runtime.gd and test_level_builder.gd to use animated GLB.

Closes #3d-player-model animation pipeline
2026-06-24 23:58:28 -04:00
Hermes 1653d404e2 fix: use correct Godot skeleton bone animation path format
The track path must be 'MikuRig/Skeleton3D:BoneName' (no :property suffix)
and the track type must be TYPE_VALUE with Vector3 position values.
Using :rotation suffix caused the track to not resolve.

Verified: Spine pose origin changes from (0,0,0) to (0,0.1,0) during animation.
2026-06-23 19:36:15 -04:00
Hermes 39063bfe9e fix: create proper Idle animation in Godot when GLB export loses tracks
The Blender GLTF exporter was stripping animation tracks (only 1 of 51
channels survived export). Now SkinnedPlayerModel detects this at
runtime and creates a proper Idle animation programmatically using Godot's
Animation API with 5 bone tracks (Spine, Arms, Neck, Hips).

Also switched model_path back to miku_rigged_final.glb since the
re-export wasn't adding usable animations.
2026-06-23 18:55:12 -04:00
Hermes 0b0497e128 chore: clean up debug and editor scratch files 2026-06-23 17:37:49 -04:00
Hermes e2a4e3997e fix: use a.loop_mode instead of a.mode for Godot 4.x Animation 2026-06-23 12:36:53 -04:00
Hermes 0da573f11f fix: use _find_skeleton and _find_animation_player consistently 2026-06-23 12:12:10 -04:00
Hermes 89ddac072b fix: resolve parse errors in skinned_player_model and level_runtime
- Remove duplicate is_local variable in level_runtime.gd line 224
- Remove orphaned animation code from old load_model in skinned_player_model.gd
- Both files now compile cleanly
2026-06-23 11:02:22 -04:00
Hermes 5e12502a18 fix: use GLTFDocument to load GLB at runtime (no .import needed)
Godot headless/CLI can't import GLB files — the GLTF loader only works
in the editor GUI. Switched SkinnedPlayerModel to use GLTFDocument.append_from_buffer()
which parses GLB data from raw bytes at runtime.

Verified: GLB parses successfully, generates scene with MikuRig skeleton,
AnimationPlayer, and 7 animations (Crouch, Death, Idle, Idle_001, Jump, Run, Walk).

Also added characters/glb_loader.gd as a reusable utility for loading
any GLB file at runtime.
2026-06-23 01:15:37 -04:00
Hermes 85066f71f3 fix: add position offset for skinned model alignment 2026-06-23 00:35:14 -04:00
Hermes a83fa41b19 fix: use original rigged skeleton for animations, add 6 new animations
The twintails were moving as arms because the auto-rig script treated the
combined mesh as one piece. Now using the original miku_rigged_final.glb
skeleton (which has correct skin weights from the model creator) and
adding 6 new animations (Idle, Walk, Run, Jump, Crouch, Death) on top
of the existing skeleton.

- tools/add_animations.py: adds animations to an existing rigged GLB
- tools/rig_and_animate.py: updated to separate mesh into loose parts
  before rigging (for future use with other models)
- miku_rigged_animated.glb: now 7 animations with correct skin weights
2026-06-23 00:33:40 -04:00
Hermes 0672bebe7b feat: add rigged+animated Miku player model with auto-rig pipeline
- Add miku_rigged_animated.glb (3.27MB) with 18-bone skeleton and 6 animations
  (Idle, Walk, Run, Jump, Crouch, Death) via Blender auto-rig script
- Add tools/rig_and_animate.py: reusable Blender script for rigging any humanoid
  mesh with Mixamo-compatible bone naming
- Update SkinnedPlayerModel: scale_factor, first-person mode, animation state
  matching (Idle/Walk/Run/Jump/Crouch/Death)
- Update level_runtime.gd: use skinned model for local player, procedural
  humanoid for remote players
- Update skin_manager.gd and test_level_builder.gd to use animated model
- Fix Godot 4.2.1 'is not Type' syntax in 5 weapon files
- Add editor/import_miku_to_tscn.gd for editor-based GLB import
2026-06-23 00:19:52 -04:00
Hermes f7cd3571fd fix: replace f-strings with GDScript % formatting 2026-06-22 22:20:36 -04:00
Hermes a2956635c0 fix: third-person camera orbits player facing + robust animation handling
Third-person camera:
- Camera offset now follows player's forward direction using basis.z
- Smoothly orbits behind player when they turn
- Looks at player's head area (y+1.5)

SkinnedPlayerModel:
- Creates AnimationPlayer programmatically if GLB doesn't have one
- Copies animations from armature's animation_data to the player
- Handles both direct actions and NLA tracks
- More detailed debug output for scene tree and animation state
2026-06-22 22:02:30 -04:00
Hermes c033247f75 fix: rename param to 'on' to avoid shadowing Node3D.show()/is_visible() 2026-06-22 21:56:21 -04:00
Hermes 00141f80cb fix: set position before adding grenade to tree, remove duplicate add_child 2026-06-22 21:52:48 -04:00
Hermes 3e849551bd fix: rename 'name' param to 'anim_name' to avoid shadowing Node.name 2026-06-22 21:42:38 -04:00
Hermes 1372db9d0e fix: prefix unused 'impulse' param with underscore in walking_dummy 2026-06-22 21:40:56 -04:00
Hermes cd1722ce74 fix: rename 'is_visible' param to 'show' to avoid shadowing Node3D.is_visible() 2026-06-22 21:06:35 -04:00
Hermes c8fcefe7ab fix: can't use look_at() before node is in tree, use rotation_degrees instead 2026-06-22 20:36:13 -04:00
Hermes ade3d54d20 fix: rename shadowed 'visible' param, prefix unused 'impulse' with underscore 2026-06-22 20:34:02 -04:00
Hermes a1f48d9ef0 fix: string repeat() instead of * operator in GDScript 2026-06-22 20:27:26 -04:00
Hermes b1aec93fd5 fix: animation debug + F1 third-person camera
SkinnedPlayerModel:
- Added _print_tree debug to see GLB scene structure at runtime
- Auto-play Idle animation on load
- Added periodic debug output for animation state
- Only change animation when target differs from current

Third-person camera:
- F1 toggles between FPS and third-person view
- Third-person camera positioned behind/above player
- Smooth follow with lerp
- Camera tracks player position each frame
2026-06-22 19:55:54 -04:00
Hermes e84a9d641a fix: textured Miku model + ragdoll color
- Re-exported Miku with properly mapped textures (9.8MB GLB)
- Texture files saved alongside GLB for Godot import
- Fixed Blender 5.0 API: 'Base Color' (not 'BaseColor'), major_segments (not segments)
- Ragdoll now uses Miku teal color (0.0, 0.75, 0.75)
- Player 1 uses SkinnedPlayerModel (no procedural generation)
- Other players still use procedural HumanoidModel
2026-06-22 19:46:22 -04:00
Hermes e1d2d140cb fix: calculate speed from player velocity instead of non-existent property 2026-06-22 19:10:53 -04:00
Hermes 92713a681a fix(miku): correct scale + animation playback
- Fixed Blender export: remove FBX armature modifier before scaling,
  then transform_apply(scale=True) before creating new armature
- Model now exports at correct 1.8 unit height (verified in Blender)
- SkinnedPlayerModel now polls MovementStateMachine in _process
  to drive animations based on movement state
- Player 1 gets Miku at origin, others get procedural humanoid at y=-0.9
2026-06-22 18:15:03 -04:00
Hermes ae8231d31d feat(miku): proper rigged model from Sketchfab + Blender
Pipeline:
1. Downloaded TDA-style Miku model from Sketchfab (19MB FBX, 38K verts)
2. Imported to Blender, scaled to Godot humanoid proportions (1.8u tall)
3. Created clean 17-bone humanoid armature (Hips/Spine/Chest/Head/Arms/Legs)
4. Auto-weight-painted mesh to armature
5. Added Idle (breath bounce) and Run (cycle) animations
6. Exported as GLB with embedded animations (3MB)

New:
- SkinnedPlayerModel class: loads GLB with own armature+animations
- Player 1 spawns with Miku model, others get procedural humanoid
- Completely separate from procedural model system

Files:
- assets/characters/skins/miku_rigged_final.glb (3MB, 48K verts, 17 bones, 2 anims)
- characters/skinned_player_model.gd
- Removed old procedural GLB attempts
2026-06-22 17:12:24 -04:00
Hermes b0fdc508f1 feat(miku): add rigged Miku character model with armature
- Generated rigged Miku model in Blender (79 mesh parts, 17 bones)
- Bone names match Godot Humanoid skeleton (Hips, Spine, Chest, Head, etc.)
- Includes idle animation (breath bounce)
- Fixed GLB export for Blender 5.0 API compatibility
- Updated HumanoidModel.apply_skin() with orientation fix
- Player 1 spawns with Miku rigged model, others get default blue
- Preview renders in docs/characters/skins/

Pipeline established:
- Blender headless -> Python script -> GLB export -> Godot import
- Skin system: PlayerSkin resource + SkinManager
- apply_skin() handles both GLB model and procedural color tint
2026-06-22 16:51:22 -04:00
Hermes a55eaff279 feat(skin): add skin system + Miku character skin
Skin System:
- PlayerSkin resource class (model_path, color_tint, etc.)
- SkinManager with default skins: Default, Red Team, Forest, Miku
- HumanoidModel.apply_skin() supports both GLB model replacement
  and procedural color tinting
- Procedural meshes auto-hide when GLB model is loaded

Miku Skin:
- 43-part procedural model generated in Blender (headless)
- Twin tails, headphones, thigh-high socks, idol outfit
- Teal/cyan color scheme with red accents
- Exported to assets/characters/skins/miku.glb
- Applied to player 1 in test level builder

Pipeline:
- Blender 5.0.1 + MCP addon installed
- Bridge script for socket-based Blender control
- docs/3D_ASSET_PIPELINE.md documents full workflow
2026-06-22 13:53:17 -04:00
Hermes 671b36c20c fix(map): complete rewrite of arena layout
Problems fixed:
- Player spawn moved from inside central platform to open area (z=20)
- Removed broken internal stairs inside tower (disconnected ramps)
- Removed corner towers, walkways, stepping stones, wall-run corridor,
  wall-climb surfaces, grapple anchors — all were overlapping or unreachable
- Reduced arena from 96x96 to 80x80 for tighter gameplay
- Crates properly spaced (1.5u apart), no overlaps or intersections
- Reduced from 6 spawns to 4 (corners only), each with 2 L-shaped cover
  walls and clear exit routes — no more 3-wall traps
- Spawn alcoves use 2 walls max with open diagonal exits
- Dummies placed in open areas away from geometry:
  TargetDummy at (0,-25), KillableDummy at (25,0),
  WalkingDummy patrols z=-30 from x=-20 to 20
- Central platform simplified: 16x16 base (2u) + 8x8 upper (4u)
- 4 cardinal ramps properly connect ground to platform edge
- Cover walls placed in open lanes, not blocking movement
- Barrels placed along outer walls, not in pathways
2026-06-21 22:41:40 -04:00
Hermes 3584a7e6c5 feat(props): integrate Blender GLB props into movement map
- Replace primitive _crate(), _barrel(), _pillar() with GLB scene instances
- Add 7 prop preloads: crate, barrel, pillar, ramp, weapon_pickup, health_pack, ammo_pack
- Add _load_props() and _place_prop() helpers with null guards
- Add pickup placements: weapon (center), health (east/west), ammo (center/NW/SE)
- Props load at runtime via load() - appear once Godot editor imports the GLB files
- Null guard prevents crashes in headless mode before import
2026-06-21 22:28:41 -04:00
Hermes 718a1477bf feat(assets): add 3D prop meshes generated via Blender headless
Props generated with Blender 5.0.1 headless + glTF export:
- crate.glb (wooden crate)
- barrel.glb (metal barrel)
- weapon_pickup.glb (glowing platform)
- health_pack.glb (cross-shaped pickup)
- ammo_pack.glb (ammo box)
- pillar.glb (structural pillar)
- ramp.glb (ramp mesh)

Also includes Blender MCP bridge setup in blender-mcp/ for
future AI-assisted 3D modeling.
2026-06-21 01:55:39 -04:00
Hermes 462d12bee5 fix: Godot 4.2.1 compat and map builder fixes
- Fix tab character in _build_elevated_walkways() call (line 63)
- Fix untyped 'pos' variable in grapple pillar loop (line 376)
- Remove fog_sky_affinity (Godot 4.3+ only property)
- Replace preload() with load() for runtime script loading
- Replace 'is DoubleBarrelShotgun' type check with duck-typing
- Fix 'is not OfflineMultiplayerPeer' syntax in grenade spawn
- Replace absf() with abs() in state_machine, state_air, state_ground
- Update test_level.tscn to remove stale UID reference

All fixes verified: PARSE OK and scene runs without script errors
on Godot 4.2.1 headless.
2026-06-21 01:24:42 -04:00
Hermes f164afecd8 feat(map): high-quality multiplayer movement map
96x96 arena with multi-level layout, grapple points, wall-run/climb
surfaces, 6 team spawns with anti-camping cover, and varied movement
challenges.

Map Features:
- Central multi-level structure: 20x20 platform (3u), 10x10 tower (6u),
  14x14 top platform (9u) with crown cover walls and internal stairs
- 4 corner towers (5u high) with railings and top platforms
- 4 elevated mid-side walkways (3u) with railings connecting center to edges
- 8 ramps: cardinal to central, diagonal to corners, to walkways
- Quarter-pipe curve (8 segments) near NE corner
- 4 parkour stepping stone chains (NW, NE, SW, SE quadrants)
- Wall-run corridor (N side) with parallel walls, obstacles, and floor gap
- Wall-run surfaces on all 4 outer wall sections
- Wall-climb surfaces around central tower and mid-map (warm color)
- 4 grapple anchor pillars (15u tall) with visible tops at cardinal points
- Cover system: low walls, crate clusters, corridor walls, structural pillars
- Speed corridor with side rails, speed bumps, and floor markings
- 6 spawn alcoves (3 red: NW/SW/West, 3 blue: NE/SE/East) with cover walls
- Anti-spawn-camping: each alcove has 2+ exits and blocking walls
- Floor accent tiles in central area
- Atmospheric sky with procedural sky material and fog
- Directional sun + fill light + center omni + 6 spawn accent lights
- Decorative: barrels, hazard stripes, accent beams, spawn area lights
- Full multiplayer spawning, weapon system, HUD, entities preserved
- All movement states: ground, air, wall_run, wall_climb, wall_cling,
  slide, dash, grapple

Parse verified clean with Godot 4.2.1 headless.
2026-06-21 01:03:34 -04:00
Dotts 3bdf8f413b Merge pull request 'Feat/12 add effects and better animations to fpv' (#17) from feat/12-add-effects-and-better-animations-to-fpv into main
Reviewed-on: #17
2026-06-11 19:35:33 -07:00
DottsGit 3c512870f2 feat: implement movement state machine ground/air states and initialize weapon manager system 2026-06-10 22:37:17 -04:00
DottsGit 3dc7fda622 feat: implement modular movement state machine and ground, air, and wall climb states 2026-06-10 22:27:10 -04:00
DottsGit 18ab9da4a3 feat: implement MovementStateMachine to manage modular player movement states and mechanics 2026-06-10 22:21:41 -04:00
DottsGit da4a6442c7 feat: implement player movement controller with grounded state, stair stepping, and wall interaction mechanics 2026-06-10 22:11:00 -04:00
DottsGit def2925a43 feat: implement pause menu with loadout, settings, and input rebinding support 2026-06-10 21:58:44 -04:00
DottsGit 4cb27f991e feat: add PlayerMovementController for character locomotion, state machine integration, and multiplayer synchronization 2026-06-10 21:12:44 -04:00
DottsGit 5e4507ab24 feat: implement pause menu system with loadout and settings configuration management 2026-06-10 21:08:01 -04:00
DottsGit 887d9c1523 feat: implement PlayerMovementController and grenade projectile system 2026-06-09 23:04:32 -04:00
DottsGit e8e5391d9f feat: implement MovementStateMachine for handling player movement states and mechanics 2026-06-09 22:53:24 -04:00
DottsGit 86abae27dd feat: implement weapon manager with viewmodel viewport, procedural arms, and loadout synchronization 2026-06-09 22:47:27 -04:00
DottsGit cff83b6356 feat: implement WeaponManager with viewport-based viewmodel rendering and procedural animation support 2026-06-09 22:44:56 -04:00
DottsGit 3e6eb40add feat: implement base weapon system classes for hitscan and projectile mechanics 2026-06-09 22:40:52 -04:00
DottsGit f63187e493 feat: implement foundational weapon systems, including hitscan and projectile base classes, reload mechanics, and player movement/camera integration. 2026-06-09 22:33:40 -04:00
DottsGit 5102b770c8 chore: reorder and consolidate window display settings in project configuration 2026-06-09 22:15:46 -04:00
Dotts 93eb98cd13 Merge pull request 'Feat/11 adding better fundamental weapon models and some textures' (#16) from feat/11-adding-better-fundamental-weapon-models-and-some-textures into main
Reviewed-on: #16
2026-06-09 19:14:24 -07:00
DottsGit ed973c3b3b feat: implement persistent settings manager, add character movement controller, and create FPS map blockout scene 2026-06-09 20:10:12 -04:00
DottsGit 0fcb5b43a4 feat: add fps_blockout test map with environmental lighting and collision geometry 2026-06-09 19:54:50 -04:00
DottsGit 9c653cac48 feat: implement procedural arena generation system and support infrastructure 2026-06-09 19:45:35 -04:00
DottsGit 8c61318a16 chore: remove orphaned UID file for inspect_models.gd 2026-06-07 21:12:40 -04:00
DottsGit 4bb867a6f2 feat: implement plasma gun and rocket swarm mechanics along with various 3D asset imports 2026-06-07 18:23:47 -04:00
Dotts c9f082ebba Merge pull request 'feat: implement movement state machine architecture and add various sound assets' (#15) from feat/11-adding-wall-climbing-and-valuting-fundamentals into main
Reviewed-on: #15
2026-06-07 13:13:37 -07:00
DottsGit 22ea918e6d feat: implement movement state machine architecture and add various sound assets 2026-06-07 16:13:01 -04:00
Dotts 064bc90f53 Merge pull request 'Feat/10 adding multiplayer fundamentals' (#14) from feat/10-adding-multiplayer-fundamentals into main
Reviewed-on: #14
2026-06-06 19:31:25 -07:00
DottsGit 1a989f3586 feat: implement player movement controller and movement state machine system 2026-06-06 21:16:48 -04:00
DottsGit 30dbf7a4cd feat: implement player movement controller and initial weapon framework including double barrel shotgun and explosive projectiles 2026-06-06 21:09:40 -04:00
DottsGit 54dfd342c2 feat: implement walking and killable enemy dummy entities and player movement controller base 2026-06-06 21:00:45 -04:00
DottsGit eb9344c0bb feat: implement PlayerMovementController with support for movement states, multiplayer synchronization, and audio/UI systems 2026-06-06 20:51:34 -04:00
DottsGit 07a15fe998 fix: a lot of network sync fixes 2026-06-06 20:45:10 -04:00
DottsGit dbbfbef4a4 fix: a lot of networking bugs 2026-06-06 19:26:22 -04:00
DottsGit ff3e01a9e5 feat: implement procedural humanoid animations, modular weapon system, and dummy entity base classes 2026-06-06 19:10:29 -04:00
DottsGit fcdd75afc0 feat: add match HUD with scoreboard, timer, and dynamic killfeed 2026-06-06 15:19:59 -04:00
DottsGit cd034d1aea feat: add dust2 level builder, player movement controller, and UI menu components 2026-06-06 15:17:54 -04:00
DottsGit 31336fe870 feat: add Dust 2 level builder and implement pause menu UI with settings and loadout management 2026-06-06 14:49:45 -04:00
DottsGit acfb8bca16 feat: implement network manager, pause menu, and export configuration for project deployment 2026-06-06 14:33:43 -04:00
DottsGit d3680765da adj: respawning kills self 2026-06-06 14:23:28 -04:00
DottsGit 2a3e6a2c78 fix: fixing respawning 2026-06-06 14:18:45 -04:00
DottsGit 9adbff3968 feat: implement multiplayer network manager and player movement controller with base game entities and UI 2026-06-06 14:03:57 -04:00
DottsGit 168bd8f1ce feat: implement networked match HUD with real-time scoreboard and killfeed systems 2026-06-06 13:49:12 -04:00
DottsGit aefed46daf feat: adding basic multiplayer 2026-06-06 13:28:43 -04:00
Dotts 59096d322a Merge pull request 'Feat/9 adding main menu fundamentals' (#13) from feat/9-adding-main-menu-fundamentals into main
Reviewed-on: #13
2026-06-06 10:09:49 -07:00
DottsGit 45353dc982 fix: dead player ragdoll no longer triggers leaving the combat area 2026-06-06 13:09:01 -04:00
DottsGit 2aa7ff0337 feat: added loadout options to the main menu 2026-06-06 13:05:50 -04:00
DottsGit 11adbc339a feat: singleplayer level selector 2026-06-06 13:03:35 -04:00
DottsGit 2baf27efd1 feat: basic main menu to load dust 2 on singleplayer 2026-06-06 12:55:17 -04:00
Dotts 83c6ce7993 Merge pull request 'Feat/8 adding a=map=basic=dust2' (#12) from feat/8-adding-a=map=basic=dust2 into main
Reviewed-on: #12
2026-06-06 09:44:38 -07:00
DottsGit 5f2c2462e2 feat: preventing players from leaving the playable area 2026-06-06 12:44:16 -04:00
DottsGit 64e024eb32 fix movement through the map is easier 2026-06-06 12:28:48 -04:00
DottsGit 050912ca61 fix: better sites 2026-06-06 12:26:03 -04:00
DottsGit 775e57ba4a feat: basic dust 2 2026-06-06 12:23:39 -04:00
Dotts 6a0a4a8aeb Merge pull request 'fix: awp fov' (#11) from feat/7-adding-first-person-model into main
Reviewed-on: #11
2026-06-06 09:11:39 -07:00
DottsGit c9d18ffe23 fix: nailgun projectiles and tracers 2026-06-06 12:10:48 -04:00
DottsGit 3488b8e005 fix: 3rd person and first person views 2026-06-06 11:51:28 -04:00
DottsGit 909b25548c fix: fov now linearly increases with speed and first person weapon view no longer clips through walls 2026-06-06 11:16:50 -04:00
DottsGit 10416a83a0 fix: awp fov 2026-06-06 11:05:46 -04:00
Dotts 8e2b198f94 Merge pull request 'Feat/6 knife and melee fundamentals' (#10) from feat/6-knife-and-melee-fundamentals into main
Reviewed-on: #10
2026-06-05 23:26:43 -07:00
DottsGit 4eaf4671ef fix: removing placing the 2d graphic on entities and changing it to a swipe for the knife 2026-06-06 02:26:16 -04:00
DottsGit 18b8b3ee99 feat: added the knife 2026-06-06 02:22:27 -04:00
Dotts 90876611f4 Merge pull request 'Feat/5 adding settings fundamentals and ui improvements' (#9) from feat/5-adding-settings-fundamentals-and-ui-improvements into main
Reviewed-on: #9
2026-06-05 23:03:52 -07:00
DottsGit 27460be0ea fix: scoping 2026-06-06 02:03:05 -04:00
DottsGit a47d01632d adj: projectile physics when moving backwards 2026-06-06 01:49:43 -04:00
DottsGit 8118100a95 feat: added explosion sounds 2026-06-06 01:42:15 -04:00
DottsGit a6f5f24420 adj: less obstructive damage numbers 2026-06-06 01:30:39 -04:00
DottsGit d784045214 feat: added audio settings 2026-06-06 01:26:51 -04:00
DottsGit 46cee744d1 fix: 2d surface hit graphics no longer appear when hitting an entity 2026-06-06 01:18:51 -04:00
DottsGit e41e64de78 feat: pause menu is just now menu and does not pause 2026-06-06 01:11:38 -04:00
DottsGit 6dceab46db feat: fps counter and limiter 2026-06-06 01:06:43 -04:00
DottsGit 33f5c7cb82 feat: added advanced settings for showing player movement debug and settings for mouse sensistivity 2026-06-06 00:51:53 -04:00
DottsGit 0c00f4c385 feat: adding better animations 2026-06-06 00:37:54 -04:00
DottsGit d0716f72e8 feat: added moving killable dummy 2026-06-06 00:31:43 -04:00
Dotts 0706d5ca73 Merge pull request 'Feat/3 adding playermodel' (#8) from feat/3-adding-playermodel into main
Reviewed-on: #8
2026-06-05 21:23:53 -07:00
DottsGit ee1114948b adj: adjusted the awp and swarm rocket damage 2026-06-06 00:22:45 -04:00
DottsGit 0b8dcaf3ce fix: ragdoll knockback is lowered across the board 2026-06-06 00:19:02 -04:00
DottsGit cb5708e19e fix: shotgun can kill the dummy without crashing the game again 2026-06-06 00:15:00 -04:00
DottsGit 6aae02bef8 feat: fixed ragdoll replacement 2026-06-06 00:09:20 -04:00
DottsGit acd495893d feat: added ragdoll, health, death, and adjusted mortar 2026-06-06 00:01:27 -04:00
DottsGit 8984739334 feat: added animations for movement 2026-06-05 13:42:18 -04:00
DottsGit 72346803a9 feat: humanoid player model without animations 2026-06-05 13:37:43 -04:00
Dotts 1e1568f748 Merge pull request 'Feat/3 grapple fundamentals' (#7) from feat/3-grapple-fundamentals into main
Reviewed-on: #7
2026-06-05 10:34:08 -07:00
DottsGit 2f259b5428 feat: adjusted the shotgun impulse knockback 2026-06-05 13:33:37 -04:00
DottsGit 6af87eb7bd feat: added rocket noises and smoke trails and adjusted AoE of rockets and damage falloff on AoE 2026-06-05 13:32:10 -04:00
DottsGit a85e0952c5 feat: added hud for utilites 2026-06-05 02:15:32 -04:00
DottsGit ecefeb9f7e feat: added grapple mechanic 2026-06-05 02:05:07 -04:00
826 changed files with 80159 additions and 1586 deletions
+326
View File
@@ -0,0 +1,326 @@
---
name: character-pipeline
description: Import, rig, stylize and animate an anime-styled character into Papaya-Shooter as a selectable skin — keeping the model's own skeleton, artist weights and separate body/cloth/hair meshes, with cloth and hair driven by the spring solver. Use when adding a new playable character, re-importing an existing one, debugging skinning/cloth/hair/animation problems on a character, or changing the cel-shaded look. Triggers on "add a character", "import a skin", "new playable model", "skirt clipping", "hair flailing", "T-posing", "character looks squashed".
---
# Character pipeline
Turns a source model into a playable, cel-shaded, cloth-simulated character skin.
The whole design follows one principle, which is also what the Hoyoverse-class
anime pipelines (Genshin / Star Rail / Zenless Zone Zero) are built on:
> **The character is not one object. It is a body, a set of garments, and hair —
> authored separately, rigged separately, and moved by different systems.**
> The body is skinned and animated. The garments and hair are bone chains that
> the animation never touches; physics moves them. Keeping those separate is
> what makes the result read as an anime character instead of a mannequin in a
> painted-on costume.
Everything below exists to protect that separation.
## The one rule
**If a model arrives with a skeleton, that skeleton ships.** Its bones, its
artist-painted weights, its per-part meshes and its skirt/hair chains all
survive. Only the ANIMATION is moved onto it.
The old route (`strip_rig.py``autorig.py``merge_animations.py`) solved a
bone-*naming* problem by destroying the asset — 18 meshes became 1, 21 skirt
bones and ~50 hair bones became 0, and 16% of vertices ended up pulled by both
legs. `tools/rig_map.py` solves naming properly now. **Never reach for
`strip_rig.py` or `--rebind`** unless the model genuinely has no skeleton at all.
## Doing it
```bash
# From an already-rigged local model (the normal case)
python tools/pipeline.py --input assets/characters/incoming/<name>.glb --name <name> --rigged
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN)
python tools/pipeline.py --uid <uid> --name <name>
# From an unrigged mesh — auto-rigs, and accepts the quality loss
python tools/pipeline.py --input <mesh.glb> --name <name>
# Rigged (or auto-rigged) but with NO skirt/hair bones — grow them, or the
# costume is welded solid and the spring solver has nothing to simulate
python tools/pipeline.py --input <model.glb> --name <name> --rigged --grow-cloth
```
Then, once, so Godot sees the new files:
```bash
godot --headless --path . --import
```
The result is `assets/characters/skins/<name>.glb` + `<name>.rig.json`, and a
registry entry in `skins.json` that `SkinManager` picks up with no code change.
Blender is required (`BLENDER_PATH`, or auto-found under
`C:\Program Files\Blender Foundation`). Godot lives at
`C:\Program Files\Godot\Godot_v4.7-stable_win64_console.exe`.
## The stages, and what each one protects
| Stage | Where | Protects |
|---|---|---|
| Fix unlit/emissive materials | `tools/gltf_fix.py` | Textures surviving import at all |
| Resolve bone ROLES, not names | `tools/rig_map.py` | The model's own skeleton |
| Rebuild parenting | `retarget.py::rebuild_hierarchy` | Limbs/cloth following the hips |
| Grow cloth chains (opt-in) | `tools/cloth_bones.py` | A costume that has no bones being able to move at all |
| 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, 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.
**The HOLD is per pose.** The runtime blends between exactly two holds, on
`ads`, so the lab offers two: low ready and aiming. Selecting a pose rebuilds
the hold sliders to that pose's — you never see a control belonging to the pose
you are not adjusting. Running and Crouched use the low-ready hold, and the
heading says so rather than letting someone tune "Running" and wonder why
standing still changed. `pitch` exists at low ready only: down the sights the
muzzle follows the camera, so there is nothing there to tune, and a slider that
does nothing is worse than a missing one.
**The wrists turn the HAND, not the gun.** The weapon is a child of a
BoneAttachment3D on the trigger hand, so the two are welded by construction: a
wrist rotation swings the barrel off the aim line and takes every control that
could correct it along with it, which made the knob useless for aligning a hand
to a gun. `ShooterPoseModifier.wrist_comp_r` is the exact counter-rotation in
the hand's local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it.
To rotate the GUN inside the hand instead, use the grip rotation in ANCHORS.
Knobs that describe the WEAPON and the hands on it — where each hand sits along
it and off its barrel line, the finger curls, the weapon size — are shared,
because shouldering a gun does not move the hand along it. Both wrists take
pitch, yaw and roll in the gun's own frame, per pose. Click a surface class
to isolate it — that is how the classifier gets checked: click `hair` and
anything else still standing was misclassified.
**Drag the coloured markers.** They ARE the anchor points the hands are solved
onto — red the trigger grip, green the support hand, blue the buttstock — and
the one under the mouse swells and draws through the body so it can be grabbed
where the hands would otherwise hide it.
Dragging an anchor is not the same as any slider:
| | moves |
|---|---|
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
| dragging a marker | the anchor itself, in three dimensions |
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
barrel, so the two hand anchors could slide up and down the gun and nowhere
else — no use for a handguard below the bore, an angled foregrip, or a pistol
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
for whichever pose is showing.
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
when it looks right — and measured, not assumed.
## Read before you touch anything
Load the reference that matches what you are doing. They are short and each one
is a list of things that cost a debugging cycle to learn.
- **`references/failure-modes.md`** — **read this first.** Seven characters
shipped "All checks passed" and four were visibly broken. What each failure
was, why the suite missed it, and the rule that generalises it to any model.
- **`references/separation.md`** — body vs. garments vs. hair: what must stay
separate, how cloth chains are detected and classed, why cloth is never
skinned to a leg, and the ZZZ-convention mapping.
- **`references/growing-cloth-bones.md`** — what to do when the source has no
skirt or hair bones: how the chains are fitted to the geometry and re-weighted.
- **`references/rigging.md`** — role resolution, hierarchy rebuild, cloth panel
subdivision, twist bones, joint helpers, the retarget maths.
- **`references/cloth-and-hair.md`** — the position-based spring solver, its
colliders, per-class tuning, collision hulls, LOD and cost.
- **`references/stylization.md`** — cel shading, outlines, the imported
line-work trap, eyes, materials.
- **`references/verification.md`** — every measuring tool, what each one
actually measures, and the pose-reading trap that invalidated all of them
once. **Read this before trusting any measurement.**
## Non-negotiables
1. **Never join meshes.** Per-part meshes are how body, cloth and hair stay
separable — for materials, for the outline pass, and for the cloth solver's
hull extraction.
2. **Never key cosmetic bones.** If a clip has tracks on skirt/hair bones, the
AnimationPlayer overwrites the solver every frame and the cloth goes rigid.
3. **Never skin cloth to a leg.** A vertex weighted 0.9 to a thigh cannot be
moved by its own cloth bone, so the solver loses the authority to push it out
of that thigh — and the leg still overtakes it. There was a
`bind_cloth_to_legs()`; it is deleted, and the note above its grave in
`retarget.py` says why.
4. **Never run `SkinLegRepair` on authored weights.** It snaps weights and
deletes triangles. It exists only to undo auto-rigging. It is gated on
`weights_authored`, which is MEASURED, not assumed.
5. **Measure from inside the modifier pass.** See `references/verification.md`.
6. **Never assume an axis.** Up, forward and scale are all measurable from the
skeleton. Assuming +Z is up scaled three characters 7x and left them on their
backs — and the height check passed on every one of them, because the number
being normalised always comes out right whether or not it was the right
number. See `references/failure-modes.md`.
7. **Never look a bone up by name.** `tools/rig_map.py` resolves roles and writes
them to the sidecar so nothing downstream has to guess. Any hardcoded spelling
`_find_bone(["RightHand", ...])`, a `thigh`/`shin` substring test — is a rig
this project has not met yet. Four characters could not hold a gun because of
exactly one such lookup.
## After importing
```bash
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 |
|---|---|---|
| Idle skirt movement | < 0.1 deg/frame | 0.5+, or never decaying |
| Leg inside cloth, idle/walk | < 25 mm | 100 mm |
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
| Solver cost | ~2.6 ms/character | 10 ms |
| Stride vs. travel direction | < 10° (except a capped sidestep) | 90° |
| Bind-pose AABB | tall on the hips→head axis, others < 2.5 m | tallest axis is depth |
## Characters currently shipping
The six in `skins.json`, with what `surface_class_check` reports:
| Skin | Source | Cloth chains | Surfaces |
|---|---|---|---|
| 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
- **Peak cloth clipping** in a run, slide and dash sits at ~95 mm of thigh
inside the skirt. Idle, walk and fall are clean. The solver sees the contact
and pushes on it every iteration; the remaining gap is a standing fight
between the collision and the garment's own shape constraints.
- **No foot IK.** Feet do not plant on ground height, so stairs and uneven
ground read as sliding.
- **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`.
**Diagnosed, not fixed.** Her `driven_bones` contains `Root_001` through
`Root_007` — and those are her HAIR roots. The Godot surface dump shows
`Hair_A` dominated by `Root_001_001:3203`, `Root_007:3203`, `Root_005:2642`.
So the animation is keying bones that physics is supposed to own, which is
non-negotiable #2 being violated by the role resolver rather than by a clip.
The corroboration is in the surface table: her hair surfaces report only 1.8%
and 9.4% chain share, because most of their vertices belong to `Root_00N`,
which is in no chain at all.
`Root_00N` matches no COSMETIC stem, so `is_cosmetic` does not catch it and
nothing keeps it out of the driven set. Fixing it by adding "root" to the
stems would be wrong — a rig whose actual root is called `Root` would lose its
hips. The fix is structural: a bone whose geometry is dominated by a mesh
classified `hair` is a hair bone, whatever it is called. The surface table
now makes that answerable at build time, which it was not when this rig was
imported. Not attempted here — it needs a Blender re-run and re-verification
of all six characters.
Her `head` role is also wrong (`Unused_Noname_010`, when a real `Head` bone
exists and is in her spine chain), and her spine chain runs two junk bones
PAST the head. Probably the same import; worth fixing in the same pass.
- **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.
@@ -0,0 +1,146 @@
# Cloth and hair — `characters/spring_bones.gd`
A position-based (PBD) solver over the rig's own cosmetic bones, the same shape
as Magica Cloth 2's BoneCloth, which is the tool the Hoyoverse-style pipelines
are built around.
## Why cloth cannot be solved with weights
Weight a skirt to the thighs → trousers. Weight it to the hips → a rigid bell.
Neither is cloth. A skirt reads as cloth because it **lags** — it keeps going
when the hips stop, swings out through a turn, floats on the way up through a
jump. That is inertia, and inertia has to be integrated, not skinned.
## The solve
Installed as a `SkeletonModifier3D` **after** `ShooterPoseModifier`, so it reacts
to the final body pose (animation + lean/slide/aim layer).
1. **Every JOINT is a particle.** Bone `i` spans particle `i` to `i+1`, so a
bone's HEAD can move. This is the load-bearing choice: a contact with no
rotational leverage — a thigh against the top of a panel — is resolved by the
whole panel moving, which is what a real skirt does.
2. **Predict** with inertia, gravity and wind, in the chain ANCHOR's frame, so
travelling at a steady speed excites nothing.
3. **Relax everything together**, Gauss-Seidel: cross-panel links, then per
chain — bone length, bend limit, backstop, colliders.
4. **Convert to rotations once**, at the end, and feed back the poses the
skeleton ACTUALLY got.
Order matters: **links first, chains second**, so the last thing to touch any
particle is its collision. With the chains first, every iteration ended by
pulling neighbouring panels back toward their rest separation — straight into the
leg just cleared. Measured on a slide: 93 mm in, 95 mm out; with the links off
entirely the same frame solved to 27 mm.
Then a short tail of **collision-and-length-only** passes, because the bend limit
and the backstop are shape constraints and re-imposing them after each collision
makes the two argue rather than converge.
## What the previous version did wrong
One spring per bone plus FOUR repair passes behind it, each writing bone poses the
next read back and partly undid — and the last (an ancestor "lift") wrote poses
never fed back into the spring state, so every frame began pulling against a pose
the springs did not know about. That feedback was the skirt "glitching out".
Three bolt-on stages (a bodily chain push, an ancestor lift, a drape weight) all
existed because a rotation-only solver cannot clear a contact near the head it
rotates about.
## Per-class tuning (`TUNING`)
| | w | zeta | gravity | wind | stray | hinge | bend |
|---|---|---|---|---|---|---|---|
| hair | 15 | 0.45 | 1.6 | 0.30 | 0.45 | 26° | 52° |
| skirt | 12 | 0.48 | 2.0 | 0.18 | 1.25 | 78° | 55° |
| cloth | 13 | 0.55 | 1.9 | 0.18 | 1.10 | 62° | 52° |
- **`hinge` vs `bend`** are the same constraint meaning different things. Between
segments it is BENDING stiffness (how sharply cloth may crease) and belongs
tight. At the root it is a HINGE at the waistband — a knee coming up to hip
height puts a thigh horizontally through where the front panel hangs, and the
panel must ride onto it, which is most of a right angle. Magica's skirt guide
suggests 20° at the root, but that is for a chain whose first bone is a large
share of the skirt; Taila's first segment is 49 mm of a 288 mm panel, so 20°
there moves the panel below it by **17 mm**.
- **`stray`** is Magica's "Max Distance" — how far a particle may end from where
the animation alone would have put it. Tight on hair (it has nothing to get out
of the way of; this is what stops flailing), loose on cloth (a skirt has to lie
along a thigh that has gone horizontal in a slide).
- **`gravity` is small** because the authored rest pose already has the garment
hanging. A constant force offsets the resting particle by `g/w²`, so a large
value pulls the hem below where it was modelled — into the thigh it then has to
be pushed out of.
## Colliders
Five capsules, measured from the mesh by `retarget.py::_leg_colliders`:
- A **waist LID** (`lid: true`) across the pelvis. Magica's skirt guide is blunt
about this: one big sphere at the waist "acts as a lid that prevents particles
in the skirt from slipping into the body". Leg capsules alone only stop cloth
going through a thigh; nothing stops a panel swinging INWARD into the pelvis.
- **Tapered** thigh and shin capsules — separate head and tail radii. A limb is
not a cylinder: Taila's thigh is ~0.11 m at the hip and ~0.06 m above the knee.
Fitted as a least-squares line through ten bands, dropping the contaminated end
bands, with twist children folded in.
- `from: 0.10` — the capsule starts BELOW the hip joint. The top of a thigh is
hip, buried inside the body the skirt hangs from.
**Per-point rest clearance.** Each (bone, collider) point's radius is capped to
just inside where that point rests, so the authored rest pose is a valid state.
Without it, cloth hanging against a thigh is shoved out and pulled back every
frame forever. The cap is PER POINT, not per bone — scaling a whole bone by its
worst point switches collision off for every panel whose top hangs against the
thigh, which is all the ones that matter.
## Collision hulls come from the MESH
`SkinnedPlayerModel._cloth_hulls`, at load time: every vertex a cloth bone
dominates, binned into a ~20 mm grid, outermost cells kept, capped at 14 points.
The sidecar's ten farthest-point samples describe a panel's corners and hem and
leave its MIDDLE unsampled — exactly where a thigh comes through. The solver
reported every contact resolved while 158 vertices sat 95 mm inside a leg.
## There is no drape term
"Cloth takes a share of the leg's motion before the solver runs" is a real
technique (Hoyoverse rigs carry a partial constraint from the leg onto the upper
skirt bones). It was here to move panels the old rotation-only solver could not.
With it against without, over the movement sweep:
```
run 101 -> 92 mm fall 82 -> 49 mm dash 136 -> 95 mm
idle after a dash 103 -> 20 mm
```
Worse in every state but a walk, and 20× worse in stability (0.48 vs 0.05
deg/frame at a dead idle) because its target sat inside the leg the collision was
pushing out of. **If you reintroduce it, the target must be collision-free
first.** A naive "seat the reference on the limb" pass was tried and destabilised
the reference chain, because a parent's seat rotation cascades into every child.
## Cost and LOD
~2.6 ms per character per frame at full quality, three quarters of it collision.
It was 10.9 ms before the inner loop stopped rebuilding every capsule and
reallocating the hull array for every (bone, collider, pass).
`SpringBones.lod` 03 drops passes then collision;
`SkinnedPlayerModel._update_cloth_lod` picks it from camera distance
(6 / 14 / 28 m) four times a second.
If you add cloth bones, re-run `debug/cloth_perf_check.gd`. The cost is the
product of joints × colliders × hull points × passes and all four are easy to
raise by accident.
## Hair specifically
- Hair DOES collide now. It used to be excluded because a collision push happened
after the integrator and so was deaf to spring tuning — long back hair got
shoved out of a thigh and hauled back at stride frequency, which was the blur.
Inside the relaxation there is no such fight.
- Hair chains are NOT linked sideways; linking them stiffens them into rope.
- Hair sits silent at idle (0.005 deg/frame). If it does not, something is
driving its target — that was the drape, and it is the first thing to suspect.
@@ -0,0 +1,299 @@
# How imports fail, and why the checks did not catch it
Seven characters shipped with "All checks passed" and four of them were visibly
broken in game — lying on their backs, seven times too big, facing backwards,
holding a gun that floated near their chest. Nothing in the verification suite
was wrong. It just never asked the questions that mattered.
**The generalised lesson, which is the whole of this page:**
> The suite verified that the character was *well-formed* — skeleton attached,
> weights authored, clips non-frozen, cloth unkeyed. It never verified that the
> character was *correct*: the right size, the right way up, the right way round,
> and reachable by the runtime. Structural validity and usable output are
> different properties, and a pipeline that only checks the first will ship the
> second broken every time a source deviates from the one it was written against.
Every check below is cheap. None of them existed.
---
## 1. Up is not always +Z — measure it, never assume it
**Symptom:** the character is enormous and lying on their back.
**Hit:** aria, momo, hikari. **Confidence: certain** — measured, not inferred.
`flatten_and_scale()` sets the character's real-world size with
```python
height = hi.z - lo.z # Blender Z is up
s = target_height / height
```
which is right for a model that arrives Z-up in Blender, and catastrophic for one
that does not. If the character is actually lying along Blender's Y, `hi.z - lo.z`
measures their **thickness** — about 0.25 m — so `s = 1.75 / 0.25 ≈ 7`. The model
is scaled seven-fold *and* left on its back. One wrong assumption, both symptoms.
The bind-pose bounding boxes say it plainly. A correct character is tall on Y and
narrow on X and Z:
| | X | Y | Z | |
|---|---|---|---|---|
| taila | 1.24 | **1.75** | 0.69 | correct |
| kiyoko | 1.50 | **1.75** | 0.32 | correct |
| mannequin | 1.86 | **1.75** | 0.35 | correct |
| aria | 6.12 | 1.75 | **7.48** | tall axis is Z — lying down, ~7x too big |
| momo | 6.89 | 1.75 | **7.95** | same |
| hikari | 6.23 | 1.75 | **13.01** | same, and worse |
The 1.75 lands on Y for everyone because the exporter maps Blender Z to glTF Y.
That is exactly what makes the bug invisible: **the number you normalised always
comes out right, whether or not it was the right number.** A check on "is the
height 1.75" passes on all seven of these.
**Rule:** derive the up axis from the SKELETON, and rotate the model upright
before scaling anything. `flatten_and_scale()` now does this.
**Measure it from the FEET to the HIPS, not from the hips to the head.** The head
is not a reliable landmark: the spine walk ends on whatever the last non-cosmetic
bone in the chain is, and on a rig with a facial skeleton that can be a bone
sitting BELOW the hips. Momo's did, so the first version of this fix stood her
neatly on her head — correct size, correct proportions, upside down. Feet cannot
be mistaken; they are the bottom of a standing character on every rig, and
`foot.L/R` have resolved on every source met so far.
**And compare it to WORLD up, not to the model's own proportions.** The obvious
test — "is the spine the longest axis of the bounding box?" — catches nothing
here, because a model rotated as a whole is internally consistent: aria's spine
*is* her longest axis, she is just lying down. She passes that test comfortably.
The question is whether the character stands up in the world the game runs in,
which means asserting the spine runs along Blender +Z, full stop.
Two more numbers are worth asserting for free: the other two extents should be
under about 2.5 m, and the height itself should land in a human range. Those
three together are what separated the four good characters from the three broken
ones on the first run.
**Where this comes from:** all three casualties have bone names like `Hips`,
`Left leg`, `Upper Chest`, `Breast_L` — a VRM that someone imported into Blender,
renamed, and re-exported. Kiyoko kept raw VRoid `J_Bip_*` names and was fine. A
**Blender round-trip can bake an axis rotation into the export**, and that family
of files is common on Sketchfab. Treat "the bone names have been humanised" as a
signal to check the axes.
---
## 2. The runtime must look bones up by ROLE, not by name
**Symptom:** the gun is not in the hands — it floats near the chest, and can
point backwards. **Hit:** aria, momo, kiyoko, hikari.
**Confidence: certain** — measured.
`SkinnedPlayerModel.set_weapon()` finds the hand with
```gdscript
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
```
Three hardcoded spellings. Against the shipped roster:
| Skin | `hand.R` resolved in the sidecar | matched by `_find_bone` |
|---|---|---|
| taila, miku, mannequin | `DEF-hand.R` | yes |
| kiyoko | `J_Bip_R_Hand` | **no** |
| aria, momo, hikari | `Right wrist` | **no** |
When it misses, `set_weapon` falls back to parenting the weapon to the model root
at a fixed chest-height offset. The gun is then not attached to the character at
all; it hangs in space near the torso and inherits none of the arm's motion.
This is the same class of bug as `verify_character.py` looking for legs by the
substrings `thigh`/`shin`. **`tools/rig_map.py` exists precisely so that nothing
downstream has to guess a bone name, and the resolved roles are written to
`<model>.rig.json` for exactly this purpose — but only some consumers read them.**
**Rule:** every bone lookup anywhere in the runtime or the tools goes through the
sidecar roles, with a name heuristic only as a last-resort fallback. Grep for
`find_bone`, `findn(`, and any tuple of bone-name spellings; each one is a rig
this project has not met yet.
---
## 3. Facing is inferred and never verified
**Symptom:** the character runs backwards. **Hit:** kiyoko.
**Confidence: probable** — the mechanism is understood, the specific cause is not
yet isolated.
Two independent things decide which way a character ends up pointing:
`retarget.py::facing_correction()` computes a yaw to align the character's rest
pose with the library's, and `SkinnedPlayerModel.facing_flip` then applies a
blanket 180° because "glTF forward is +Z; players face -Z". If the source already
faces the other way, the two compose to a character running backwards — and
nothing anywhere measures the finished result.
**Rule:** facing is measurable from the skeleton — the toes are forward of the
ankles. `flatten_and_scale()` now snaps that to Blender -Y, the convention the
runtime flip is built around, so every character leaves the pipeline pointing the
same way whatever the source did. Kiyoko was 180 degrees off and is now correct.
Snap to the nearest QUARTER TURN, not to the measured angle: a rest pose with the
feet slightly splayed is not a character who is 7 degrees turned, and correcting
it as one puts a permanent yaw on the whole skeleton.
---
## 4. Generated cloth chains must be validated against the geometry they drive
**Symptom:** hair stretches wildly during animation.
**Hit:** miku. **Confidence: probable.**
`tools/cloth_bones.py` grows chains for a costume that has none, then **clears
each vertex's existing body weights** and re-assigns it to the fitted chain,
keeping the original only over the first 22%. That is correct when the polyline
actually follows the clump. When it does not — a large or forked island, a
mis-picked root end — vertices land on a bone travelling somewhere else entirely,
and linear-blend skinning turns that into stretching.
The tool reports how many chains it grew. It never checks whether they *work*.
**Rule:** after growing chains, verify per vertex that its assigned bone stays
near it — pose the chain a few degrees and assert the vertex moves with its bone
rather than away from it. And never destroy the original weights without a
fallback: a generated chain should blend against the body weight it replaced, so
a bad fit degrades to "stiff" rather than to "torn".
---
## 5. A weapon has to be scaled to the arm that holds it
**Symptom:** hands flat and open, both fists bunched together at the grip, the
stock nowhere near the shoulder. **Hit:** every model.
**Confidence: certain** — measured and fixed.
Three separate causes, all of them "a constant where a measurement belonged".
**The gun was mounted with a constant rotation.** `set_weapon` used
`rotation_degrees = (0, 90, -90)`. A bone attachment is expressed in the BONE's
axes and no two rigs agree on those, so one constant mounts the weapon
differently on every character. It never needed to be right: the pose layer aims
the gun by rotating the WRIST until the weapon's forward lies on the aim line, so
handing it the IDENTITY means "forward is the hand bone's -Z" — true by
construction on any rig — and the wrist absorbs the roll.
**The gun was full size on a stylised character.** The set is modelled at
real-world scale (an M4 is 0.84 m butt to muzzle); these characters have 0.47 m
arms against an adult 0.52. That puts the handguard 0.66 m from the support
shoulder — 0.2 m beyond reach — so a loop slid the support hand back down the
weapon until it fitted. On Taila a support offset authored at 0.35 m collapsed to
**0.083 m**: two fists together at the grip, which reads as a two-handed pistol
grip, not a rifle.
The fix is not a fixed scale factor. The binding constraint is the SUPPORT arm:
its hand must reach `stock + fore` in front of the pocket, from a shoulder half a
shoulder-width off the weapon axis. Solve that triangle for the largest gun whose
handguard still lands inside the arm's reach. Taila and Kiyoko come out at
different scales from the same code, both with the support hand at its full
authored handguard distance and no sliding at all.
Also give the slide-back loop a FLOOR. A slightly straight support arm looks far
better than no handguard hold.
**Nothing posed the fingers.** Every hand was flat and open — the single loudest
tell that a character is not really holding anything. Fingers are now closed by
the pose layer, using an axis derived from each hand's OWN anatomy in the rest
pose, because no two rigs agree on finger bone orientation:
```
along wrist -> middle knuckle the length of the hand
palm middle knuckle -> thumb tip across it; the thumb OPPOSES the
fingers, so it is on the palm side by
construction — a fact about hands, not
a rig convention
curl along x palm turning about this swings the fingers
into the palm, not sideways
```
The trigger hand's index finger gets a much shallower curl than the rest — it
lies along the trigger. Curling it with the others is what makes a character look
like they are squeezing a bar of soap.
Finger bones now resolve by role too (`rig_map.DIGITS`), across all three naming
families met so far: Rigify `DEF-f_index.01.L`, VRoid `J_Bip_L_Index1`, and
Blender-export `IndexFinger1_L`. Segments are ordered by DEPTH BELOW THE HAND,
not by the number in the name — the numbering is not consistent between families,
but the hierarchy always runs knuckle to fingertip.
**The support hand came out upside down**, because its orientation was built as
a shortest arc plus a constant twist: align the hand's forearm line to the barrel
(`Quaternion(fa_rest_dir, aim_dir)`), then add 0.5 rad of roll. A shortest arc
says NOTHING about roll — it is the minimal rotation between two directions — so
the entire roll came from that constant, and a constant is right only for the rig
it was tuned on.
Orienting a hand onto something it grips is a FRAME-TO-FRAME problem, and framing
it that way leaves nothing free to guess:
```
curl axis must lie along the object's axis, or the fingers close ACROSS the
handguard instead of around it
palm must face the object — up, for a hand supporting from underneath
along falls out of the other two (palm x curl)
```
Map the hand's rest anatomical frame onto that target and the roll is determined,
not chosen. Verified on both the Rigify-named mannequin and the VRoid-named
Kiyoko: fingers wrap the handguard from below, over the top.
**Rule:** anything expressed as a constant in a rig's local frame — a mount
rotation, a grip offset, a curl axis, a weapon size, a wrist twist — is a guess
about one skeleton. Derive it from the skeleton, or hand it to a solver that
already knows the answer. And when a rotation needs a specific ROLL, never build
it from a shortest arc: that operator has no opinion about roll, so whatever you
add afterwards is doing all the work.
## 6. Some sources are not salvageable, and the gate should say so
**Hit:** hikari. She failed every way at once — stretched and warped, tiny, far
away, gun backwards. Her rig has been through at least two toolchains: her
cosmetic bones are zero-length terminators, she carried a second armature with
its own clips, and her feet and her spine disagree about which way is up, so the
stand-up correction cannot resolve her either. She is now REJECTED by the gate
and removed from the roster rather than shipped broken.
**Rule:** a source that fails several unrelated checks is not a tuning problem,
it is a bad file. Spend the effort on finding a cleaner source, not on repairing
this one — and make sure the gate blocks it, because the failure mode before this
work was that everything passed and the breakage was only visible in game.
The vetting snippet in `separation.md` catches most of these before download:
several meshes, 50+ joints, cosmetic bones present. Add a look for duplicate
armatures and for bone chains whose bones are all at the same position.
## 7. Known-unsolved, and honestly so
Taila's legs still clip through the front of her skirt in a run, slide and dash
(~95 mm). See `cloth-and-hair.md`. The solver sees the contact and pushes on it
every iteration; what remains is a standing fight between the collision and the
garment's shape constraints, not a missing check.
---
## The check that would have caught most of this
One pass over the finished GLB, before it is ever registered:
```
POSTURE tallest axis of the bind-pose AABB == the hips->head axis,
and the other two are under ~2.5 m (catches #1)
SCALE height within a few percent of --height (catches #1)
FACING toes forward of ankles, along the world forward the game
expects, AFTER facing_flip (catches #3)
REACHABLE every role the runtime looks up — hands, head, spine — resolves
through the sidecar and not by name (catches #2)
CLOTH every chain has measurable extent, and its vertices track it (#4)
```
None of these needs Blender or the engine; the bind-pose AABB and the inverse
bind matrices in the GLB are enough for the first four.
@@ -0,0 +1,76 @@
# Growing cloth bones on a model that has none
`tools/cloth_bones.py`, opt-in via `pipeline.py --grow-cloth`.
## When you need it
The spring solver simulates cloth **bones**. A garment with none is welded to
whatever body bone it was weighted to, and no runtime setting changes that. Every
auto-rigged model is in this state, and so is many a "rigged" download whose
skeleton is body-only.
Check before importing (no Blender needed — see `separation.md`): if the joint
list has nothing matching `hair|skirt|tail|ribbon`, the costume will not move.
## What it does
Runs on the **rigged** model, before the retarget.
1. **Finds the geometry by material slot.** A slot called `hair` is hair. The
artist already answered the question, and on a joined mesh — which is what the
auto-rig leaves behind — the material slot is the only separation left.
2. **Splits it into clumps.**
- *Hair*: connected islands over the mesh's own edges. A strand is a connected
piece of surface; clustering by position would merge two ponytails passing
near each other and split one that bends.
- *Skirt*: radial wedges around the body's up axis. A skirt is ONE connected
surface, so islands would return the whole thing as a single piece — which
is the bell-shaped failure. Wedges are the ZZZ-convention panel grid.
3. **Fits a polyline down each clump** by binning vertices by distance from the
anchored end and taking each bin's centroid, so the chain follows the piece's
own curve. A straight root-to-tip line cuts the corner on a bent ponytail and
every vertex on the outside of that bend ends up on a bone travelling the
wrong way.
4. **Builds a bone chain along the polyline**, parented to the body bone that was
already holding that geometry.
5. **Re-weights** each vertex onto the two bones either side of where it projects,
blended by how far between them it lands — while keeping the ORIGINAL body
weight over the first `ROOT_BLEND` (22%) of the chain, so the scalp stays on
the skull and the waistband stays on the hips.
Then `retarget.py::describe_rig` finds the chains by name exactly as it would an
artist's, and writes tips, hulls and neighbours to the sidecar.
## Tuning
```
--hair-segments 3 bones per hair strand
--skirt-segments 4 bones per skirt panel
--skirt-panels 12 radial panels; more = opens around a leg more smoothly
--classes hair,skirt which material names to look for
```
`MIN_STRAND_LENGTH` (60 mm) and `MIN_STRAND_VERTS` (12) drop fringe and
ornaments. Simulating those costs the same as a ponytail and only ever produces
jitter around the face.
## Worked result: Miku
```
19 chains, 57 bones grown from one `hair` material slot
sidecar: 0 cloth chains -> 19
idle stability: 0.007-0.018 deg/frame (quiet)
mesh intact, no tearing (debug/character_look_capture.gd)
```
## Limits
- **It cannot find a garment that shares a material with the body.** Miku's skirt
is on her `body` slot, so she got hair chains and no skirt. Splitting by
geometry rather than by material would be the next step.
- **It does not set `weights_authored`.** An auto-rigged model still gets the
destructive load-time `SkinLegRepair`. That repair only touches cross-leg
vertices, so it leaves hair alone, but it is worth knowing.
- **Grown chains are a fallback, not a substitute for a rigged source.** They
follow the geometry, but an artist's chains carry intent — where a panel should
split, which strands move together — that no fit recovers.
@@ -0,0 +1,127 @@
# Rigging and retargeting
## Roles, not names
`tools/rig_map.py` resolves a skeleton to ROLES — `hips`, `spine[]`, `neck`,
`head`, and `limb[(role, side)]` for `thigh/shin/foot/toe/shoulder/upper_arm/
forearm/hand`. Matching is by whole tokens plus anatomy (chain length, position,
which bone is a child of which), so a Rigify `DEF-thigh.L`, a Mixamo
`mixamorig:LeftUpLeg` and a bespoke `Bip01_L_Thigh` all land on the same role.
This is what removed the need to destroy foreign skeletons. `roles.missing_core()`
is the gate: if the core roles cannot be found the pipeline stops rather than
guessing.
The resolved roles are written to `<model>.rig.json` and read at runtime by
`ShooterPoseModifier._resolve`, which aliases its library-flavoured names
(`DEF-hips`, `DEF-spine.001`…) onto whatever this rig calls them. Taila's hips
are `DEF-spine`, her head is `DEF-spine.006`, and she has **no bone with "neck"
in its name at all** — unresolved, every lean, aim pitch and slide head-lift
silently did nothing.
### Names lie. Anatomy does not.
Four sources that were not authored against the library's spelling each broke
role resolution in a different way. All four fixes are in; the lesson is that
**anything guessing anatomy from a name needs a structural fallback.**
- A bare `leg` is the SHIN on Mixamo (`LeftUpLeg` is the thigh) and the THIGH on
a rig whose shin is called `knee`. Same token, opposite bones, both common. So
`RigRoles` walks the leg upward from the foot and fills in whatever the names
could not, stepping over twist bones.
- Claims are granted **longest-stem first**. Taking the first role in
`LIMB_ORDER` that matched at all let shin's catch-all `"leg"` beat thigh's
exact `"upperleg"`, and the outcome depended on bone iteration order.
- Cosmetic and spring classes accept a **two-character positional suffix**:
`HairFL`, `HairFR`, `HairF_Top` tokenise to `hairfl` and matched nothing, so a
character imported with no hair chains at all. Two characters is short enough
that `forearm` and `earring` are still not swept in.
- VRoid spells legs `UpperLeg`/`LowerLeg`. Any CHECK that name-matches
`thigh`/`shin` will silently pass or silently fail on it — see
`verification.md`.
If a new source fails with `could not identify these bones`, dump the joint names
first (`separation.md` has a no-Blender snippet) and decide whether it is a
missing stem or a case only anatomy can settle.
## Rebuilding the hierarchy
A Rigify DEF-rig exports its chain roots parented straight to the armature root,
because Rigify drives them by constraint rather than by hierarchy. Left that way,
rotating the hips leaves the legs, skirt and hair floating in place.
`rebuild_hierarchy` re-attaches orphans: by anatomy where the role is known, and
by rest geometry (nearest plausible parent) otherwise. **Cloth may only attach to
the trunk.**
## Subdividing cloth panels
`subdivide_cloth_panels(arm, meshes, roles, segments=4)`.
A skirt panel that is a single bone from the waist is a rigid flap: it can only
rotate about its own head, and a contact near that head is unreachable at any
angle. Splitting each panel into a chain is what lets it bend, and it is why the
ZZZ-convention skirt is a grid rather than a fan.
On Taila this turns 21 panel bones into 21 chains of 4. The segment lengths come
out uneven (49/49/49/141 mm) because the last segment runs on to the hem.
Weights are redistributed along the panel as it is split, so the mesh follows the
new chain.
## Twist bones
A forearm or thigh twist bone takes half the roll of its parent so the skin does
not candy-wrap. They are detected (`is_segment_of`) and recorded in the sidecar's
`twist` list. They are also folded into the limb when measuring collider radii:
most of a thigh's surface belongs to `DEF-thigh.L.001`, and what is left
dominated by `DEF-thigh.L` is mostly hip flare, which fitted a 0.154 m radius —
a 30 cm thigh.
## Joint helpers
`SkinJointHelper.install` runs for EVERY model however it was rigged. Linear-blend
skinning collapses any joint by cos(angle/2) no matter how good the weights are;
measured at the knee, 0.77 without helpers against 0.99 with. They are updated
LAST, inside the modification pass, so each helper tracks whatever final rotation
its child bone ended up with.
## The retarget maths
Bake each clip as a **rest-relative delta**:
```
R_world = src_pose_rot * src_rest_rot⁻¹ what the clip does
tgt_rot = R_world * tgt_rest_rot done to THIS rig
```
Copying absolute world orientation instead — which is what a constraint bake does
— forces the library's bone ROLL onto a mesh bound with a different one, and
twists every limb by a constant offset.
Also handled: a facing correction (`facing_correction`) when the library and the
character face different ways, and a hips-height scale so a short character does
not float.
## Export flags that matter
```python
export_bake_animation=False,
export_optimize_animation_keep_anim_armature=False,
```
`keep_anim_armature` forces a track onto every bone whether or not the clip
touches it. Off, the skirt and hair export with **no tracks at all** and belong
entirely to the spring solver. This one flag is the animation/physics split.
## Height normalisation
`flatten_and_scale(arm, meshes, TARGET_HEIGHT)` — default 1.75 m. Applied before
the retarget so the library's stride matches the character's legs.
## When a model has no skeleton
`tools/autorig.py` will fit one, and the pipeline accepts the quality loss:
nearest-bone weights, cross-leg bleed, no cloth chains. `weights_authored` comes
out false, `SkinLegRepair` runs at load to snap the worst of it, and the
character will have no secondary motion. Prefer finding a rigged source.
@@ -0,0 +1,178 @@
# Body, garments, hair — what must stay separate
The single structural idea behind an anime-styled character rig, and the thing
every failure in this project traced back to.
## The convention this pipeline follows
Hoyoverse-class character rigs (Genshin, Star Rail, Zenless Zone Zero) are built
the same way, and the parts that matter are visible in any of their exported
assets and in the toolchains built around them (Magica Cloth 2, UnityChan
SpringBone, VRM's spring-bone spec — all of which exist because this shape is
the convention):
| Convention | What this repo does |
|---|---|
| Body, face, hair and each garment are SEPARATE meshes with separate materials | Never join meshes; 18 meshes on Taila are all kept |
| Skirts get a radial grid of bone chains — many panels, several segments each | 21 panels × 4 segments, subdivided at build time |
| Hair is chains of 24 bones from the scalp | Detected from the source rig; 14 chains on Taila |
| Cloth/hair bones carry NO animation keys; physics owns them | `export_optimize_animation_keep_anim_armature=False` |
| Physics colliders are a small set of capsules: thighs, shins, and a big one at the waist acting as a lid | 5 capsules, measured from the mesh (`_leg_colliders`) |
| Neighbouring skirt panels are linked sideways | 278 cross-panel distance links from shared vertices |
| Each surface is TAGGED with what it is, so shading can differ per class | `tools/surface_map.py` writes it; `SkinSurfaces` reads it |
| Cel shading with a ramp, plus a separate outline pass | `LevelMaterials.apply_toon_recursive` + `apply_character_look` |
Where we differ: their collider capsules and cloth parameters are hand-authored
per character by a technical artist. We MEASURE them from the model's own
geometry at build time, because there is no artist in this loop. That is the
whole reason `<model>.rig.json` exists.
Where there IS an artist in the loop, there is now somewhere to put the answer:
`debug/rig_lab.tscn` and the layered files behind it (see the SKILL). Measuring
is the default and hand-authoring is the override, rather than the other way
round.
## Separation is only half of it — the parts have to be NAMED
Keeping the meshes apart is structural. Knowing which is which is what lets
anything act on the difference, and until the surface table existed nothing did:
every surface of every character took one set of shading numbers, calibrated on
skin, because there was no way to ask whether a surface was hair.
The table lives in the sidecar as `surfaces`, keyed on the MATERIAL name — mesh
node names are `Object_7` through `Object_32` on every character in this game and
carry no meaning, while material names survive the glTF round trip intact and are
what the artist actually chose. It is decided three ways, in descending order of
how much it trusts them:
1. **the material name.** On VRoid exports it is formal —
`N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every
VRoid character here uses SKIN / FACE / EYE / HAIR / CLOTH.
2. **the weights.** Decisive when the name says nothing: a surface pulled by the
skirt chain is a skirt whatever it is called. The threshold is deliberately
low (5%), because VRoid welds the whole cap of the hair to the head bone and
springs only the strands — kiyoko's hair mesh is 85% head, and a majority rule
would call it skin.
3. **the material flags.** These catch line-work, which is the one class that is
not a surface of the character at all.
It is built from the same chains the spring solver uses, so the two can never
disagree about which bones are a skirt.
## Why the separation is load-bearing
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
and its own outline weight, cloth wants flat banding. One merged mesh gets one
treatment and everything reads as plastic.
**The cloth solver.** `SkinnedPlayerModel._cloth_hulls` extracts, per cloth bone,
the vertices that bone dominates — that is only meaningful while the garment is
its own mesh with its own weights. Merge the meshes and the solver has no way to
know which vertices are skirt.
**Weights.** A joined mesh rebound by nearest-bone weighting produced 2817
vertices pulled by BOTH legs on Taila (16% of the model, worst a dead 50/50).
Such a vertex sits between the legs and stays there while they separate,
stretching every triangle around it. That is the "squashing on jump" and the
"elongated boot".
## How cloth is detected and classed
`tools/rig_map.py::is_cosmetic` matches WHOLE TOKENS in a bone name against:
```
hair skirt cloth ribbon tail cape coat scarf sleeve breast bust
feather strap antenna wing (+ face/eye classes that must never swing)
```
Whole-token only — `shoulder` must not match `should`, and a bone called
`hair_root` is hair while `chairbone` is not.
`retarget.py::SPRING_CLASSES` is a NARROWER set: the classes that actually get
secondary motion. A face-shape or eye chain is cosmetic but must never swing.
Each chain lands in `<model>.rig.json` as:
```json
{ "class": "skirt",
"root_parent": "DEF-spine.001",
"bones": ["DEF-skirt", "DEF-skirt.seg1", "DEF-skirt.seg2", "DEF-skirt.seg3"],
"tips": [[x,y,z], ...], // where each bone points, in its own space
"hulls": [[[x,y,z], ...], ...], // sample of the geometry it drives
"neighbours": [{"DEF-skirt.L": 10.7, ...}] // shared-vertex weight
}
```
`tips` exists because **a glTF skeleton carries no bone tails at all**, and
Taila's skirt panel bones have no children either, so nothing in the skeleton
says which way a panel hangs. It is measured from the geometry the bone drives.
`neighbours` means SHARED VERTICES — the artist's own answer to which pieces of
cloth are sewn together. Adjacency by name or by rest distance would both be
guesses.
## The three rules that keep it intact
1. **Cloth may only ever parent to the trunk, never to a limb.**
`rebuild_hierarchy` enforces this. A skirt parented to a thigh becomes
trousers.
2. **Cloth is never SKINNED to a leg.** There was a `bind_cloth_to_legs()` that
gave cloth vertices near a thigh a share of that thigh, so the skirt would
ride the leg the way a real one does. It is deleted. A vertex weighted 0.9 to
a thigh cannot be moved by its own cloth bone, so the solver loses the
authority to push it out of that leg — and 0.9 of a rotation always lags the
surface doing 1.0 of it, so the leg overtakes it anyway. It also poisoned the
collider measurement: 2258 skirt vertices counted as thigh geometry and fitted
a 0.28 m thigh.
3. **Cloth bones carry no animation tracks.** If the exporter bakes rest-pose
tracks onto them (`keep_anim_armature`), the AnimationPlayer overwrites the
spring solver every frame.
## Worked example: why the two shipped characters differ so much
Both are in `assets/characters/skins/`. Compare their sidecars:
| | Taila | Miku |
|---|---|---|
| source had a skeleton | yes | **no — 5 meshes, 0 joints** |
| `weights_authored` | true | **false** |
| cloth chains | 35 (127 bones) | **0** |
| twist bones | 8 | **0** |
| meshes shipped | 18 | **1** |
Miku's source (`assets/characters/incoming/miku_test.glb`) is an unrigged mesh,
so she went through `autorig.py`: joined to one mesh, rebound by nearest-bone
weighting, no cloth chains. Her twin tails and skirt are dead geometry that
cannot move, and `SkinLegRepair` runs destructively on her every spawn.
Nothing downstream can recover this. **The single highest-leverage decision in
this whole pipeline is choosing a source model that already has a skeleton with
skirt and hair bones.** Everything else is recoverable; this is not.
A quick check on any candidate, without Blender:
```python
import json, struct
with open(path,'rb') as f:
f.read(12); clen,_=struct.unpack('<II',f.read(8))
j=json.loads(f.read(clen))
nodes=[n.get('name','') for n in j['nodes']]
joints=[nodes[i] for s in j.get('skins',[]) for i in s['joints']]
print(len(j['meshes']), 'meshes', len(joints), 'joints')
print([n for n in joints if any(t in n.lower() for t in ('hair','skirt','tail','ribbon'))])
```
Several meshes, 50+ joints, and a non-empty cosmetic list means a good source.
## Checking a source model before importing
```bash
python tools/verify_character.py <model.glb>
```
What you want to see: several meshes, bone names containing `skirt`/`hair`,
twist bones (`thigh.L.001`), and weights that are NOT all at 4 influences.
`weights_authored` in the sidecar is measured from exactly this and decides
whether the destructive load-time repair runs.
@@ -0,0 +1,112 @@
# Stylization — the cel-shaded look
Two passes, applied at load in `SkinnedPlayerModel.load_model`:
```gdscript
LevelMaterials.apply_toon_recursive(scene) # world-wide toon shading
LevelMaterials.apply_character_look(scene) # character-only corrections
```
## The trap: imported models bring their own line-work
Anime models exported from MMD/VRoid/Blender toon setups very often ship the
outline **as geometry** — an inverted-hull shell of the mesh with a flat black,
UNTEXTURED material, plus separate flat cards for the eye whites, irises and the
pupil highlight. The mesh you import is not just the character; part of it is
already the drawing.
Toon-lighting that shell is what put a **white rim on every hair strand**. It is
an inverted hull whose normals face away from you; a lighting model that adds a
rim term lights it brightly exactly where it is supposed to read as ink.
`apply_character_look` therefore looks for the model's own line-work and handles
it flat and unshaded. "Untextured" alone is NOT the test — that made every
flat-coloured model render as a black silhouette, because Quaternius' mannequin
has two untextured materials (a yellow body, lilac joints) and both were hidden
as though they were an outline shell. The test asks three things instead: is it
named `eyes*`, is it drawn front-face-culled (the classic inverted-hull setup),
or is its albedo near-black. An ink shell is black; a flat-coloured character is
any colour at all.
That test now runs at BUILD time (`tools/surface_map.classify_linework`) and its
answer lives in the sidecar. `SkinSurfaces.guess()` is the same rule kept as the
runtime fallback, for a model with no surface table — and the cull-mode half of
it is re-run at runtime even when the table exists, because glTF has no way to
say "draw only the backfaces" and an inverted hull cannot survive the round trip
as a cull mode. Blender genuinely cannot see it; Godot can.
What it then does:
- **Outline hull** → made fully transparent rather than deleted. Deleting a
surface would renumber the rest and break the mesh's own skin bindings. The
game draws its own outline.
- **Eye cards** (`resource_name` starts with `eyes`) → flat ink, except anything
with `HL` in the name, which is the glint in the pupil and really is white.
If a newly imported character comes out with a white halo, a black silhouette,
or black eyes that should have irises, the line-work test and the name-matching
below it are where to look — now in `tools/surface_map.py`, mirrored by
`SkinSurfaces.guess()`. **Change both or neither**: a model with a surface table
would start rendering differently from one without.
## Per-class art direction
Because each surface says what it is, each class takes its own numbers
(`LevelMaterials.CHARACTER_LOOK`). `body` is deliberately identical to what every
surface used to get, so the calibration this was all built on does not move. The
others are departures, each for a reason:
| Class | Outline | Band | Why |
|---|---|---|---|
| body | 5.0 mm | 0.16 | unchanged — the baseline |
| cloth | 5.8 mm | 0.13 | a garment's silhouette is most of what separates a character from the background at range; folds need a defined terminator to read as fabric |
| hair | 3.4 mm | 0.20 | **the one that matters.** A hair mesh is dozens of near-parallel strands millimetres apart; at the body's 5 mm each strand's hull swallows its neighbour and the head reads as one solid dark cap |
| accessory | 6.8 mm | 0.10 | small, rigid, usually the most saturated thing on the character — meant to pop |
This required moving the outline from `material_overlay` on the INSTANCE to
`next_pass` on each surface's material. Miku's body, face and hair are three
surfaces of one mesh, so an instance-wide overlay can only ever give all three
the same weight.
Taila's eyes still render as black cards rather than amber irises. Her eye
surfaces are untextured, and the glTF import hands every untextured surface a
default near-white albedo, so colour cannot tell an iris card from a lash card
on her — the name is all there is, and `eyes*` currently means "ink". Unfixed.
## Materials on import: the unlit problem
Anime glTFs are very often exported "unlit": `KHR_materials_unlit`, a **black**
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
honouring the unlit extension use base colour and ignore emission — so Blender
reads black, never references the images, and imports with `bpy.data.images`
**empty**. The character comes out a silhouette, and there is no node graph left
to patch afterwards.
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
base colour, the unlit flag is dropped. It must run first — this is the first
thing `retarget.py::main` does, before `import_any`.
`fix_unlit_materials(meshes)` then repairs anything left inside Blender.
## What the toon pass does
`apply_toon_recursive` gives everything the game's banded ramp. `apply_character_look`
then softens the banding on characters, because re-banding an already-shaded
anime texture reads as gloss — the texture already contains its own shading and
the second pass fights it.
## Convention alignment
The Hoyoverse-class look is, broadly: a ramp texture indexed by NdotL for the
body, a separate ramp and often a dedicated shader for the face, an inverted-hull
outline whose width is vertex-colour-modulated, and specific handling for eyes
and hair highlights. This project does the simplified version — one banded ramp
plus a screen-space-ish ink treatment, and the model's own outline shell hidden
in favour of the game's. The face is NOT specially shaded here; if a character
comes out with harsh shadow shapes across the nose, that is the missing piece.
## Outline thickness
Lives with the toon material in `scenes/maps/level_materials.gd`
(`CHARACTER_INK` and the outline settings). This is the branch it was last
touched on — `feat/outline-thickness-and-tp-weapon-hold`.
@@ -0,0 +1,168 @@
# Verification — and the trap that invalidated all of it
## READ THIS FIRST
**Godot restores every bone's local pose after the `SkeletonModifier3D` pass.**
So calling `force_update_all_bone_transforms()` and reading
`get_bone_global_pose()` from a `SceneTree` script, from `_process`, or anywhere
outside that pass recomputes the globals from the **animation alone**. The
shooter pose layer and the cloth solver are simply not in what you measure.
`debug/cloth_clip_check.gd` did exactly this. It reported the same ~95 mm of
leg-inside-skirt with collision fully enabled **and with the collision call
commented out**. Every number ever taken from that tool before 2026-07-26 is
void, and several rounds of "tuning did nothing" in the history were reading a
pose the solver never touched.
**To measure a pose layer, add your own `SkeletonModifier3D` as a child of the
`Skeleton3D` AFTER the one you care about, and snapshot inside its
`_process_modification()`.** The `PoseProbe` class in `cloth_clip_check.gd` and
`travel_dir_check.gd` is the pattern.
Two related traps:
- **Headless runs uncapped**, so the engine delta is sub-millisecond and anything
integrated barely moves. Set `SpringBones.fixed_delta = 1.0/60.0`.
- **A single frame of a locomotion clip measures the clip.** A run cycle twists
the torso against the hips by tens of degrees twice per stride, swamping
anything a pose layer does. Average over a stride.
## The tools
| Tool | Measures | Good |
|---|---|---|
| `spawn_smoke_test.gd` | spawn, skins, anim tree, camera, state cycling | 29 OK, 0 failures |
| `cloth_clip_check.gd` | leg-inside-cloth per movement state, per vertex | idle < 25 mm |
| `cloth_settle_check.gd` | deg/frame at a dead idle, contacts/frame | skirt < 0.1, hair < 0.01 |
| `cloth_perf_check.gd` | ms per character per frame | ~2.6 ms |
| `cloth_allow_check.gd` | how much of each limb the rest-clearance cap makes the solver blind to | 1735 mm on Taila |
| `cloth_stretch_check.gd` | mesh tearing between panels | no 3× edges |
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
| `verify_character.py` | meshes, bones, weights of a SOURCE model | several meshes, cloth bones present |
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
| `hold_pose_check.gd` | the lab shows only the selected pose's knobs; every wrist axis turns its hand | 0 failures |
| `wrist_gun_check.gd` | the wrist turns the hand and NOT the gun welded to it | hand ~28°, gun < 1° |
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
| `roster_capture.gd` | one photo of every character, from the picker | — |
| `ui_capture.gd` | one photo of every menu screen | — |
| `rest_pose_check.gd` | each rig's bind-pose limb directions vs. the library's | see below |
## What `rest_pose_check` actually established
It was written to test a suspicion — that the rest-relative retarget silently
assumes both rigs rest alike — and it disproved it. Miku's arms rest **41°** off
the animation library's and Taila's **32°**, and both animate correctly. The
delta retarget handles a rest-pose difference, which is what it is for. Do not
go looking there again.
It also demonstrates the measurement trap in miniature. Written as "the direction
from a bone to its FIRST CHILD", it reported kiyoko's and aria's legs 71° off —
because a thigh's first child is as likely to be a skirt bone as a shin, and it
was measuring the hang of a skirt panel. Pointing it at the next limb BY ROLE
dropped both to 1°. The same rule as everywhere else in this pipeline: resolve
roles, never take whatever the rig happens to hand you.
## Assert the consequence, not the plumbing
Three of these exist because the obvious check passes on a broken system.
- `character_picker_check` asserts the skeleton's pose CHANGES over a dozen
frames. Asking the model which clip it is playing does not work: that is a
variable the class sets on itself, and it reads `"Idle"` just as happily when
the animation tree is not ticking at all.
- `rig_anchor_check` asserts the weapon MOVES by the offset asked for. An anchor
system is easy to build so that the sliders move, the file saves and the JSON
round-trips while the gun does not budge — the value read into a variable
nobody consumed. It measures in the attachment's frame, not the world's:
the attachment tracks a bone on an animating skeleton, so a world-space delta
is mostly the idle animation.
- `anchor_shift_check` and `anchor_drag_check` both measure in the GUN's frame
rather than the world's, and have to. The hold BREATHES — a
`sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
same world position twice, and comparing absolute positions reported a 3.5 mm
error that was the character inhaling. Taking each anchor relative to the one
it hangs off and rotating into the current gun basis cancels the breathing,
the ADS blend and the recoil kick exactly, because all three move the basis
and the anchor together.
- `hold_pose_check` measures the wrists through a `PoseProbe`, and had to learn
it the same way everything else did: reading `get_bone_pose_rotation` from the
SceneTree reported every wrist axis as turning the hand by **0.0 degrees**
the identical answer it would give if the wrists had never been implemented.
See READ THIS FIRST. That trap is still the most expensive one in this repo.
- `surface_class_check` FAILS on a surface that falls through to the heuristic
instead of resolving from the table. A model whose names stopped matching still
renders — the fallback catches it — and quietly loses its per-class art
direction. Nothing else would report that.
And four of the last five real defects came from LOOKING, not from asserting:
a preview showing the back of the character's head, a turntable that carried on
from the previous character, an unstyled list, and momo's idle pose. Every one
passed every assertion. Run `roster_capture` and `ui_capture` and open the PNGs.
Run them:
```bash
godot --headless --path . -s res://debug/<tool>.gd
godot --headless --path . -s res://debug/<tool>.gd -- res://assets/characters/skins/<name>.glb
```
Scripts run with `-s` MUST extend `SceneTree`. A `Node` script never quits and
hangs forever.
## Measure the right quantity
`cloth_clip_check.gd` used to report "how much CLOSER the leg got than the artist
modelled it". A hem 200 mm clear of a shin legitimately comes 180 mm closer when
the leg kicks out in a slide, and counting that as a failure buried the real
clipping under motion the character is supposed to have. It now reports how far
INSIDE a capsule a cloth vertex is, over and above however far inside it was
modelled — only cloth actually within the capsule can be showing a leg through.
It also applies the collider's `from` offset, so it tests the same band of thigh
the solver is defending. Measuring the full bone tests the hip cap the solver
deliberately excludes and reports it as clipping no tuning can fix.
## What the suite still does not check
It verifies that a character is WELL-FORMED, not that it is CORRECT. Those are
different properties, and only the first was ever asserted — which is how four
characters shipped "All checks passed" while lying on their backs, seven times
too large, facing backwards, or unable to hold a gun. See `failure-modes.md`.
`posture` and `bone roles reachable at runtime` are now hard checks. Still
missing, and worth adding when a source next exposes them: facing measured on the
OUTPUT, and per-vertex validation that a generated cloth chain actually tracks
the geometry it was given.
## Diagnosing "the solver isn't working"
In order:
1. **Is the measurement inside the modifier pass?** (Above. Do this first.)
2. **Does the solver SEE the contact?** `debug_hit_report()` — bone → deepest
overlap it found. If ~0 while the mesh is deep inside a leg, the collision
hull does not cover the geometry that is clipping.
3. **Does it CONVERGE?** `debug_residual_report()` — overlap left after the
relaxation. Seen 93 mm, left 95 mm is a standing fight, not slow convergence;
quadrupling the iterations will buy nothing. Find what is pulling back.
4. **Only then, tune.**
That order was learned the hard way: the drape, the bend limits, the backstop,
the iteration count and the hull sampling were each suspected and tested, and
the answer was in step 1.
## Also run
```bash
godot --headless --path . -s res://movement/tests/run_fsm_tests.gd # 11 tests
godot --headless --path . --check-only --script res://<file>.gd # syntax
```
Autoload identifiers report false "not found" errors under `--check-only`
ignore those.
+21
View File
@@ -47,7 +47,28 @@ user_settings/
*.pidb *.pidb
*.userprefs *.userprefs
# Secrets
.sketchfab_token
# Python
.venv/
__pycache__/
# Asset pipeline staging (raw downloads, not game-ready)
assets/characters/incoming/
# Copyrighted visual references (local-only; never imported or exported)
/.reference_assets/
/assets/characters/local_reference/
/assets/characters/skins/skins.local.json
# Misc # Misc
*LF* *LF*
tags tags
*.log *.log
Papaya-Shooter.pck
Papaya-Shooter.exe
Papaya-Shooter.console.exe
# Raw downloaded asset packs (not game content; some paths exceed Windows limits)
addons/lowpoly_map_gen/downloaded_assets/
Binary file not shown.
@@ -0,0 +1,24 @@
# CMU backward locomotion source
`_cmu_locomotion.glb` contains one animation-only clip:
- `RunBackward` — CMU Graphics Lab Motion Capture Database, subject 76,
trial 11: “quick large steps backwards.”
Source BVH:
https://github.com/una-dinosauria/cmu-mocap/blob/master/data/076/76_11.bvh
Original motion database and trial description:
https://mocap.cs.cmu.edu/
The BVH is Bruce Hahnes conversion of the original CMU motion-capture data.
CMUs FAQ permits copying, modifying, and redistributing the data (including
commercial use), requests acknowledgment, and prohibits selling the database
itself. This project uses a cropped, in-place gait cycle as part of the game,
not as a standalone resale of the database.
Build command:
```text
blender --background --python tools/build_cmu_locomotion_library.py -- 76_11.bvh assets/characters/animations/_cmu_locomotion.glb
```
Binary file not shown.
@@ -0,0 +1,17 @@
# Godot TPS Demo directional animations
Source: https://github.com/godotengine/tps-demo
The animation-only `_directional.glb` contains the `strafe_front-cycle`,
`strafe_back-cycle`, `strafe_left-cycle`, and `strafe_right-cycle` actions from
the Godot Third Person Shooter Demo player model. Meshes, textures, and
unrelated animations were removed. The game retargets these authored cycles to
its Quaternius-compatible character skeleton.
Original assets Copyright (c) 2018 Juan Linietsky and Fernando Miguel Calabró.
Distributed under the Creative Commons Attribution 3.0 license:
https://creativecommons.org/licenses/by/3.0/
Changes: animation-only extraction, retargeting, in-place root motion, and
runtime phase-synchronized blending with the project's authored walk/run clips.
Binary file not shown.
@@ -0,0 +1,15 @@
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
-------------------------------------------------------
Join the Discord Server:
https://discord.gg/vJqnRUYRfT
Binary file not shown.
@@ -0,0 +1,12 @@
Universal Animation Library 2 [Standard]
Author: Quaternius
Source: https://quaternius.itch.io/universal-animation-library-2
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
The committed _library_v2.glb is the non-root-motion Unreal/Godot export from
the free Standard package. The game drives world movement; authored vertical
body motion remains in the clips.
Binary file not shown.
@@ -0,0 +1,23 @@
Mesh2Motion Human Animation Library
Source:
https://github.com/Mesh2Motion/mesh2motion-app
https://github.com/Mesh2Motion/mesh2motion-assets
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
The committed _mesh2motion.glb is an animation-only subset of
static/animations/human-addon-animations.glb. It retains these authored clips:
- Run_Anime
- Strafe_left
- Strafe_right
- Flying Forward Super
Meshes, textures, and unrelated actions were removed. Run_Anime is retargeted
offline into the four cardinal high-speed travel headings and the wall-run
loop; the animator's keyframed limb motion is preserved. Flying Forward Super
provides the grapple flight pose.
Binary file not shown.
@@ -0,0 +1,14 @@
# Authored wall-run animation source
`_wallrun.glb` contains the animation-only UE4 Mannequin clips
`WallRunLeft` and `WallRunRight`.
- Author: Uisco
- Source: https://uisco.itch.io/wall-running-animations
- Original files: `wall_run_left.FBX`, `wall_run_right.FBX`
- Published: 2021-03-24
- Listing: free / name-your-own-price game asset
The source listing does not state a named open-content license. Keep this
provenance file with the embedded game asset, credit Uisco, and do not resell
or redistribute the clips as a standalone animation pack.
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Anime Maid Character",
"author": "413 Games",
"author_url": "https://413games.itch.io/",
"license": "CC0 1.0 Universal / Public Domain",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"source_url": "https://413games.itch.io/maid-character",
"changes": "Original rig and materials retained, retargeted to the Papaya-Shooter animation library; game-native mecha equipment added at runtime."
}
@@ -0,0 +1,306 @@
{
"roles": {
"hips": "mixamorig:Hips",
"head": "mixamorig:Head",
"neck": "mixamorig:Neck",
"spine": [
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2",
"mixamorig:Neck",
"mixamorig:Head"
],
"shoulder.L": "mixamorig:LeftShoulder",
"shoulder.R": "mixamorig:RightShoulder",
"toe.R": "mixamorig:RightToeBase",
"toe.L": "mixamorig:LeftToeBase",
"forearm.L": "mixamorig:LeftForeArm",
"forearm.R": "mixamorig:RightForeArm",
"thigh.R": "mixamorig:RightUpLeg",
"thigh.L": "mixamorig:LeftUpLeg",
"foot.R": "mixamorig:RightFoot",
"foot.L": "mixamorig:LeftFoot",
"hand.R": "mixamorig:RightHand",
"hand.L": "mixamorig:LeftHand",
"upper_arm.L": "mixamorig:LeftArm",
"upper_arm.R": "mixamorig:RightArm",
"shin.L": "mixamorig:LeftLeg",
"shin.R": "mixamorig:RightLeg"
},
"fingers": {
"thumb.L": [
"mixamorig:LeftHandThumb1",
"mixamorig:LeftHandThumb2",
"mixamorig:LeftHandThumb3",
"mixamorig:LeftHandThumb4",
"mixamorig:LeftHandThumb4_end"
],
"index.L": [
"mixamorig:LeftHandIndex1",
"mixamorig:LeftHandIndex2",
"mixamorig:LeftHandIndex3",
"mixamorig:LeftHandIndex4",
"mixamorig:LeftHandIndex4_end"
],
"middle.L": [
"mixamorig:LeftHandMiddle1",
"mixamorig:LeftHandMiddle2",
"mixamorig:LeftHandMiddle3",
"mixamorig:LeftHandMiddle4",
"mixamorig:LeftHandMiddle4_end"
],
"ring.L": [
"mixamorig:LeftHandRing1",
"mixamorig:LeftHandRing2",
"mixamorig:LeftHandRing3",
"mixamorig:LeftHandRing4",
"mixamorig:LeftHandRing4_end"
],
"pinky.L": [
"mixamorig:LeftHandPinky1",
"mixamorig:LeftHandPinky2",
"mixamorig:LeftHandPinky3",
"mixamorig:LeftHandPinky4",
"mixamorig:LeftHandPinky4_end"
],
"thumb.R": [
"mixamorig:RightHandThumb1",
"mixamorig:RightHandThumb2",
"mixamorig:RightHandThumb3",
"mixamorig:RightHandThumb4",
"mixamorig:RightHandThumb4_end"
],
"index.R": [
"mixamorig:RightHandIndex1",
"mixamorig:RightHandIndex2",
"mixamorig:RightHandIndex3",
"mixamorig:RightHandIndex4",
"mixamorig:RightHandIndex4_end"
],
"middle.R": [
"mixamorig:RightHandMiddle1",
"mixamorig:RightHandMiddle2",
"mixamorig:RightHandMiddle3",
"mixamorig:RightHandMiddle4",
"mixamorig:RightHandMiddle4_end"
],
"ring.R": [
"mixamorig:RightHandRing1",
"mixamorig:RightHandRing2",
"mixamorig:RightHandRing3",
"mixamorig:RightHandRing4",
"mixamorig:RightHandRing4_end"
],
"pinky.R": [
"mixamorig:RightHandPinky1",
"mixamorig:RightHandPinky2",
"mixamorig:RightHandPinky3",
"mixamorig:RightHandPinky4",
"mixamorig:RightHandPinky4_end"
]
},
"chains": [],
"twist": [],
"surfaces": [
{
"mesh": "Body",
"surface": 0,
"material": "N00_000_00_Body_00_SKIN (Instance)",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 5452,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 1,
"material": "N00_000_00_HairBack_00_HAIR (Instance)",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 760,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 2,
"material": "N00_002_03_Tops_01_CLOTH (Instance)",
"class": "cloth",
"detail": "garment",
"why": "material name says 'garment'",
"verts": 908,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 3,
"material": "N00_001_01_Shoes_01_CLOTH (Instance)",
"class": "cloth",
"detail": "garment",
"why": "material name says 'garment'",
"verts": 390,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 4,
"material": "N00_000_00_FaceMouth_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 1053,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 5,
"material": "N00_000_00_EyeIris_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 214,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 6,
"material": "N00_000_00_EyeHighlight_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 38,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 7,
"material": "N00_000_00_Face_00_SKIN (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 2266,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 8,
"material": "N00_000_00_EyeWhite_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 290,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 9,
"material": "N00_000_00_FaceBrow_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 52,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 10,
"material": "N00_000_00_FaceEyeline_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 126,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 11,
"material": "N00_000_Hair_00_HAIR (Instance)",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 7355,
"textured": false,
"chain_share": {}
}
],
"colliders": [
{
"bone": "mixamorig:Hips",
"child": "mixamorig:Spine",
"from": 0.0,
"radius_head": 0.1341,
"radius_tail": 0.1341,
"radius": 0.1341,
"lid": true
},
{
"bone": "mixamorig:LeftUpLeg",
"child": "mixamorig:LeftLeg",
"from": 0.1,
"radius_head": 0.0985,
"radius_tail": 0.0605,
"radius": 0.0605
},
{
"bone": "mixamorig:RightUpLeg",
"child": "mixamorig:RightLeg",
"from": 0.1,
"radius_head": 0.0985,
"radius_tail": 0.0605,
"radius": 0.0605
},
{
"bone": "mixamorig:LeftLeg",
"child": "mixamorig:LeftFoot",
"from": 0.1,
"radius_head": 0.1172,
"radius_tail": 0.0507,
"radius": 0.0507
},
{
"bone": "mixamorig:RightLeg",
"child": "mixamorig:RightFoot",
"from": 0.1,
"radius_head": 0.1172,
"radius_tail": 0.0507,
"radius": 0.0507
}
],
"weights_authored": true,
"driven_bones": [
"mixamorig:Head",
"mixamorig:Hips",
"mixamorig:LeftArm",
"mixamorig:LeftFoot",
"mixamorig:LeftForeArm",
"mixamorig:LeftHand",
"mixamorig:LeftLeg",
"mixamorig:LeftShoulder",
"mixamorig:LeftToeBase",
"mixamorig:LeftUpLeg",
"mixamorig:Neck",
"mixamorig:RightArm",
"mixamorig:RightFoot",
"mixamorig:RightForeArm",
"mixamorig:RightHand",
"mixamorig:RightLeg",
"mixamorig:RightShoulder",
"mixamorig:RightToeBase",
"mixamorig:RightUpLeg",
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2"
]
}
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Anime Girl Rigged Anime model",
"uid": "fbccf5c5a7b244e7ab04fa44da19c621",
"author": "dequeijospizza",
"author_url": "https://sketchfab.com/dequeijospizza",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/anime-girl-rigged-anime-model-fbccf5c5a7b244e7ab04fa44da19c621"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Kiyoko School Girl",
"uid": "072667d7b2b3468e9baff483b27c3a09",
"author": "Kasujin",
"author_url": "https://sketchfab.com/Kasujin",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/kiyoko-school-girl-072667d7b2b3468e9baff483b27c3a09"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
@@ -0,0 +1,8 @@
{
"name": "Universal Animation Library \u2014 Mannequin",
"author": "Quaternius",
"license": "CC0 1.0 Universal (Public Domain Dedication)",
"url": "https://quaternius.com/",
"source": "assets/characters/animations/_library.glb",
"note": "The reference mannequin shipped inside the animation library this project already uses. No attribution required under CC0; recorded anyway, and because the library's own LICENSE asks that Quaternius be credited."
}
+207
View File
@@ -0,0 +1,207 @@
{
"roles": {
"hips": "DEF-hips",
"head": "DEF-head",
"neck": "DEF-neck",
"spine": [
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-neck",
"DEF-head"
],
"shoulder.L": "DEF-shoulder.L",
"shoulder.R": "DEF-shoulder.R",
"upper_arm.L": "DEF-upper_arm.L",
"upper_arm.R": "DEF-upper_arm.R",
"forearm.R": "DEF-forearm.R",
"forearm.L": "DEF-forearm.L",
"thigh.L": "DEF-thigh.L",
"thigh.R": "DEF-thigh.R",
"foot.R": "DEF-foot.R",
"foot.L": "DEF-foot.L",
"shin.L": "DEF-shin.L",
"shin.R": "DEF-shin.R",
"hand.R": "DEF-hand.R",
"hand.L": "DEF-hand.L",
"toe.L": "DEF-toe.L",
"toe.R": "DEF-toe.R"
},
"fingers": {
"thumb.L": [
"DEF-thumb.01.L",
"DEF-thumb.02.L",
"DEF-thumb.03.L"
],
"index.L": [
"DEF-f_index.01.L",
"DEF-f_index.02.L",
"DEF-f_index.03.L"
],
"middle.L": [
"DEF-f_middle.01.L",
"DEF-f_middle.02.L",
"DEF-f_middle.03.L"
],
"ring.L": [
"DEF-f_ring.01.L",
"DEF-f_ring.02.L",
"DEF-f_ring.03.L"
],
"pinky.L": [
"DEF-f_pinky.01.L",
"DEF-f_pinky.02.L",
"DEF-f_pinky.03.L"
],
"thumb.R": [
"DEF-thumb.01.R",
"DEF-thumb.02.R",
"DEF-thumb.03.R"
],
"index.R": [
"DEF-f_index.01.R",
"DEF-f_index.02.R",
"DEF-f_index.03.R"
],
"middle.R": [
"DEF-f_middle.01.R",
"DEF-f_middle.02.R",
"DEF-f_middle.03.R"
],
"ring.R": [
"DEF-f_ring.01.R",
"DEF-f_ring.02.R",
"DEF-f_ring.03.R"
],
"pinky.R": [
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.R"
]
},
"chains": [],
"twist": [],
"surfaces": [
{
"mesh": "Mannequin",
"surface": 0,
"material": "M_Main",
"class": "body",
"detail": "skin",
"why": "no name or weight evidence \u2014 treated as body",
"verts": 3391,
"textured": false,
"chain_share": {}
},
{
"mesh": "Mannequin",
"surface": 1,
"material": "M_Joints",
"class": "body",
"detail": "skin",
"why": "no name or weight evidence \u2014 treated as body",
"verts": 5157,
"textured": false,
"chain_share": {}
}
],
"colliders": [
{
"bone": "DEF-hips",
"child": "DEF-spine.001",
"from": 0.0,
"radius_head": 0.1381,
"radius_tail": 0.1381,
"radius": 0.1381,
"lid": true
},
{
"bone": "DEF-thigh.L",
"child": "DEF-shin.L",
"from": 0.1,
"radius_head": 0.1037,
"radius_tail": 0.0733,
"radius": 0.0733
},
{
"bone": "DEF-thigh.R",
"child": "DEF-shin.R",
"from": 0.1,
"radius_head": 0.1037,
"radius_tail": 0.0733,
"radius": 0.0733
},
{
"bone": "DEF-shin.L",
"child": "DEF-foot.L",
"from": 0.1,
"radius_head": 0.0927,
"radius_tail": 0.0516,
"radius": 0.0516
},
{
"bone": "DEF-shin.R",
"child": "DEF-foot.R",
"from": 0.1,
"radius_head": 0.0927,
"radius_tail": 0.0516,
"radius": 0.0516
}
],
"weights_authored": true,
"driven_bones": [
"DEF-f_index.01.L",
"DEF-f_index.01.R",
"DEF-f_index.02.L",
"DEF-f_index.02.R",
"DEF-f_index.03.L",
"DEF-f_index.03.R",
"DEF-f_middle.01.L",
"DEF-f_middle.01.R",
"DEF-f_middle.02.L",
"DEF-f_middle.02.R",
"DEF-f_middle.03.L",
"DEF-f_middle.03.R",
"DEF-f_pinky.01.L",
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.L",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.L",
"DEF-f_pinky.03.R",
"DEF-f_ring.01.L",
"DEF-f_ring.01.R",
"DEF-f_ring.02.L",
"DEF-f_ring.02.R",
"DEF-f_ring.03.L",
"DEF-f_ring.03.R",
"DEF-foot.L",
"DEF-foot.R",
"DEF-forearm.L",
"DEF-forearm.R",
"DEF-hand.L",
"DEF-hand.R",
"DEF-head",
"DEF-hips",
"DEF-neck",
"DEF-shin.L",
"DEF-shin.R",
"DEF-shoulder.L",
"DEF-shoulder.R",
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-thigh.L",
"DEF-thigh.R",
"DEF-thumb.01.L",
"DEF-thumb.01.R",
"DEF-thumb.02.L",
"DEF-thumb.02.R",
"DEF-thumb.03.L",
"DEF-thumb.03.R",
"DEF-toe.L",
"DEF-toe.R",
"DEF-upper_arm.L",
"DEF-upper_arm.R",
"root"
]
}
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Hatsune Miku",
"uid": "34f3e7daa4c64c8a8000ae7f90b01ceb",
"author": "Tigerar1",
"author_url": "https://sketchfab.com/allanromanreyes",
"license": "Free Standard",
"license_slug": "free-st",
"source_url": "https://sketchfab.com/3d-models/hatsune-miku-34f3e7daa4c64c8a8000ae7f90b01ceb"
}
+229
View File
@@ -0,0 +1,229 @@
{
"roles": {
"hips": "DEF-hips",
"head": "DEF-head",
"neck": "DEF-neck",
"spine": [
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-neck",
"DEF-head"
],
"shoulder.L": "DEF-shoulder.L",
"shoulder.R": "DEF-shoulder.R",
"upper_arm.L": "DEF-upper_arm.L",
"upper_arm.R": "DEF-upper_arm.R",
"forearm.L": "DEF-forearm.L",
"forearm.R": "DEF-forearm.R",
"thigh.L": "DEF-thigh.L",
"thigh.R": "DEF-thigh.R",
"foot.R": "DEF-foot.R",
"foot.L": "DEF-foot.L",
"shin.R": "DEF-shin.R",
"shin.L": "DEF-shin.L",
"hand.L": "DEF-hand.L",
"hand.R": "DEF-hand.R",
"toe.R": "DEF-toe.R",
"toe.L": "DEF-toe.L"
},
"fingers": {
"thumb.L": [
"DEF-thumb.01.L",
"DEF-thumb.02.L",
"DEF-thumb.03.L"
],
"index.L": [
"DEF-f_index.01.L",
"DEF-f_index.02.L",
"DEF-f_index.03.L"
],
"middle.L": [
"DEF-f_middle.01.L",
"DEF-f_middle.02.L",
"DEF-f_middle.03.L"
],
"ring.L": [
"DEF-f_ring.01.L",
"DEF-f_ring.02.L",
"DEF-f_ring.03.L"
],
"pinky.L": [
"DEF-f_pinky.01.L",
"DEF-f_pinky.02.L",
"DEF-f_pinky.03.L"
],
"thumb.R": [
"DEF-thumb.01.R",
"DEF-thumb.02.R",
"DEF-thumb.03.R"
],
"index.R": [
"DEF-f_index.01.R",
"DEF-f_index.02.R",
"DEF-f_index.03.R"
],
"middle.R": [
"DEF-f_middle.01.R",
"DEF-f_middle.02.R",
"DEF-f_middle.03.R"
],
"ring.R": [
"DEF-f_ring.01.R",
"DEF-f_ring.02.R",
"DEF-f_ring.03.R"
],
"pinky.R": [
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.R"
]
},
"chains": [],
"twist": [],
"surfaces": [
{
"mesh": "Object_2",
"surface": 0,
"material": "body",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 1500,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 1,
"material": "body_parts",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 330,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 2,
"material": "hair",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 500,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 3,
"material": "face",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 461,
"textured": true,
"chain_share": {}
}
],
"colliders": [
{
"bone": "DEF-hips",
"child": "DEF-spine.001",
"from": 0.0,
"radius_head": 0.1915,
"radius_tail": 0.1915,
"radius": 0.1915,
"lid": true
},
{
"bone": "DEF-thigh.L",
"child": "DEF-shin.L",
"from": 0.1,
"radius_head": 0.1435,
"radius_tail": 0.1304,
"radius": 0.1304
},
{
"bone": "DEF-thigh.R",
"child": "DEF-shin.R",
"from": 0.1,
"radius_head": 0.1084,
"radius_tail": 0.1084,
"radius": 0.1084
},
{
"bone": "DEF-shin.L",
"child": "DEF-foot.L",
"from": 0.1,
"radius_head": 0.0862,
"radius_tail": 0.0728,
"radius": 0.0728
},
{
"bone": "DEF-shin.R",
"child": "DEF-foot.R",
"from": 0.1,
"radius_head": 0.0862,
"radius_tail": 0.0728,
"radius": 0.0728
}
],
"weights_authored": false,
"driven_bones": [
"DEF-f_index.01.L",
"DEF-f_index.01.R",
"DEF-f_index.02.L",
"DEF-f_index.02.R",
"DEF-f_index.03.L",
"DEF-f_index.03.R",
"DEF-f_middle.01.L",
"DEF-f_middle.01.R",
"DEF-f_middle.02.L",
"DEF-f_middle.02.R",
"DEF-f_middle.03.L",
"DEF-f_middle.03.R",
"DEF-f_pinky.01.L",
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.L",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.L",
"DEF-f_pinky.03.R",
"DEF-f_ring.01.L",
"DEF-f_ring.01.R",
"DEF-f_ring.02.L",
"DEF-f_ring.02.R",
"DEF-f_ring.03.L",
"DEF-f_ring.03.R",
"DEF-foot.L",
"DEF-foot.R",
"DEF-forearm.L",
"DEF-forearm.R",
"DEF-hand.L",
"DEF-hand.R",
"DEF-head",
"DEF-hips",
"DEF-neck",
"DEF-shin.L",
"DEF-shin.R",
"DEF-shoulder.L",
"DEF-shoulder.R",
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-thigh.L",
"DEF-thigh.R",
"DEF-thumb.01.L",
"DEF-thumb.01.R",
"DEF-thumb.02.L",
"DEF-thumb.02.R",
"DEF-thumb.03.L",
"DEF-thumb.03.R",
"DEF-toe.L",
"DEF-toe.R",
"DEF-upper_arm.L",
"DEF-upper_arm.R",
"root"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "DANDADAN - Momo Ayase (3D Model) + DL",
"uid": "6ac6c3476a1f4b8da1c7de7e98a7c83c",
"author": "HiGuys920",
"author_url": "https://sketchfab.com/higuys920",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/dandadan-momo-ayase-3d-model-dl-6ac6c3476a1f4b8da1c7de7e98a7c83c"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
@@ -0,0 +1,10 @@
{
"name": "3D Anime Female Adventurer Character Model",
"version": "1.2",
"author": "Dawn to Dusk Games",
"author_url": "https://dawn-to-dusk-games.itch.io/",
"license": "CC0 1.0 Universal",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"source_url": "https://dawn-to-dusk-games.itch.io/3d-anime-female-adventurer",
"changes": "Outfit 01 retargeted to the Papaya-Shooter animation library; game-native mecha equipment added at runtime."
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
{
"skins": [
{
"id": "miku",
"name": "Miku",
"description": "Hatsune Miku \u2014 Virtual Idol",
"model": "res://assets/characters/skins/miku.glb",
"viewmodel": {
"sleeve": "#168F98",
"accent": "#252632",
"skin": "#F5CDBB",
"glove": "#20212C",
"nail": "#20A9B1",
"hand_style": "fingerless",
"sleeve_ratio": 0.78
},
"unlocked": true
},
{
"id": "taila",
"name": "Taila",
"description": "",
"model": "res://assets/characters/skins/taila.glb",
"viewmodel": {
"sleeve": "#171D32",
"accent": "#E8772D",
"skin": "#EFC6B7",
"glove": "#151826",
"nail": "#E8772D",
"hand_style": "glove",
"sleeve_ratio": 0.78
},
"unlocked": true
},
{
"id": "mannequin",
"name": "Mannequin",
"description": "Quaternius reference mannequin (CC0)",
"model": "res://assets/characters/skins/mannequin.glb",
"viewmodel": {
"sleeve": "#D9692D",
"accent": "#6D4C9B",
"skin": "#D9692D",
"glove": "#6D4C9B",
"nail": "#F0A14B",
"hand_style": "android",
"sleeve_ratio": 0.84
},
"unlocked": true
},
{
"id": "kiyoko",
"name": "Kiyoko",
"description": "Kiyoko School Girl \u2014 VRoid (CC-BY, Kasujin)",
"model": "res://assets/characters/skins/kiyoko.glb",
"viewmodel": {
"sleeve": "#ECE7E3",
"accent": "#253151",
"skin": "#F0C6B2",
"glove": "#253151",
"nail": "#E9A9B4",
"hand_style": "bare",
"sleeve_ratio": 0.32
},
"unlocked": true
},
{
"id": "aria",
"name": "Aria",
"description": "Anime Girl Rigged \u2014 VRoid (CC-BY, dequeijospizza)",
"model": "res://assets/characters/skins/aria.glb",
"viewmodel": {
"sleeve": "#E8E8E5",
"accent": "#27252D",
"skin": "#EFC7B7",
"glove": "#27252D",
"nail": "#DFA0AE",
"hand_style": "bare",
"sleeve_ratio": 0.30
},
"unlocked": true
},
{
"id": "momo",
"name": "Momo",
"description": "Momo Ayase, DANDADAN (CC-BY, HiGuys920)",
"model": "res://assets/characters/skins/momo.glb",
"viewmodel": {
"sleeve": "#D66F98",
"accent": "#34303A",
"skin": "#F0C5B2",
"glove": "#34303A",
"nail": "#D66F98",
"hand_style": "fingerless",
"sleeve_ratio": 0.80
},
"unlocked": true
}
]
}
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Taila | Original work",
"uid": "8ae231b61fc34827be30e2a1edc5b811",
"author": "Partaevil",
"author_url": "https://sketchfab.com/Partaevil",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/taila-original-work-8ae231b61fc34827be30e2a1edc5b811"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+50
View File
@@ -0,0 +1,50 @@
{
"skins": {
"aria": {
"ak47": {
"curl_thumb": 0.0,
"curl_trigger": 2.0,
"curl_wrap": 0.655,
"fore_shift": [
-0.103000000119209,
0.034000001847744,
0.017000000923872
],
"grip_shift": [
0.0199999995529652,
-0.145999997854233,
-0.00899999961256981
],
"gun_fore": 0.157,
"gun_stock": 0.06,
"pitch_hip": 0.0610000000000001,
"pocket_hip": [
0.00700000021606684,
-0.146999999880791,
0.0329999998211861
],
"pole_l_hip": [
0.493999987840652,
-0.433999985456467,
-0.18299999833107
],
"pole_r_hip": [
-0.114000000059605,
-0.25900000333786,
-0.526000022888184
],
"weapon_scale": 0.53,
"wrist_l_hip": [
-1.307000041008,
-0.0480000004172325,
0.0410000011324883
],
"wrist_r_hip": [
-0.421999990940094,
1.60000002384186,
1.28299999237061
]
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More