Detector for directional locomotion clip families. It groups AnimEntry clip names by stem+direction, reports incomplete or complete families, chooses center clip and fills a 3x3 blend grid, and emits LocomotionSetSpec entries with unique names when complete.
using System;
using System.Collections.Generic;
namespace HumanoidRetargeter.Target;
/// <summary>
/// Report entry for one directional clip family found by
/// <see cref="LocomotionSetDetector.Detect"/> — emitted on
/// <see cref="RetargetBatchResult.LocomotionSets"/> whether or not a blend node was produced
/// (incomplete families are reported with <see cref="Emitted"/> false and a note).
/// </summary>
public sealed class LocomotionSetReport
{
/// <summary>Common stem of the family's clip names (e.g. <c>Walk</c> for
/// <c>Walk_N</c>…<c>Walk_NW</c>).</summary>
public required string Stem { get; init; }
/// <summary>True when the family was complete (all four cardinals) and a 2DBlend +
/// Folder were emitted into the generated/augmented vmdl.</summary>
public required bool Emitted { get; init; }
/// <summary>Name of the emitted 2DBlend node (collision-suffixed); empty when
/// <see cref="Emitted"/> is false.</summary>
public string BlendNodeName { get; init; } = "";
/// <summary>Name of the emitted Folder node; empty when <see cref="Emitted"/> is false.</summary>
public string FolderName { get; init; } = "";
/// <summary>Clip name per canonical direction token (<c>N</c>, <c>NE</c>, <c>E</c>,
/// <c>SE</c>, <c>S</c>, <c>SW</c>, <c>W</c>, <c>NW</c>); only detected members present.</summary>
public required IReadOnlyDictionary<string, string> Members { get; init; }
/// <summary>Sequence name placed in the blend grid's center cell (an idle clip from the
/// batch when one matched, else the forward member as fallback); null when nothing was
/// emitted.</summary>
public string? CenterClipName { get; init; }
/// <summary>Detection notes: missing cardinals (incomplete family), center-cell
/// fallback, duplicate direction members, diagonal fill-ins.</summary>
public List<string> Notes { get; } = new();
}
/// <summary>
/// Detects directional locomotion families among a batch's converted clip names and shapes
/// them into <see cref="LocomotionSetSpec"/>s replicating the shipped citizen 2DBlend layout.
/// </summary>
/// <remarks>
/// <para><b>Shipped-data findings</b> (dev fixture <c>citizen_animationlist.vmdl_prefab</c>):
/// every citizen locomotion family (<c>Walk</c>, <c>Run</c>, <c>Run2X</c>,
/// <c>CrouchWalk</c>, …) groups eight directional AnimFiles
/// (<c><stem>_N</c>…<c><stem>_NW</c>) under a Folder named by the stem together
/// with a <c>2DBlend</c> node. The 2DBlend holds NO per-child coordinates — it is a dense
/// 3×3 grid: <c>row_pose_param_name = "move_x"</c>, <c>col_pose_param_name = "move_y"</c>,
/// <c>row_weight_list = col_weight_list = [-1.0, 0.0, 1.0]</c>, and
/// <c>blend_anim_list = [[SW, S, SE], [W, idle, E], [NW, N, NE]]</c> — +<c>move_x</c> is
/// forward (N), +<c>move_y</c> is right (E), and the center cell is an idle sequence
/// (<c>IdlePose_Default</c> / <c>CrouchIdlePose_Default</c>). The pose parameters come from
/// the citizen base models' PoseParamList prefab, which Base-Model child vmdls inherit.</para>
/// <para><b>Family matching:</b> a clip joins a family when its name ends in
/// <c>_<token></c> (case-insensitive) where token is a compass direction
/// (<c>N</c>/<c>NE</c>/<c>E</c>/<c>SE</c>/<c>S</c>/<c>SW</c>/<c>W</c>/<c>NW</c>) or a word
/// form (<c>Forward</c>→N, <c>Backward</c>/<c>Back</c>→S, <c>Left</c>→W, <c>Right</c>→E);
/// the part before the separator is the stem. A family is COMPLETE when all four cardinals
/// are present (≥ 4 members); diagonals are optional — a missing diagonal's grid corner
/// reuses the row's cardinal (NE/NW → N, SE/SW → S). The center cell prefers a batch clip
/// named <c><stem>_Idle</c>, <c><stem>Idle</c>, <c><stem></c> or
/// <c>Idle</c> (first match, case-insensitive) and falls back to the forward member.</para>
/// </remarks>
public static class LocomotionSetDetector
{
/// <summary>Canonical direction tokens in grid-independent order (index 0–7).</summary>
private static readonly string[] DirectionTokens = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" };
private const int N = 0, NE = 1, E = 2, SE = 3, S = 4, SW = 5, W = 6, NW = 7;
/// <summary>
/// Scans <paramref name="entries"/> (a batch's successful AnimFile entries, in batch
/// order) for directional families. Complete families yield a
/// <see cref="LocomotionSetSpec"/> whose Folder/2DBlend names are made unique against
/// <paramref name="usedNames"/> (the batch's clip-name collision set; new names are added
/// to it). Families with two or more directional members that are missing a cardinal are
/// reported (not emitted). Single directional clips are ignored as noise.
/// </summary>
public static (List<LocomotionSetSpec> Sets, List<LocomotionSetReport> Reports) Detect(
IReadOnlyList<AnimEntry> entries, ISet<string> usedNames, bool autoSuffixCollisions)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(usedNames);
var sets = new List<LocomotionSetSpec>();
var reports = new List<LocomotionSetReport>();
// ---- group by stem (case-insensitive, first-seen casing wins, batch order kept) ----
var groupIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var groups = new List<(string Stem, AnimEntry?[] Members, List<string> Notes)>();
foreach (var entry in entries)
{
if (ParseDirection(entry.Name) is not { } parsed)
continue;
if (!groupIndex.TryGetValue(parsed.Stem, out var index))
{
index = groups.Count;
groupIndex.Add(parsed.Stem, index);
groups.Add((parsed.Stem, new AnimEntry?[8], new List<string>()));
}
var group = groups[index];
if (group.Members[parsed.Direction] is { } existing)
{
group.Notes.Add(
$"'{entry.Name}' also maps to direction {DirectionTokens[parsed.Direction]} — "
+ $"keeping '{existing.Name}'.");
}
else
{
group.Members[parsed.Direction] = entry;
}
}
foreach (var (stem, members, groupNotes) in groups)
{
var memberCount = 0;
foreach (var member in members)
{
if (member is not null)
memberCount++;
}
if (memberCount < 2)
continue; // a single directional clip is noise, not a family
var report = BuildFamily(stem, members, entries, usedNames, autoSuffixCollisions,
out var set);
report.Notes.AddRange(groupNotes);
reports.Add(report);
if (set is not null)
sets.Add(set);
}
return (sets, reports);
}
/// <summary>
/// Lightweight name-only dry-run of <see cref="Detect"/>: groups
/// <paramref name="clipNames"/> into directional families under the SAME parsing
/// (<c>_N</c>…<c>_NW</c> compass tokens and the Forward/Back(ward)/Left/Right word
/// forms), grouping (case-insensitive stems, first-seen casing/order kept, duplicate
/// directions counted once) and completeness rules (Complete = all four cardinals
/// present). Families with two or more members are listed whether complete or not;
/// single directional clips are ignored as noise. UIs use this to decide whether
/// enabling <see cref="BatchOptions.DetectLocomotionSets"/> could produce anything
/// before any conversion has run.
/// </summary>
public static IReadOnlyList<(string Stem, bool Complete, int MemberCount)> ScanNames(
IEnumerable<string> clipNames)
{
ArgumentNullException.ThrowIfNull(clipNames);
var groupIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var groups = new List<(string Stem, bool[] Members)>();
foreach (var name in clipNames)
{
if (string.IsNullOrEmpty(name) || ParseDirection(name) is not { } parsed)
continue;
if (!groupIndex.TryGetValue(parsed.Stem, out var index))
{
index = groups.Count;
groupIndex.Add(parsed.Stem, index);
groups.Add((parsed.Stem, new bool[8]));
}
groups[index].Members[parsed.Direction] = true;
}
var families = new List<(string Stem, bool Complete, int MemberCount)>();
foreach (var (stem, members) in groups)
{
var memberCount = 0;
foreach (var present in members)
{
if (present)
memberCount++;
}
if (memberCount < 2)
continue; // a single directional clip is noise, not a family
var complete = members[N] && members[E] && members[S] && members[W];
families.Add((stem, complete, memberCount));
}
return families;
}
/// <summary>Builds the report (and, when the family is complete, the emission spec)
/// for one stem group.</summary>
private static LocomotionSetReport BuildFamily(
string stem, AnimEntry?[] members, IReadOnlyList<AnimEntry> entries,
ISet<string> usedNames, bool autoSuffixCollisions, out LocomotionSetSpec? set)
{
var memberMap = new Dictionary<string, string>(StringComparer.Ordinal);
for (var d = 0; d < members.Length; d++)
{
if (members[d] is { } m)
memberMap.Add(DirectionTokens[d], m.Name);
}
var missingCardinals = new List<string>();
foreach (var cardinal in new[] { N, E, S, W })
{
if (members[cardinal] is null)
missingCardinals.Add(DirectionTokens[cardinal]);
}
if (missingCardinals.Count > 0)
{
set = null;
var report = new LocomotionSetReport
{
Stem = stem,
Emitted = false,
Members = memberMap,
};
report.Notes.Add(
$"Locomotion family '{stem}' is incomplete — missing cardinal direction(s) "
+ $"{string.Join(", ", missingCardinals)}; no 2D blend emitted.");
return report;
}
var notes = new List<string>();
// ---- center cell: an idle clip from the batch, else the forward member ----
var center = FindCenterClip(stem, entries);
if (center is null)
{
center = members[N]!.Name;
notes.Add(
$"Locomotion family '{stem}': no idle clip found for the blend center "
+ $"(looked for '{stem}_Idle', '{stem}Idle', '{stem}', 'Idle'); the center "
+ $"cell falls back to '{center}'.");
}
// ---- diagonals: missing corners reuse the row's cardinal (shipped grid layout) ----
string Cell(int diagonal, int rowCardinal)
{
if (members[diagonal] is { } m)
return m.Name;
notes.Add(
$"Locomotion family '{stem}': diagonal {DirectionTokens[diagonal]} missing — "
+ $"grid corner reuses '{members[rowCardinal]!.Name}'.");
return members[rowCardinal]!.Name;
}
// Shipped layout: rows = move_x (-1, 0, +1), cols = move_y (-1, 0, +1);
// row 0 = [SW, S, SE], row 1 = [W, center, E], row 2 = [NW, N, NE].
var grid = new[]
{
new[] { Cell(SW, S), members[S]!.Name, Cell(SE, S) },
new[] { members[W]!.Name, center, members[E]!.Name },
new[] { Cell(NW, N), members[N]!.Name, Cell(NE, N) },
};
var memberNames = new List<string>();
var looping = true;
foreach (var member in members)
{
if (member is null)
continue;
memberNames.Add(member.Name);
looping &= member.Looping;
}
var folderName = UniqueName(stem, usedNames, autoSuffixCollisions);
var blendName = UniqueName(stem + "_2D", usedNames, autoSuffixCollisions);
set = new LocomotionSetSpec
{
FolderName = folderName,
BlendName = blendName,
Looping = looping,
BlendGrid = grid,
MemberNames = memberNames,
};
var emitted = new LocomotionSetReport
{
Stem = stem,
Emitted = true,
BlendNodeName = blendName,
FolderName = folderName,
Members = memberMap,
CenterClipName = center,
};
emitted.Notes.AddRange(notes);
return emitted;
}
/// <summary>The blend center candidates among the batch's clip names, in priority order:
/// <c><stem>_Idle</c>, <c><stem>Idle</c>, <c><stem></c>, <c>Idle</c>
/// (case-insensitive); null when none exists.</summary>
private static string? FindCenterClip(string stem, IReadOnlyList<AnimEntry> entries)
{
foreach (var candidate in new[] { stem + "_Idle", stem + "Idle", stem, "Idle" })
{
foreach (var entry in entries)
{
if (string.Equals(entry.Name, candidate, StringComparison.OrdinalIgnoreCase))
return entry.Name;
}
}
return null;
}
/// <summary>Splits a clip name into stem + direction: the text after the LAST underscore
/// must be a compass token or word form; the (non-empty) text before it is the stem.</summary>
private static (string Stem, int Direction)? ParseDirection(string name)
{
var separator = name.LastIndexOf('_');
if (separator <= 0 || separator == name.Length - 1)
return null;
var token = name[(separator + 1)..];
int? direction = token.ToLowerInvariant() switch
{
"n" or "forward" => N,
"ne" => NE,
"e" or "right" => E,
"se" => SE,
"s" or "backward" or "back" => S,
"sw" => SW,
"w" or "left" => W,
"nw" => NW,
_ => null,
};
if (direction is not { } d)
return null;
return (name[..separator], d);
}
/// <summary>Same collision auto-suffixing the batch applies to clip names
/// (<c>name</c>, <c>name_2</c>, …); registers the result in <paramref name="usedNames"/>.</summary>
private static string UniqueName(string name, ISet<string> usedNames, bool autoSuffix)
{
if (usedNames.Add(name) || !autoSuffix)
return name;
for (var i = 2; ; i++)
{
var candidate = $"{name}_{i}";
if (usedNames.Add(candidate))
return candidate;
}
}
}