Skip to content

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:

FieldMeaning
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
LocalTransformTRS relative to parent
InverseBindTransformInverse 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:

MagicVersionReaderStorage
r3d2canm-ReadCompressedQuantized TRS per keyframe, sparse per joint
r3d2anmd5ReadV5Indexed into shared vectors/rotations arrays
r3d2anmd4ReadV4Same as V5 plus per-frame joint hash repetition
r3d2anmdotherReadLegacyFixed-size per-track with padded name

Each file produces a list of AnimationTracks. A track has:

csharp
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.csElfHash() lowercases its input before hashing:

csharp
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:

  1. Pass 1 — prefer skinning joints. For each remaining track, find a joint in the current BFS frontier whose Hash == track.JointNameHash and whose Id is in Influences.
  2. 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 (head vs Head, L_hat vs L_Hat1, Spine vs Spine1/2/3, L_Shouler — typo'd — vs L_Shoulder).
  • Id is not in Influences.

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:

  1. Creates a Node per joint (local TRS = LocalTransform.Transpose()) and wires parent/child.
  2. Writes InverseBindTransform.Transpose() per joint into the skin's IBM accessor in joint-id order — joint i in the GLTF skin.joints array corresponds to IBM index i. The mesh's JOINTS_n attribute indexes into this array.
  3. The GLTF skinning formula then resolves to T(bind) = identity (bind pose) and T(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.Translationstranslation 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:

js
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

SymptomLikely cause
T-pose correct, every animation mangles one specific boneTrack routed to orphan stub with colliding hash
Head-at-crotch (bone at world origin) on every animationTrack routed to stub whose bind translation cancels root
Weapon / VFX attach point frozen during animationsAttachment bone excluded from mapping (Pass 2 missing or broken)
Subset of a rig animates, rest stays at bindTrack 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, leaving Weapon_Rabbit stranded 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:

  1. 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 .anm instances (multiple clip names may point at the same file — e.g. Garen's Recall_Base and Recall) so each Animation is rewritten only once per victim.
  2. PASS 2 — reparent each victim: set ParentId = -1 and LocalTransform = original GlobalTransform. The bind-pose world is unchanged, InverseBindTransform stays 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.local correctly 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 leave Duration = 0 because they only set FrameDuration; we now set Duration = framesPerTrack × FrameDuration explicitly so these animations participate in the bake instead of being silently skipped.
  • Frame-rate conventions are mixed: mStartFrame/mEndFrame are in 30 Hz ticks, our bake samples at 60 Hz. Keep SnapFrameRate = 30f and SnapSampleRate = 60f in sync with those sources.

Test cases

  • Garen Skin 0 (86000) — Garen_2013_dance_loop drops the sword; weapon is snapped to buffbone_glb_channel_loc. After bake, world-space motion of the weapon bone during the dance is 0 units. Also covers the shared-Animation case via Recall_Base/Recall pointing at the same .anm.

Known test cases

  • Shaco Skin 8 (35008) — has both Head (skinned, parent=Neck) and head (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 real Head snaps to the orphan's bind on every animation. Minimum reproducer for any regression in MapAllTracksInOrder.

Files

  • LeagueToolkit/IO/SkeletonFile/Skeleton.cs.skl parser, MapTracksToJoints, MapAllTracksInOrder
  • LeagueToolkit/IO/SkeletonFile/SkeletonJoint.cs — joint record, transform coupling, Reparent
  • LeagueToolkit/IO/AnimationFile/Animation.cs.anm parsers (compressed / v4 / v5 / legacy)
  • LeagueToolkit/IO/AnimationFile/AnimationTrack.cs — track record
  • LeagueToolkit/Helpers/Cryptography.csElfHash (case-insensitive)
  • LeagueConvert/IO/Skin/Extensions/SkinExtensions.csBuildSkeleton, CreateAnimations, WriteAnimationChannel
  • LeagueConvert/IO/Skin/Skin.csGetJointSnapEventData, BakeJointSnapEvents, RewriteVictimTrackAsWorld, ComputeWorldTransform

Built for engineers and AI assistants working on Khada.