Skeletons & Animations
This document is a knowledge base for how the extractor reads League's skeleton (.skl) and animation (.anm) files, maps animation tracks to joints, and exports them as GLTF skins/animations. The non-obvious invariants below were learned the hard way — read this before touching anything in LeagueToolkit/IO/SkeletonFile/ or LeagueToolkit/IO/AnimationFile/.
Pipeline at a glance
.skn (mesh) ─┐
.skl (bones) ─┼─► Skin.cs ─► SkinExtensions.BuildSkeleton ─► GLTF skin (joints + IBMs)
.anm (1..N) ─┤ │
│ ▼
└──► Skeleton.MapTracksToJoints ─► GLTF animations (TRS channels per joint)At runtime, the viewer plays an animation by replacing each animated joint's local TRS transform with the keyframe values; unanimated joints stay at their bind-pose local transform. Mesh vertices are skinned via jointGlobalMatrix(animated) × IBM × vertex.
.skl — Skeleton file
Parsed in LeagueToolkit/IO/SkeletonFile/Skeleton.cs. Two formats:
- Current (
FormatToken 0x22FD4FC3) —Read(). Stores joints + explicit hashes + influences. - Legacy (
r3d2sklt v1/v2) —ReadLegacy(). Stores only global transforms; local transforms are derived from parent.
Each SkeletonJoint has:
| Field | Meaning |
|---|---|
Id (short) | Joint index (order in the file) |
ParentId (short) | Parent joint Id, or -1 for root joints |
Hash (uint) | ElfHash(name) — case-insensitive (see pitfall below) |
Name (string) | Joint name |
LocalTransform | TRS relative to parent |
InverseBindTransform | Inverse of the global bind-pose transform |
Set-coupling: setting GlobalTransform recomputes InverseBindTransform and vice versa.
The Influences list
Skeleton.Influences is a list of joint Ids that actually skin mesh vertices. Mesh vertex weights reference positions in this list, not joint IDs directly. A joint NOT in Influences has no mesh binding — it exists purely as an attachment/locator (VFX anchor, weapon attach, healthbar position, etc.) or as an animation compatibility stub.
This fact is load-bearing for track-to-joint mapping (see below).
.anm — Animation file
Parsed in LeagueToolkit/IO/AnimationFile/Animation.cs. Four formats:
| Magic | Version | Reader | Storage |
|---|---|---|---|
r3d2canm | - | ReadCompressed | Quantized TRS per keyframe, sparse per joint |
r3d2anmd | 5 | ReadV5 | Indexed into shared vectors/rotations arrays |
r3d2anmd | 4 | ReadV4 | Same as V5 plus per-frame joint hash repetition |
r3d2anmd | other | ReadLegacy | Fixed-size per-track with padded name |
Each file produces a list of AnimationTracks. A track has:
uint JointNameHash; // ElfHash of target joint name
IDictionary<float, Vector3> Translations;
IDictionary<float, Vector3> Scales;
IDictionary<float, Quaternion> Rotations;A track only animates the components it has keyframes for — if Rotations has data but Translations is empty, only rotation is written to GLTF; the joint keeps its bind-pose translation.
⚠️ ElfHash is case-insensitive
LeagueToolkit/Helpers/Cryptography.cs — ElfHash() lowercases its input before hashing:
public static uint ElfHash(string toHash)
{
toHash = toHash.ToLower(); // ← this line is load-bearing
...
}Consequence: two joints named "Head" and "head" hash to the same 32-bit value, and animation tracks store only the hash. When the skeleton contains both variants, you have a collision and must disambiguate by some other signal.
League's in-game engine disambiguates at runtime (likely by joint declaration order); our extractor has to do it explicitly.
Track-to-joint mapping
Skeleton.MapTracksToJoints(Animation) walks the bone tree breadth-first and consumes tracks as it goes. The inner MapAllTracksInOrder does a two-pass match:
- Pass 1 — prefer skinning joints. For each remaining track, find a joint in the current BFS frontier whose
Hash == track.JointNameHashand whoseIdis inInfluences. - Pass 2 — any match. If no influential joint matched, fall back to any joint in the frontier with the same hash (for attachment bones that are animated but don't skin).
Both passes are necessary:
- Pass 1 handles hash collisions where one variant is a real skinning bone and the other is a compatibility stub — track goes to the real bone.
- Pass 2 preserves tracks for buffbones / locators / weapon attach points / healthbar anchors, which are animated but never skin mesh vertices.
Compatibility stub bones
Some champion skeletons contain "orphan" duplicate joints — same hash as a real bone (differing only in case), parented directly to the skeleton root, with a bind translation that cancels the root's (e.g. T=(0, -64.08, 1.58) when Root is at T=(0, 64.08, 1.58), placing the stub at world origin). They exist so that older base-champion .anm files — authored against an earlier bone naming scheme — can still match a joint hash without erroring out.
How to recognise one:
- Parent is the skeleton root (not a meaningful chain like Neck→Head).
- Bind local translation geometrically cancels the parent, placing the joint at world origin (near the crotch on humanoid rigs).
- Name is the lowercased / older form of a real bone (
headvsHead,L_hatvsL_Hat1,SpinevsSpine1/2/3,L_Shouler— typo'd — vsL_Shoulder). Idis not inInfluences.
If the mapping accidentally routes a real animation track to one of these stubs, the real bone gets the other track with the same hash (typically the one meant for the stub), snapping it to the stub's bind position. Every animation then looks catastrophically wrong — classic symptom: head at the crotch.
GLTF export
SkinExtensions.BuildSkeleton:
- Creates a
Nodeper joint (local TRS =LocalTransform.Transpose()) and wires parent/child. - Writes
InverseBindTransform.Transpose()per joint into the skin's IBM accessor in joint-id order — jointiin the GLTFskin.jointsarray corresponds to IBM indexi. The mesh'sJOINTS_nattribute indexes into this array. - The GLTF skinning formula then resolves to
T(bind) = identity(bind pose) andT(animated) = chain(animated localTs) × IBM— which is why the IBM must be the inverse of the global bind transform, not any animated state.
SkinExtensions.CreateAnimations consumes the mapping from MapTracksToJoints and emits one GLTF animation channel per non-empty component (track.Translations → translation channel, etc.). Empty components are omitted so unanimated axes fall back to bind-pose.
Debugging
Quick GLB inspection
Use @gltf-transform/core in a one-off script — iterate root.listSkins()[0].listJoints(), print each joint's name, parent, and local TRS. Pair with root.listAnimations() iteration to dump first/last keyframe per channel.
For skinning-vs-orphan check:
const referenced = new Set();
for (const mesh of root.listMeshes())
for (const prim of mesh.listPrimitives())
for (let i = 0; prim.getAttribute(`JOINTS_${i}`); i++)
for (const v of prim.getAttribute(`JOINTS_${i}`).getArray())
referenced.add(v);
// any joint whose index is NOT in `referenced` is unskinned (orphan, locator, attach point)Signs of a track-mapping bug
| Symptom | Likely cause |
|---|---|
| T-pose correct, every animation mangles one specific bone | Track routed to orphan stub with colliding hash |
| Head-at-crotch (bone at world origin) on every animation | Track routed to stub whose bind translation cancels root |
| Weapon / VFX attach point frozen during animations | Attachment bone excluded from mapping (Pass 2 missing or broken) |
| Subset of a rig animates, rest stays at bind | Track hashes don't match any joint (skeleton vs anm mismatch) — investigate Debug.Assert in Skeleton.Read() |
JointSnapEventData — world-locked props
Open bug: on Cottontail Lux (
99029) this bake reparents two bones no snap event names, leavingWeapon_Rabbitstranded at world origin. See Lux 99029 case study before changing anything here.
Some animations need a bone to follow a different bone's world transform for a slice of time — Garen's dance drops his sword and the weapon has to stay on the ground while the rest of the rig moves above it, death poses pin limbs to props, etc. The game encodes this as a JointSnapEventData on the animation clip's mEventDataMap (metaclass hash 3049371309):
mJointNameToOverride — victim bone whose world transform should be replaced
mJointNameToSnapTo — target bone whose world transform provides the replacement
mStartFrame / mEndFrame — active window, in 30 Hz ticks. Omitted = whole animation.Both joint-name fields may be stored as BinTreeString (lowercase, "weapon", "buffbone_glb_channel_loc") or as a hash ref — handle both, and resolve the hash via HashTables.BinHashes.
Why we bake at extract time
The game engine replaces the victim's WORLD matrix directly; skinning then uses that world matrix regardless of what the parent chain did. glTF has no equivalent "world override" — it can only drive per-bone LOCAL TRS, and the viewer composes world = parent.world × child.local at playback.
The naïve bake is victim.local = target.world × inverse(parent.world) sampled per frame. It's exact at keyframes but breaks between them: viewers lerp parent.local and victim.local independently, and those interpolation errors don't cancel through the chain. You see visible weapon wobble (Garen dance), because the cancellation only holds when both sides are evaluated together.
Reparenting bake
Instead, we reparent the victim to the skeleton root and overwrite its entire track with the desired world-space TRS at each sample:
- PASS 1 — for every animation, for every victim, sample the desired world transform (target's world during snap, victim's own chain-composed world otherwise) at 60 Hz. Decompose and write TRS into the victim's track. Must run before reparenting so chain walks still traverse the original hierarchy. Use a
HashSet<Animation>to handle shared.anminstances (multiple clip names may point at the same file — e.g. Garen'sRecall_BaseandRecall) so eachAnimationis rewritten only once per victim. - PASS 2 — reparent each victim: set
ParentId = -1andLocalTransform = original GlobalTransform. The bind-pose world is unchanged,InverseBindTransformstays valid, skinning still works at rest.
After the bake, the victim has no animated ancestor to fight with. Linear interpolation between adjacent world-space samples is a straight line through 3D space — exactly what the game shows. The viewer does no extra work.
Caveats
- Children of a reparented victim are rare but valid; they keep composing
child.world = victim.world × child.localcorrectly because the victim's world now carries the game-truth at every instant. - The bake depends on
Animation.Duration. Uncompressed readers (ReadV4/V5/Legacy) used to leaveDuration = 0because they only setFrameDuration; we now setDuration = framesPerTrack × FrameDurationexplicitly so these animations participate in the bake instead of being silently skipped. - Frame-rate conventions are mixed:
mStartFrame/mEndFrameare in 30 Hz ticks, our bake samples at 60 Hz. KeepSnapFrameRate = 30fandSnapSampleRate = 60fin sync with those sources.
Test cases
- Garen Skin 0 (
86000) —Garen_2013_dance_loopdrops the sword;weaponis snapped tobuffbone_glb_channel_loc. After bake, world-space motion of the weapon bone during the dance is 0 units. Also covers the shared-Animation case viaRecall_Base/Recallpointing at the same.anm.
Known test cases
- Shaco Skin 8 (
35008) — has bothHead(skinned, parent=Neck) andhead(orphan, parent=Root, bind T≈(0,−64,1.58)). Base Shaco animations reference the lowercase legacy name; Skin 8 animations reference both. Without the two-pass Influences-preference match, the realHeadsnaps to the orphan's bind on every animation. Minimum reproducer for any regression inMapAllTracksInOrder.
Files
LeagueToolkit/IO/SkeletonFile/Skeleton.cs—.sklparser,MapTracksToJoints,MapAllTracksInOrderLeagueToolkit/IO/SkeletonFile/SkeletonJoint.cs— joint record, transform coupling,ReparentLeagueToolkit/IO/AnimationFile/Animation.cs—.anmparsers (compressed / v4 / v5 / legacy)LeagueToolkit/IO/AnimationFile/AnimationTrack.cs— track recordLeagueToolkit/Helpers/Cryptography.cs—ElfHash(case-insensitive)LeagueConvert/IO/Skin/Extensions/SkinExtensions.cs—BuildSkeleton,CreateAnimations,WriteAnimationChannelLeagueConvert/IO/Skin/Skin.cs—GetJointSnapEventData,BakeJointSnapEvents,RewriteVictimTrackAsWorld,ComputeWorldTransform