# Gum Runtime Animation Chains

> Runtime Animation Chains

- Skill: `vchelaru/gum-runtime-animation-chains` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/gum-runtime-animation-chains`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/gum-runtime-animation-chains/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/gum-runtime-animation-chains

---


# Runtime Animation Chains

Gum plays back FRB-style `.achx`/`.achj` animations on `Sprite` and `NineSlice`. `.achx` (XML) and `.achj` (JSON, matching FlatRedBall2's `AnimationChain.Common` writer) both deserialize into `AnimationChainListSave` — see `AnimationChainListSave.FromFile`. Each chain is a list of frames with a texture, source rect, frame length, optional flip flags, optional `RelativeX`/`RelativeY` per-frame offsets, and optional per-frame color (see below).

## Pipeline: Save → Runtime

`AnimationChainListSave` (XML) → `ToAnimationChainList()` → `AnimationChainList` (a `List<AnimationChain>`). Each `AnimationChainSave.Frames[i]` becomes an `AnimationFrame` via `AnimationFrameSave.ToAnimationFrame(loadTexture, coordinateType)`.

**`TextureCoordinateType` matters at load time, not render time.** The `AnimationFrame.LeftCoordinate/RightCoordinate/TopCoordinate/BottomCoordinate` fields are always stored as UV (0–1). If the source `.achx` declares `<CoordinateType>Pixel</CoordinateType>`, the loader divides by `Texture.Width/Height` during conversion. UV-mode `.achx` files copy the values verbatim. A frame loaded before its texture resolves cannot perform pixel-to-UV conversion — its coords stay zero.

## Playback: AnimationChainLogic

Both `Sprite` and `NineSlice` compose an `AnimationChainLogic` instance (XNA, Sokol, Raylib, Skia all share the same playback type). `SpriteAnimationLogic` is a back-compat `[Obsolete]` subclass; new code should use `AnimationChainLogic`.

State lives entirely on `AnimationChainLogic`:

- `_currentChainIndex` defaults to 0 so assigning `AnimationChains` + `Animate = true` works without setting `CurrentChainName`. `CurrentChainName` setter sets `_currentChainIndex = -1` and resolves the desired name lazily once chains are populated (`RefreshCurrentChainToDesiredName`).
- `AnimateSelf(secondDifference)` advances `_timeIntoAnimation`, loops or clamps based on `IsAnimationChainLooping`, fires `AnimationChainCycled`, picks a new frame via `UpdateFrameBasedOffOfTimeIntoAnimation`, and — only if the frame index changed — calls `UpdateToCurrentAnimationFrame()`.
- `UpdateToCurrentAnimationFrame()` invokes the `ApplyFrame` delegate the host wired up. **It does not directly mutate the renderable.**

`AnimateSelf` is driven once per frame by `GraphicalUiElement.AnimateSelf` (recursively). The whole subsystem is platform-agnostic — there is no MonoGame coupling in `AnimationChainLogic`.

## Frame application is split across two times

Sprite handles each frame in **two phases**:

1. **On frame change** (`Sprite.ApplyAnimationFrame`, wired into `AnimationLogic.ApplyFrame`): copies `Texture`, computes pixel `SourceRectangle` from UV coords × texture size, and copies `FlipHorizontal`/`FlipVertical`. Persistent state.
2. **At every render** (`Sprite.Render`, when `CurrentFrameIndex < CurrentChain.Count`): re-reads `CurrentChain[CurrentFrameIndex].RelativeX/RelativeY`, mutates `this.X/Y`, calls the static draw, then restores the originals.

This split matters: changing `RelativeX/Y` on a live frame takes effect on the next render without needing `UpdateToCurrentAnimationFrame`. But it also means rotation handling for the offset lives at the render call site, not in `ApplyFrame`.

## The RelativeX/Y anchor mismatch (recurring gotcha)

`Sprite.Render` applies the per-frame offset as a literal pixel shift around a **top-left** anchor (`origin = Vector2.Zero` in the static `Render`). The Y component is negated on apply (`this.Y -= offsetVector.Y`) because `AnimationFrame.RelativeY` follows FRB's Y-up convention while Gum is Y-down.

**The values inside an `.achx` are authored against FRB's center-anchored Sprite.** When the Sprite's destination height is constant (e.g. `HeightUnits = Absolute`), the offset is just a translation and the anchor difference doesn't matter. When destination height tracks the source rect (e.g. `HeightUnits = PercentageOfSourceFile`), the FRB-authored values only compensate **half** the per-frame height change — because the other half was absorbed by FRB's centered anchor. The Gum-rendered sprite drifts in the direction the source rect is shrinking (typically "climbs upward" for collapse animations).

To match FRB visuals on a height-tracking Sprite, the offset has to be applied around the sprite's center, not its top-left — or an extra `(referenceHeight - currentHeight) / 2` must be added to Y.

## NineSlice: same playback, no RelativeX/Y

XNA `NineSlice` now composes `AnimationChainLogic` (same pattern as Sprite) — the inline tick loop is gone. Its `ApplyFrame` handler distributes the frame's texture to all 9 internal sprites via `SetSingleTexture`, derives the `SourceRectangle` from the frame's UV coords, and copies `FlipHorizontal`. It does **not** apply `RelativeX/Y`. If `RelativeX/Y` support is ever needed on NineSlice, add the offset application in NineSlice's render path the same way Sprite does.

Skia and Raylib `NineSlice` renderables also compose `AnimationLogic` and apply frames the same way as XNA — each has its own `ApplyAnimationFrame` in `Runtimes/{RaylibGum,SkiaGum}/Renderables/NineSlice.cs`.

## Per-frame color (Alpha/Multiply/Add)

`AnimationFrameSave`/`AnimationFrame` carry nullable `Red`/`Green`/`Blue`/`Alpha` (0-255) and an `AnimationFrameColorOperation?` (`Multiply`/`Add`), matching FRB2's Animation Editor fields. Each backend's `ApplyAnimationFrame` sets the renderable's own `Alpha` when authored, and `Red`/`Green`/`Blue` only when `ColorOperation == Multiply` (unset channel defaults to 255) — snapshotted once per frame change, not re-combined at render time, so a frame that authors neither leaves the renderable's current color alone. Alpha (#4489) and Multiply (#4490) are applied on all of XNA/Raylib/Skia; Skia's `Sprite` additionally needed a `Color`-driven `SKColorFilter.CreateBlendMode(..., Modulate)` in `GetPaint` since it previously ignored RGB entirely (NineSlice already had this filter). `Add` parses but isn't applied to rendering anywhere yet — it needs a per-backend pixel shader (#4477).

## Key Files

| File | Purpose |
|------|---------|
| `RenderingLibrary/Graphics/Animation/AnimationChainLogic.cs` | Platform-agnostic playback state and tick |
| `RenderingLibrary/Graphics/Animation/SpriteAnimationLogic.cs` | `[Obsolete]` back-compat subclass of `AnimationChainLogic` |
| `RenderingLibrary/Graphics/Animation/AnimationFrame.cs` | Runtime frame; `ToAnimationFrame` extension converts from save with `TextureCoordinateType` |
| `RenderingLibrary/Graphics/Animation/AnimationChain.cs` | `List<AnimationFrame>`; `ToAnimationChain` extension |
| `RenderingLibrary/Graphics/Animation/AnimationChainList.cs` | `List<AnimationChain>`; `.achx` deserialization entry point |
| `RenderingLibrary/Graphics/Sprite.cs` | `ApplyAnimationFrame` (frame-change side) and `Render` (per-render `RelativeX/Y` application) |
| `RenderingLibrary/Graphics/NineSlice.cs` | `ApplyAnimationFrame` distributes frame texture across 9 slices; no `RelativeX/Y` support |
| `Gum/Graphics/Animation/Content/AnimationFrameSave.cs` | XML/JSON-serializable frame; pixel or UV coords per `AnimationChainListSave.CoordinateType`; per-frame color fields |
| `Gum/Graphics/Animation/Content/AnimationChainListSave.cs` | `.achx` (XmlSerializer) and `.achj` (`ParseJson`/`ParseFrameJson`) parsing, dialect picked by extension in `FromFile` |

