diff --git a/Theriapolis.Godot/Scenes/Steps/StepStats.cs b/Theriapolis.Godot/Scenes/Steps/StepStats.cs
new file mode 100644
index 0000000..73ee87b
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Steps/StepStats.cs
@@ -0,0 +1,371 @@
+using Godot;
+using System.Collections.Generic;
+using System.Linq;
+using Theriapolis.GodotHost.Scenes.Widgets;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes.Steps;
+
+///
+/// Step V — Abilities. Direct port of StepStats in
+/// src/steps.jsx per GODOT_PORTING_GUIDE.md §7.4: pool of 6
+/// dice, 6 ability slots, drag-and-drop assignment with pool→slot,
+/// slot→slot (swap), and slot→pool semantics.
+///
+/// Two methods supported (CharacterDraft.StatMethod): "array" uses the
+/// standard array [15, 14, 13, 12, 10, 8]; "roll" generates each pool
+/// value as a 4d6-drop-lowest roll. A Reroll button is exposed in roll
+/// mode. Auto-assign sorts the remaining pool descending and places the
+/// largest values into the empty slots ordered by class primary
+/// abilities first.
+///
+/// The step rebuilds the entire token tree on each Changed signal, so
+/// drag handlers just compute the new state and call Patch — no manual
+/// reparenting.
+///
+public partial class StepStats : VBoxContainer, IStep
+{
+ private static readonly string[] Abilities =
+ { "STR", "DEX", "CON", "INT", "WIS", "CHA" };
+
+ private static readonly System.Random Rng = new();
+
+ private CharacterDraft _draft = null!;
+ private AbilityPool _pool = null!;
+ private readonly Dictionary _slots = new();
+ private Button _arrayBtn = null!;
+ private Button _rollBtn = null!;
+ private Button _rerollBtn = null!;
+ private Button _autoBtn = null!;
+ private ScrollContainer? _scroll;
+ private int _savedScroll = -1;
+ private bool _scrollPending;
+
+ public void Bind(CharacterDraft draft)
+ {
+ _draft = draft;
+ _draft.Changed += Refresh;
+ Build();
+ }
+
+ public string? Validate()
+ {
+ int n = _draft?.StatAssign.Count ?? 0;
+ return n == 6 ? null : $"Assign all six abilities ({n}/6).";
+ }
+
+ private void Build()
+ {
+ AddThemeConstantOverride("separation", 18);
+
+ var intro = new VBoxContainer();
+ intro.AddThemeConstantOverride("separation", 6);
+ AddChild(intro);
+ intro.AddChild(new Label { Text = "FOLIO V · ABILITIES" });
+ intro.AddChild(new Label { Text = "Assign your Ability Scores" });
+ intro.AddChild(new Label
+ {
+ Text = "Pick a method, then drag a value from the pool into one of the six "
+ + "ability slots. Drag between slots to swap, or drag back to the "
+ + "pool to unassign. Auto Assign places the remaining pool into the "
+ + "empty slots, prioritising your calling's primary abilities.",
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ });
+
+ // Method + action toolbar
+ var toolbar = new HBoxContainer();
+ toolbar.AddThemeConstantOverride("separation", 12);
+ AddChild(toolbar);
+
+ _arrayBtn = new Button { Text = "Standard Array" };
+ _arrayBtn.Pressed += () => SwitchMethod("array");
+ toolbar.AddChild(_arrayBtn);
+
+ _rollBtn = new Button { Text = "Roll 4d6 (drop lowest)" };
+ _rollBtn.Pressed += () => SwitchMethod("roll");
+ toolbar.AddChild(_rollBtn);
+
+ var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill };
+ toolbar.AddChild(spacer);
+
+ _rerollBtn = new Button { Text = "Reroll" };
+ _rerollBtn.Pressed += Reroll;
+ toolbar.AddChild(_rerollBtn);
+
+ _autoBtn = new Button { Text = "Auto Assign" };
+ _autoBtn.Pressed += AutoAssign;
+ toolbar.AddChild(_autoBtn);
+
+ var poolBox = new VBoxContainer();
+ poolBox.AddThemeConstantOverride("separation", 6);
+ AddChild(poolBox);
+ poolBox.AddChild(new Label { Text = "AVAILABLE" });
+ _pool = new AbilityPool();
+ _pool.Dropped += OnPoolDropped;
+ poolBox.AddChild(_pool);
+
+ var slotsBox = new VBoxContainer();
+ slotsBox.AddThemeConstantOverride("separation", 8);
+ AddChild(slotsBox);
+ slotsBox.AddChild(new Label { Text = "ABILITIES" });
+
+ foreach (var ab in Abilities)
+ {
+ string captured = ab; // capture for the closure below
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", 12);
+ slotsBox.AddChild(row);
+
+ row.AddChild(new Label
+ {
+ Text = captured,
+ CustomMinimumSize = new Vector2(64, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var slot = new AbilitySlot { Ability = captured };
+ slot.Dropped += (Godot.Collections.Dictionary payload)
+ => HandleSlotDrop(payload, captured);
+ _slots[captured] = slot;
+ row.AddChild(slot);
+ }
+
+ Refresh();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Refresh — rebuild token children from CharacterDraft state.
+
+ private void Refresh()
+ {
+ if (_pool is null) return;
+
+ // Tearing down + rebuilding child token trees triggers a layout
+ // pass that resets the parent ScrollContainer's vertical scroll.
+ // Snapshot the scroll position and restore it after the new layout
+ // settles. The restore goes through a method (not SetDeferred on
+ // the property) so it runs reliably after layout finishes — the
+ // method is queued via CallDeferred at the end of Refresh.
+ _scroll = FindAncestorScroll();
+ _savedScroll = _scroll?.ScrollVertical ?? -1;
+
+ // Toolbar reflects current method and assignment state.
+ bool isRoll = _draft.StatMethod == "roll";
+ _rerollBtn.Visible = isRoll;
+ _arrayBtn.Disabled = !isRoll; // greyed when already on Array
+ _rollBtn.Disabled = isRoll; // greyed when already on Roll
+ _autoBtn.Disabled = _draft.StatPool.Count == 0;
+
+ // Pool: one token per StatPool entry. Free() (synchronous) so old
+ // children disappear *before* AddChild runs and the layout pass
+ // doesn't see a transient doubled-content state.
+ foreach (var c in _pool.GetChildren()) c.Free();
+ for (int i = 0; i < _draft.StatPool.Count; i++)
+ {
+ var token = new AbilityToken
+ {
+ Value = _draft.StatPool[i],
+ Origin = "pool",
+ OriginPoolIdx = i,
+ };
+ _pool.AddChild(token);
+ }
+
+ // Slots: token if assigned, otherwise dash placeholder.
+ foreach (var (ab, slot) in _slots)
+ {
+ foreach (var c in slot.GetChildren()) c.Free();
+ if (_draft.StatAssign.ContainsKey(ab))
+ {
+ int v = (int)_draft.StatAssign[ab];
+ var token = new AbilityToken
+ {
+ Value = v,
+ Origin = "slot",
+ OriginAbility = ab,
+ };
+ slot.AddChild(token);
+ }
+ else
+ {
+ slot.AddChild(new Label
+ {
+ Text = "—",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ }
+ }
+
+ // Restore scroll on the next _Process frame — after layout has fully
+ // converged. CallDeferred / SetDeferred / CreateTimer all proved
+ // racy because the ScrollContainer re-clamps scroll on later layout
+ // passes triggered by tree mutations elsewhere.
+ if (_savedScroll >= 0 && _scroll is not null) _scrollPending = true;
+ }
+
+ public override void _Process(double delta)
+ {
+ if (_scrollPending && _scroll is not null && IsInstanceValid(_scroll))
+ {
+ _scroll.ScrollVertical = _savedScroll;
+ }
+ _scrollPending = false;
+ }
+
+ private ScrollContainer? FindAncestorScroll()
+ {
+ Node? n = GetParent();
+ while (n is not null)
+ {
+ if (n is ScrollContainer sc) return sc;
+ n = n.GetParent();
+ }
+ return null;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Drop handlers — compute new state and Patch.
+ // Mirrors handleDrop / dropToPool in steps.jsx exactly.
+
+ private void HandleSlotDrop(Godot.Collections.Dictionary payload, string destAbility)
+ {
+ var pool = _draft.StatPool.Duplicate();
+ var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate();
+
+ string from = payload["from"].AsString();
+ int value = payload["value"].AsInt32();
+
+ if (from == "pool")
+ {
+ // pool → slot
+ int idx = payload["idx"].AsInt32();
+ if (assign.ContainsKey(destAbility))
+ {
+ // Bump the existing slot value back to the pool.
+ pool.Add((int)assign[destAbility]);
+ }
+ if (idx >= 0 && idx < pool.Count) pool.RemoveAt(idx);
+ assign[destAbility] = value;
+ }
+ else if (from == "slot")
+ {
+ string srcAbility = payload["ability"].AsString();
+ if (srcAbility == destAbility) return;
+ // Swap if dest is filled, otherwise move.
+ if (assign.ContainsKey(destAbility))
+ {
+ var dstV = assign[destAbility];
+ assign[srcAbility] = dstV;
+ }
+ else
+ {
+ assign.Remove(srcAbility);
+ }
+ assign[destAbility] = value;
+ }
+
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "stat_pool", pool },
+ { "stat_assign", assign },
+ });
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Method switch + roll + auto-assign
+
+ private void SwitchMethod(string method)
+ {
+ var pool = method == "roll" ? RollSixSet() : ArrayPool();
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "stat_method", method },
+ { "stat_pool", pool },
+ { "stat_assign", new Godot.Collections.Dictionary() },
+ });
+ }
+
+ private void Reroll()
+ {
+ if (_draft.StatMethod != "roll") return;
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "stat_pool", RollSixSet() },
+ { "stat_assign", new Godot.Collections.Dictionary() },
+ });
+ }
+
+ private void AutoAssign()
+ {
+ if (_draft.StatPool.Count == 0) return;
+
+ // Empty slots, ordered by class primary abilities first.
+ var cls = CodexContent.Class(_draft.ClassId);
+ var primary = cls?.PrimaryAbility ?? System.Array.Empty();
+ var emptyAbilities = Abilities
+ .Where(a => !_draft.StatAssign.ContainsKey(a))
+ .OrderBy(a =>
+ {
+ int idx = System.Array.IndexOf(primary, a);
+ return idx < 0 ? 99 : idx;
+ })
+ .ToList();
+
+ // Pool sorted descending — biggest values go to highest priority.
+ var sortedPool = _draft.StatPool.OrderByDescending(v => v).ToList();
+ var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate();
+ var newPool = new Godot.Collections.Array();
+
+ for (int i = 0; i < sortedPool.Count; i++)
+ {
+ if (i < emptyAbilities.Count) assign[emptyAbilities[i]] = sortedPool[i];
+ else newPool.Add(sortedPool[i]);
+ }
+
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "stat_pool", newPool },
+ { "stat_assign", assign },
+ });
+ }
+
+ private static Godot.Collections.Array ArrayPool() =>
+ new() { 15, 14, 13, 12, 10, 8 };
+
+ private static Godot.Collections.Array RollSixSet()
+ {
+ var pool = new Godot.Collections.Array();
+ for (int i = 0; i < 6; i++) pool.Add(RollFourDropLowest());
+ return pool;
+ }
+
+ private static int RollFourDropLowest()
+ {
+ // 4d6, drop lowest, sum the rest. Range [3, 18].
+ int[] rolls = new int[4];
+ for (int i = 0; i < 4; i++) rolls[i] = Rng.Next(1, 7);
+ System.Array.Sort(rolls);
+ return rolls[1] + rolls[2] + rolls[3];
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+
+ private void OnPoolDropped(Godot.Collections.Dictionary payload)
+ {
+ if (payload["from"].AsString() != "slot") return;
+ var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate();
+ var pool = _draft.StatPool.Duplicate();
+ string ability = payload["ability"].AsString();
+ if (!assign.ContainsKey(ability)) return;
+ int v = (int)assign[ability];
+ assign.Remove(ability);
+ pool.Add(v);
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "stat_pool", pool },
+ { "stat_assign", assign },
+ });
+ }
+
+}
diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs b/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs
new file mode 100644
index 0000000..c7da589
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs
@@ -0,0 +1,37 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Scenes.Widgets;
+
+///
+/// Container for unassigned AbilityToken children. Per
+/// GODOT_PORTING_GUIDE.md §7.3, accepts only slot→pool drops (returning
+/// an assigned value to the pool); pool→pool drops are no-ops. The step
+/// consumes and mutates CharacterDraft.StatPool /
+/// StatAssign in one call so the inevitable refresh re-creates tokens
+/// in the right places.
+///
+public partial class AbilityPool : HBoxContainer
+{
+ [Signal] public delegate void DroppedEventHandler(Godot.Collections.Dictionary payload);
+
+ public override void _Ready()
+ {
+ AddThemeConstantOverride("separation", 8);
+ MouseFilter = MouseFilterEnum.Stop;
+ CustomMinimumSize = new Vector2(0, 64);
+ }
+
+ public override bool _CanDropData(Vector2 atPosition, Variant data)
+ {
+ if (data.VariantType != Variant.Type.Dictionary) return false;
+ var d = data.AsGodotDictionary();
+ if (!d.ContainsKey("kind") || d["kind"].AsString() != "ability_value") return false;
+ // Only slot→pool drops are meaningful; pool→pool is a no-op.
+ return d.ContainsKey("from") && d["from"].AsString() == "slot";
+ }
+
+ public override void _DropData(Vector2 atPosition, Variant data)
+ {
+ EmitSignal(SignalName.Dropped, data);
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs
new file mode 100644
index 0000000..b5e3892
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs
@@ -0,0 +1,40 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Scenes.Widgets;
+
+///
+/// Drop target for ability assignment. Per GODOT_PORTING_GUIDE.md §7.2:
+/// accepts any drag payload tagged "ability_value" and emits
+/// with the payload so the step orchestrates the
+/// state change centrally. Visual content (the AbilityToken when filled,
+/// or a placeholder dash when empty) is set by the step on every refresh.
+///
+/// Each slot owns exactly one ability id (STR/DEX/CON/INT/WIS/CHA). On
+/// click of a filled slot, returns the value to the pool (synthesised
+/// slot→pool payload) — matches the React prototype's "click to unbind"
+/// affordance from steps.jsx.
+///
+public partial class AbilitySlot : PanelContainer
+{
+ [Signal] public delegate void DroppedEventHandler(Godot.Collections.Dictionary payload);
+
+ [Export] public string Ability { get; set; } = "STR";
+
+ public override void _Ready()
+ {
+ CustomMinimumSize = new Vector2(56, 56);
+ MouseFilter = MouseFilterEnum.Stop;
+ }
+
+ public override bool _CanDropData(Vector2 atPosition, Variant data)
+ {
+ if (data.VariantType != Variant.Type.Dictionary) return false;
+ var d = data.AsGodotDictionary();
+ return d.ContainsKey("kind") && d["kind"].AsString() == "ability_value";
+ }
+
+ public override void _DropData(Vector2 atPosition, Variant data)
+ {
+ EmitSignal(SignalName.Dropped, data);
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs
new file mode 100644
index 0000000..e3bcd63
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs
@@ -0,0 +1,71 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Scenes.Widgets;
+
+///
+/// Draggable ability-score token. Replaces the React prototype's
+/// .die + filled .slot in steps.jsx per
+/// GODOT_PORTING_GUIDE.md §7.1. Shows the int value as a centered
+/// label; _GetDragData returns a Dictionary payload describing
+/// where the token came from so the step can mutate CharacterDraft
+/// accordingly.
+///
+/// Tokens are owned by either an AbilityPool (Origin == "pool",
+/// OriginPoolIdx set) or an AbilitySlot (Origin == "slot",
+/// OriginAbility set). Dropping a token rebuilds the StepStats UI from
+/// the new draft state — token instances are short-lived.
+///
+public partial class AbilityToken : PanelContainer
+{
+ [Export] public int Value { get; set; }
+ [Export] public string Origin { get; set; } = "pool";
+ [Export] public string OriginAbility { get; set; } = "";
+ [Export] public int OriginPoolIdx { get; set; } = -1;
+
+ public override void _Ready()
+ {
+ CustomMinimumSize = new Vector2(56, 56);
+ // PASS so clicks propagate up to the parent AbilitySlot's GuiInput
+ // handler (click-to-return). Drag detection still triggers on the
+ // deepest non-IGNORE Control under the cursor, so PASS works for
+ // _GetDragData too.
+ MouseFilter = MouseFilterEnum.Pass;
+
+ var label = new Label
+ {
+ Text = Value.ToString(),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ label.AddThemeFontSizeOverride("font_size", 22);
+ AddChild(label);
+ }
+
+ public override Variant _GetDragData(Vector2 atPosition)
+ {
+ // Drag preview — a dimmed duplicate of the token.
+ var preview = new Panel { CustomMinimumSize = new Vector2(56, 56) };
+ var lbl = new Label
+ {
+ Text = Value.ToString(),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ lbl.AddThemeFontSizeOverride("font_size", 22);
+ lbl.AnchorRight = 1f;
+ lbl.AnchorBottom = 1f;
+ preview.AddChild(lbl);
+ preview.Modulate = new Color(1, 1, 1, 0.85f);
+ SetDragPreview(preview);
+
+ return new Godot.Collections.Dictionary
+ {
+ { "kind", "ability_value" },
+ { "value", Value },
+ { "from", Origin },
+ { "ability", OriginAbility },
+ { "idx", OriginPoolIdx },
+ };
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs
index 2e3dfc3..4a273f1 100644
--- a/Theriapolis.Godot/Scenes/Wizard.cs
+++ b/Theriapolis.Godot/Scenes/Wizard.cs
@@ -41,7 +41,7 @@ public partial class Wizard : Control
null, // 2 Calling
null, // 3 Subclass
null, // 4 History
- null, // 5 Abilities
+ typeof(Steps.StepStats), // 5 Abilities — implemented (M6.2)
null, // 6 Skills
null, // 7 Sign
};