Dark Star Shaco: head-at-crotch bug
Skin affected: Shaco Skin 8 (ID 35008, "Dark Star Shaco"). Symptom: On every animation, Shaco's head rendered at his crotch. T-pose was correct; only animated skins were broken. Root cause: an ElfHash collision between two joints with case-different names got routed to the wrong bone during Skeleton.MapTracksToJoints.
Background: how track → joint matching works
.anm animation files contain tracks keyed by uint joint-name hashes (ElfHash), not by name:
public class AnimationTrack {
public uint JointNameHash; // ElfHash(joint_name)
public IDictionary<float, Vector3> Translations;
// ...
}At load time, Skeleton.MapTracksToJoints(Animation) walks the bone tree breadth-first and matches each track to a joint with the same hash. The match is the only link between the animation data and the skinning bones.
The two joints that broke it
Dark Star Shaco's skeleton has two joints whose names differ only by case:
| Joint name | Id | Parent | Bind translation | Skins mesh? |
|---|---|---|---|---|
Head | (real head bone) | Neck | neck-relative | yes (Id ∈ Influences) |
head | (orphan) | Root (-1) | ≈(0, -64, 1.58) — cancels Root's (0, 64, 1.58) | no |
The orphan head is a compatibility stub: older base-Shaco .anm files reference the lowercase head that existed in an earlier bone naming scheme. Riot keeps the stub so those animations don't error out at load time. Its bind translation is chosen to cancel the root's offset, placing it near world origin (the crotch on a humanoid rig).
The collision
LeagueToolkit/Helpers/Cryptography.cs#ElfHash lowercases input before hashing:
public static uint ElfHash(string toHash) {
toHash = toHash.ToLower(); // ← load-bearing
// …ELF hash…
}Consequence: ElfHash("Head") == ElfHash("head"). Both joints have the same hash in the animation track's eyes. The match becomes a coin flip — whichever joint comes first in the BFS traversal claims the track.
In Shaco Skin 8, BFS traversal (ordered by joint Id, which reflects declaration order in the .skl) encountered the orphan head before the real Head. So every animation's head-track got bound to the orphan, which has its bind transform fixed at world origin. The real Head got nothing and rendered at its rest pose... which didn't match the expected motion. Visually: head at crotch.
The fix: two-pass, Influences-preference match
MapAllTracksInOrder was changed to match in two passes:
// Pass 1 — for each unclaimed track, pick a joint whose hash matches AND
// whose Id is in skeleton.Influences (i.e., it actually skins mesh vertices).
foreach (var track in unclaimed) {
var real = frontier.FirstOrDefault(j => j.Hash == track.JointNameHash
&& influences.Contains(j.Id));
if (real != null) { claim(track, real); }
}
// Pass 2 — fall back to any joint with the matching hash (locators, VFX anchors,
// weapon attach points — animated but don't skin mesh).
foreach (var track in stillUnclaimed) {
var any = frontier.FirstOrDefault(j => j.Hash == track.JointNameHash);
if (any != null) { claim(track, any); }
}The key insight: skinning joints always win over compatibility stubs. The real Head is in Skeleton.Influences (mesh vertices bind to it); the orphan head is not. Pass 1 routes Shaco's head-track to the real Head. Pass 2 still picks up attachment-only bones (weapon anchors, buffbones, etc.) that are animated but never appear in Influences — keeping those features working.
How to recognise the pattern elsewhere
A joint is probably a compatibility stub when all of the following hold:
- Parent is the skeleton root (not a real chain like Neck → Head).
- Bind local translation geometrically cancels the root's, placing the joint at world origin (near the crotch on humanoid rigs).
- Name is a lowercase / older form of a real bone (
headvsHead,L_hatvsL_Hat1,SpinevsSpine1/2/3,L_Shouler— typo'd — vsL_Shoulder). - Id is not in
Influences.
If every animation routes a track to such a joint, the real bone gets nothing or the other clashing track — classic symptom: any bone at the crotch on every animation.
Regression test
Shaco Skin 8 is the minimum reproducer for any future regression in MapAllTracksInOrder:
pnpm run processor generate-files "[35008]"Expected: Shaco's head stays on his neck during Dance, Recall, and all idle variations. If you see head-at-crotch, Pass 1 of the match is broken.
Files
packages/extractor/LeagueToolkit/IO/SkeletonFile/Skeleton.cs—MapTracksToJoints,MapAllTracksInOrderpackages/extractor/LeagueToolkit/Helpers/Cryptography.cs—ElfHash(case-insensitive)packages/extractor/LeagueConvert/IO/Skin/Extensions/SkinExtensions.cs— consumes the mapping inCreateAnimationspackages/docs/skeleton-and-animation.md— full skeleton/animation pipeline reference