diff --git a/AssetEditor.sln b/AssetEditor.sln
index f32c46102..ac9a5f78b 100644
--- a/AssetEditor.sln
+++ b/AssetEditor.sln
@@ -134,6 +134,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.CampaignAnimationCreat
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.CoreLog", "Shared\Logging\Shared.Core.Logging\Shared.CoreLog.csproj", "{C79EDB14-ED00-4474-B59F-9EED0AF39459}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CampaignAnimationSetEditor", "CampaignAnimationSetEditor", "{AA14F125-E7D2-8CBC-FC94-A1A6F78D9FAF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Editors.CampaignAnimationSetEditor", "Editors\CampaignAnimationSetEditor\Editors.CampaignAnimationSetEditor\Editors.CampaignAnimationSetEditor.csproj", "{417FAB39-3269-4D1C-981B-DD9965E1B26C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.CampaignAnimationSetEditor", "Editors\CampaignAnimationSetEditor\Test.CampaignAnimationSetEditor\Test.CampaignAnimationSetEditor.csproj", "{32582784-3E26-44C1-9375-ECE044B4CB4A}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -648,6 +654,30 @@ Global
{C79EDB14-ED00-4474-B59F-9EED0AF39459}.Release|x64.Build.0 = Release|Any CPU
{C79EDB14-ED00-4474-B59F-9EED0AF39459}.Release|x86.ActiveCfg = Release|Any CPU
{C79EDB14-ED00-4474-B59F-9EED0AF39459}.Release|x86.Build.0 = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|x64.Build.0 = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Debug|x86.Build.0 = Debug|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|x64.ActiveCfg = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|x64.Build.0 = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|x86.ActiveCfg = Release|Any CPU
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C}.Release|x86.Build.0 = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|x64.Build.0 = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Debug|x86.Build.0 = Debug|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|x64.ActiveCfg = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|x64.Build.0 = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|x86.ActiveCfg = Release|Any CPU
+ {32582784-3E26-44C1-9375-ECE044B4CB4A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -710,6 +740,9 @@ Global
{438E6F1B-F5A9-49F3-8516-CB5E9D316C81} = {ED432A81-F5C7-0469-2ED7-3AF47D036060}
{91617891-AD16-423F-B56F-AC0D2CE82B95} = {ED432A81-F5C7-0469-2ED7-3AF47D036060}
{C79EDB14-ED00-4474-B59F-9EED0AF39459} = {58F19D12-FE6E-A99A-941B-96C21567964E}
+ {AA14F125-E7D2-8CBC-FC94-A1A6F78D9FAF} = {07AC615B-A8FC-4E1A-BDD5-BC11452429A0}
+ {417FAB39-3269-4D1C-981B-DD9965E1B26C} = {AA14F125-E7D2-8CBC-FC94-A1A6F78D9FAF}
+ {32582784-3E26-44C1-9375-ECE044B4CB4A} = {AA14F125-E7D2-8CBC-FC94-A1A6F78D9FAF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB5968F3-98ED-4AFF-98EA-0DBEDCACADF2}
diff --git a/AssetEditor/AssetEditor.csproj b/AssetEditor/AssetEditor.csproj
index 4bba02b08..beb03a165 100644
--- a/AssetEditor/AssetEditor.csproj
+++ b/AssetEditor/AssetEditor.csproj
@@ -30,6 +30,7 @@
+
diff --git a/AssetEditor/Services/DependencyInjectionConfig.cs b/AssetEditor/Services/DependencyInjectionConfig.cs
index ebb066dbf..3483c2eef 100644
--- a/AssetEditor/Services/DependencyInjectionConfig.cs
+++ b/AssetEditor/Services/DependencyInjectionConfig.cs
@@ -37,6 +37,7 @@ public DependencyInjectionConfig(bool loadResources = true)
new Editors.Ipc.DependencyInjectionContainer(),
new Editors.BmdEditor.DependencyInjectionContainer(),
new Editors.CscEditor.DependencyInjectionContainer(),
+ new Editors.CampaignAnimationSetEditor.DependencyInjectionContainer(),
// Host application
new DependencyInjectionContainer(),
diff --git a/AssetEditor/Themes/Controls.xaml b/AssetEditor/Themes/Controls.xaml
index faccf006f..ba3a43a87 100644
--- a/AssetEditor/Themes/Controls.xaml
+++ b/AssetEditor/Themes/Controls.xaml
@@ -3587,7 +3587,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/CampaignAnimationSetEditorView.xaml.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/CampaignAnimationSetEditorView.xaml.cs
new file mode 100644
index 000000000..46aefcfd8
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/CampaignAnimationSetEditorView.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows.Controls;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ public partial class CampaignAnimationSetEditorView : UserControl
+ {
+ public CampaignAnimationSetEditorView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml
new file mode 100644
index 000000000..51f91d90e
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml.cs
new file mode 100644
index 000000000..8b23e9a0e
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/OpenCampaignBinWindow.xaml.cs
@@ -0,0 +1,24 @@
+using System.Windows;
+using System.Windows.Input;
+using Editors.CampaignAnimationSetEditor.ViewModels;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ public partial class OpenCampaignBinWindow : Window
+ {
+ public OpenCampaignBinWindow()
+ {
+ InitializeComponent();
+ }
+
+ void Ok_Click(object sender, RoutedEventArgs e) => Accept();
+
+ void FileListBox_MouseDoubleClick(object sender, MouseButtonEventArgs e) => Accept();
+
+ void Accept()
+ {
+ if (DataContext is OpenCampaignBinViewModel { SelectedFile: not null })
+ DialogResult = true;
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PathTailConverter.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PathTailConverter.cs
new file mode 100644
index 000000000..8aceb577f
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PathTailConverter.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ /// Shows the tail of a long path (the filename, the meaningful part) rather than
+ /// trimming the end as WPF does by default. Applied through DataGridTemplateColumn's
+ /// CellTemplate, never CellEditingTemplate - DataGridTextColumn shares one binding between its
+ /// read-only and editing views, so shortening there would corrupt the value being edited.
+ public class PathTailConverter : IValueConverter
+ {
+ const int MaxLength = 55;
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var text = value as string ?? "";
+ if (text.Length <= MaxLength)
+ return text;
+
+ return "…" + text[(text.Length - (MaxLength - 1))..];
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PoseIdDisplayConverter.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PoseIdDisplayConverter.cs
new file mode 100644
index 000000000..4f3552c46
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/PoseIdDisplayConverter.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+using Editors.CampaignAnimationSetEditor.Services;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ /// Shows the known pose name for a PoseId, falling back to the raw number for the rare
+ /// vanilla outlier values that don't fit the confirmed KnownPoseId scheme.
+ public class PoseIdDisplayConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is int poseId && Enum.IsDefined(typeof(KnownPoseId), poseId))
+ return ((KnownPoseId)poseId).ToString();
+
+ return value?.ToString() ?? "";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml
new file mode 100644
index 000000000..c6423687a
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml.cs
new file mode 100644
index 000000000..aeead0bc8
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/SaveCampaignBinAsWindow.xaml.cs
@@ -0,0 +1,19 @@
+using System.Windows;
+using Editors.CampaignAnimationSetEditor.ViewModels;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ public partial class SaveCampaignBinAsWindow : Window
+ {
+ public SaveCampaignBinAsWindow()
+ {
+ InitializeComponent();
+ }
+
+ void Ok_Click(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is SaveCampaignBinAsViewModel vm && !string.IsNullOrWhiteSpace(vm.FileName))
+ DialogResult = true;
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/StatusDescriptionConverter.cs b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/StatusDescriptionConverter.cs
new file mode 100644
index 000000000..eea93b64f
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Editors.CampaignAnimationSetEditor/Views/StatusDescriptionConverter.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+using Editors.CampaignAnimationSetEditor.Services;
+
+namespace Editors.CampaignAnimationSetEditor.Views
+{
+ /// Explains what a status name means, based on .
+ /// Anything the survey didn't confirm is worded as such rather than stated as fact.
+ public class StatusDescriptionConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var name = value as string ?? "";
+ return Enum.TryParse(name, out var known)
+ ? known.Describe()
+ : KnownCampaignStatusExtensions.UnknownStatusDescription;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationBinRoundTripTests.cs b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationBinRoundTripTests.cs
new file mode 100644
index 000000000..190ecfd57
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationBinRoundTripTests.cs
@@ -0,0 +1,113 @@
+using System.IO;
+using System.Linq;
+using Shared.ByteParsing;
+using Shared.GameFormats.AnimationPack;
+
+namespace Test.CampaignAnimationSetEditor
+{
+ /// Round-trip tests for the binary campaign animation set format, using real vanilla
+ /// WH3 files as fixtures. This format's load/write code previously had no test coverage.
+ [TestFixture]
+ public class CampaignAnimationBinRoundTripTests
+ {
+ static byte[] LoadFixture(string name) => File.ReadAllBytes(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", name));
+
+ [TestCase("cam_bi2_dlc27_preyton_boss.bin")]
+ [TestCase("cam_hu6_gor_axe.bin")]
+ public void Load_then_write_then_reload_preserves_structure(string fixtureName)
+ {
+ var originalBytes = LoadFixture(fixtureName);
+ var bin = CampaignAnimationBinLoader.Load(new ByteChunk(originalBytes));
+
+ var reference = Path.GetFileNameWithoutExtension(fixtureName);
+ var writtenBytes = CampaignAnimationBinLoader.Write(bin, reference);
+ var reloaded = CampaignAnimationBinLoader.Load(new ByteChunk(writtenBytes));
+
+ Assert.That(reloaded.Version, Is.EqualTo(bin.Version));
+ Assert.That(reloaded.Reference, Is.EqualTo(reference));
+ Assert.That(reloaded.SkeletonName, Is.EqualTo(bin.SkeletonName));
+ Assert.That(reloaded.Status, Has.Count.EqualTo(bin.Status.Count));
+
+ for (var i = 0; i < bin.Status.Count; i++)
+ {
+ Assert.That(reloaded.Status[i].Name, Is.EqualTo(bin.Status[i].Name), $"status[{i}].Name");
+
+ if (bin.Status[i].Name == "global")
+ {
+ Assert.That(reloaded.Status[i].Docks?.Count ?? 0, Is.EqualTo(bin.Status[i].Docks?.Count ?? 0), $"status[{i}].Docks");
+ Assert.That(reloaded.Status[i].Poses?.Count ?? 0, Is.EqualTo(bin.Status[i].Poses?.Count ?? 0), $"status[{i}].Poses");
+ }
+ else
+ {
+ Assert.That(reloaded.Status[i].Idle?.Count ?? 0, Is.EqualTo(bin.Status[i].Idle?.Count ?? 0), $"status[{i}].Idle");
+ Assert.That(reloaded.Status[i].Locomotion?.Count ?? 0, Is.EqualTo(bin.Status[i].Locomotion?.Count ?? 0), $"status[{i}].Locomotion");
+ Assert.That(reloaded.Status[i].Action?.Count ?? 0, Is.EqualTo(bin.Status[i].Action?.Count ?? 0), $"status[{i}].Action");
+ }
+ }
+ }
+
+ [Test]
+ public void Preyton_fixture_has_the_expected_status_normal_locomotion_entries()
+ {
+ var bin = CampaignAnimationBinLoader.Load(new ByteChunk(LoadFixture("cam_bi2_dlc27_preyton_boss.bin")));
+
+ var statusNormal = bin.Status.Single(x => x.Name == "status_normal");
+ Assert.That(statusNormal.Locomotion, Has.Count.EqualTo(2));
+ Assert.That(statusNormal.Locomotion!.Select(x => x.Animation), Has.All.Contains("bird02"));
+ }
+
+ [Test]
+ public void Gor_axe_fixture_has_a_global_status_with_docks()
+ {
+ var bin = CampaignAnimationBinLoader.Load(new ByteChunk(LoadFixture("cam_hu6_gor_axe.bin")));
+
+ var global = bin.Status.SingleOrDefault(x => x.Name == "global");
+ Assert.That(global, Is.Not.Null);
+ Assert.That(global!.Docks, Is.Not.Null.And.Not.Empty);
+ }
+
+ /// Regression coverage for a field-order bug in the original parser: PersistentMeta,
+ /// PersistentMeta_Pose/_Dock, LocomotionEntry, TransitionEntry and UnknownEntry all read
+ /// (Animation, AnimationMeta, SoundMeta, Type, ...) when the on-disk order is (Animation,
+ /// Type, AnimationMeta, SoundMeta, ...), so meta paths landed in the wrong properties.
+ /// PersistentMeta_Pose/_Dock also had no real "Weight" field - it was the 4th string
+ /// (SoundMeta) misread as a float.
+ [Test]
+ public void Gor_axe_fixture_global_entries_decode_with_correct_field_order()
+ {
+ var bin = CampaignAnimationBinLoader.Load(new ByteChunk(LoadFixture("cam_hu6_gor_axe.bin")));
+ var global = bin.Status.Single(x => x.Name == "global");
+
+ var meta = global.PersitantMetaData!.Single();
+ Assert.That(meta.Type, Is.EqualTo("global"));
+ Assert.That(meta.AnimationMeta, Does.EndWith(".anm.meta"));
+
+ var pose = global.Poses!.Single();
+ Assert.That(pose.Type, Is.EqualTo("global"));
+ Assert.That(pose.PoseId, Is.EqualTo(3));
+
+ foreach (var dock in global.Docks!)
+ {
+ Assert.That(dock.Type, Is.EqualTo("global"));
+ Assert.That(dock.Dock, Does.StartWith("DOCK_EQPT_"));
+ }
+ }
+
+ [Test]
+ public void Preyton_fixture_locomotion_and_action_entries_decode_with_correct_field_order()
+ {
+ var bin = CampaignAnimationBinLoader.Load(new ByteChunk(LoadFixture("cam_bi2_dlc27_preyton_boss.bin")));
+ var statusNormal = bin.Status.Single(x => x.Name == "status_normal");
+
+ foreach (var locomotion in statusNormal.Locomotion!)
+ {
+ Assert.That(locomotion.Type, Is.EqualTo("status_normal"));
+ Assert.That(locomotion.SoundMeta, Is.Empty.Or.EndsWith(".snd.meta"));
+ }
+
+ var statusBattle = bin.Status.Single(x => x.Name == "status_battle");
+ foreach (var action in statusBattle.Action!)
+ Assert.That(action.Type, Is.EqualTo("status_normal"));
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationSetGeneratorServiceTests.cs b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationSetGeneratorServiceTests.cs
new file mode 100644
index 000000000..7d2e743a1
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/CampaignAnimationSetGeneratorServiceTests.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using Editors.CampaignAnimationSetEditor.Services;
+using Shared.GameFormats.AnimationPack;
+
+namespace Test.CampaignAnimationSetEditor
+{
+ [TestFixture]
+ public class CampaignAnimationSetGeneratorServiceTests
+ {
+ static AnimationBinEntryGenericFormat Entry(string slotName, string animationFile) => new()
+ {
+ SlotName = slotName,
+ AnimationFile = animationFile,
+ };
+
+ [Test]
+ public void FindEntry_PrefersExactNameOverPrefixMatch()
+ {
+ var entries = new List
+ {
+ Entry("STAND_IDLE_3", "idle3.anim"),
+ Entry("STAND_IDLE_1", "idle1.anim"),
+ };
+
+ var result = CampaignAnimationSetGeneratorService.FindEntry(entries, ["STAND_IDLE_1"], ["STAND_IDLE_"]);
+
+ Assert.That(result!.AnimationFile, Is.EqualTo("idle1.anim"));
+ }
+
+ [Test]
+ public void FindEntry_FallsBackToPrefixWhenNoExactMatch()
+ {
+ var entries = new List
+ {
+ Entry("STAND_IDLE_7", "idle7.anim"),
+ };
+
+ var result = CampaignAnimationSetGeneratorService.FindEntry(entries, ["STAND_IDLE_1"], ["STAND_IDLE_"]);
+
+ Assert.That(result!.AnimationFile, Is.EqualTo("idle7.anim"));
+ }
+
+ [Test]
+ public void FindEntry_ReturnsNullWhenNothingMatches()
+ {
+ var entries = new List { Entry("RUN_1", "run.anim") };
+
+ var result = CampaignAnimationSetGeneratorService.FindEntry(entries, ["STAND_IDLE_1"], ["STAND_IDLE_"]);
+
+ Assert.That(result, Is.Null);
+ }
+
+ [TestCase("animations/battle/bird02/locomotion/bi2_walk_01.anim", "animations/battle/bird02/locomotion/campaign/cam_bi2_walk_01.anim")]
+ [TestCase("animations/battle/humanoid01d/spear_and_shield/hu1d_sps_run_01.anim", "animations/battle/humanoid01d/spear_and_shield/campaign/cam_hu1d_sps_run_01.anim")]
+ public void BuildCampaignAnimationPath_InsertsCampaignFolderAndCamPrefix(string source, string expected)
+ {
+ var result = CampaignAnimationSetGeneratorService.BuildCampaignAnimationPath(source);
+
+ Assert.That(result, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void BuildCampaignAnimationPath_DoesNotDoublePrefixAnAlreadyCamNamedFile()
+ {
+ var result = CampaignAnimationSetGeneratorService.BuildCampaignAnimationPath("animations/battle/bird02/locomotion/cam_bi2_walk_01.anim");
+
+ Assert.That(result, Is.EqualTo("animations/battle/bird02/locomotion/campaign/cam_bi2_walk_01.anim"));
+ }
+
+ [Test]
+ public void IsFlyingMoveset_TrueWhenGroundFlyStandPresent()
+ {
+ var entries = new List { Entry("FLY_STAND", "fly_stand.anim") };
+
+ Assert.That(CampaignAnimationSetGeneratorService.IsFlyingMoveset(entries, ""), Is.True);
+ }
+
+ [Test]
+ public void IsFlyingMoveset_TrueWhenRiderFlyStandPresent()
+ {
+ var entries = new List { Entry("RIDER_FLY_STAND", "rider_fly_stand.anim") };
+
+ Assert.That(CampaignAnimationSetGeneratorService.IsFlyingMoveset(entries, "RIDER_"), Is.True);
+ }
+
+ [Test]
+ public void IsFlyingMoveset_FalseWhenOnlyOtherFlySlotsPresentWithoutFlyStand()
+ {
+ // FLY_COMBAT_IDLE_1/FLY_WALK_1 without FLY_STAND itself shouldn't count - FLY_STAND is
+ // the one slot the fallback logic depends on actually existing.
+ var entries = new List
+ {
+ Entry("FLY_COMBAT_IDLE_1", "fly_combat_idle.anim"),
+ Entry("FLY_WALK_1", "fly_walk.anim"),
+ };
+
+ Assert.That(CampaignAnimationSetGeneratorService.IsFlyingMoveset(entries, ""), Is.False);
+ }
+
+ [Test]
+ public void IsFlyingMoveset_FalseForGroundFlyStandWhenCheckingRiderPrefix()
+ {
+ // A ground FLY_STAND shouldn't count when checking under the rider prefix - detection
+ // must be scoped to the correct skeleton context.
+ var entries = new List { Entry("FLY_STAND", "fly_stand.anim") };
+
+ Assert.That(CampaignAnimationSetGeneratorService.IsFlyingMoveset(entries, "RIDER_"), Is.False);
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_bi2_dlc27_preyton_boss.bin b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_bi2_dlc27_preyton_boss.bin
new file mode 100644
index 000000000..ced0899f1
Binary files /dev/null and b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_bi2_dlc27_preyton_boss.bin differ
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_hu6_gor_axe.bin b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_hu6_gor_axe.bin
new file mode 100644
index 000000000..830e14722
Binary files /dev/null and b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Fixtures/cam_hu6_gor_axe.bin differ
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/FreezeRootBoneCommandTests.cs b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/FreezeRootBoneCommandTests.cs
new file mode 100644
index 000000000..2bf5dc014
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/FreezeRootBoneCommandTests.cs
@@ -0,0 +1,102 @@
+using Editors.CampaignAnimationSetEditor.Services;
+using GameWorld.Core.Animation;
+using Microsoft.Xna.Framework;
+using Moq;
+using Shared.Core.Services;
+
+namespace Test.CampaignAnimationSetEditor
+{
+ [TestFixture]
+ public class FreezeRootBoneCommandTests
+ {
+ [Test]
+ public void Execute_NoAnimation_ShowsDialogAndReturnsFalse()
+ {
+ var dialogs = new Mock();
+ var command = new FreezeRootBoneCommand(dialogs.Object);
+
+ var result = command.Execute(null, rootBoneIndex: 0, rootBoneName: "animroot", out var convertedAnimation);
+
+ Assert.That(result, Is.False);
+ Assert.That(convertedAnimation, Is.Null);
+ dialogs.Verify(x => x.ShowDialogBox("Unable to convert animation - No animation selected"), Times.Once);
+ }
+
+ [Test]
+ public void Execute_NoFrames_ShowsDialogAndReturnsFalse()
+ {
+ var dialogs = new Mock();
+ var command = new FreezeRootBoneCommand(dialogs.Object);
+ var sourceAnimation = new AnimationClip { PlayTimeInSec = 0.1f };
+
+ var result = command.Execute(sourceAnimation, rootBoneIndex: 0, rootBoneName: "animroot", out var convertedAnimation);
+
+ Assert.That(result, Is.False);
+ Assert.That(convertedAnimation, Is.Null);
+ dialogs.Verify(x => x.ShowDialogBox("Unable to convert animation - Animation has no frames"), Times.Once);
+ }
+
+ [Test]
+ public void Execute_BoneIndexOutOfRange_ShowsDialogAndReturnsFalse()
+ {
+ var dialogs = new Mock();
+ var command = new FreezeRootBoneCommand(dialogs.Object);
+ var sourceAnimation = CreateAnimationClip(boneCount: 1, frameCount: 1);
+
+ var result = command.Execute(sourceAnimation, rootBoneIndex: 1, rootBoneName: "animroot", out var convertedAnimation);
+
+ Assert.That(result, Is.False);
+ Assert.That(convertedAnimation, Is.Null);
+ dialogs.Verify(x => x.ShowDialogBox("Unable to convert animation - Bone index 1 is out of range for frame 0"), Times.Once);
+ }
+
+ [Test]
+ public void Execute_ValidAnimation_ClonesAndResetsSelectedBoneAcrossAllFrames()
+ {
+ var dialogs = new Mock();
+ var command = new FreezeRootBoneCommand(dialogs.Object);
+ var sourceAnimation = CreateAnimationClip();
+ var originalAnimation = sourceAnimation.Clone();
+
+ var result = command.Execute(sourceAnimation, rootBoneIndex: 1, rootBoneName: "animroot", out var convertedAnimation);
+
+ Assert.That(result, Is.True);
+ Assert.That(convertedAnimation, Is.Not.Null);
+ Assert.That(convertedAnimation, Is.Not.SameAs(sourceAnimation));
+ dialogs.Verify(x => x.ShowDialogBox(It.IsAny()), Times.Never);
+
+ for (var frameIndex = 0; frameIndex < convertedAnimation!.DynamicFrames.Count; frameIndex++)
+ {
+ Assert.That(convertedAnimation.DynamicFrames[frameIndex].Position[1], Is.EqualTo(Vector3.Zero));
+ Assert.That(convertedAnimation.DynamicFrames[frameIndex].Rotation[1], Is.EqualTo(Quaternion.Identity));
+
+ Assert.That(convertedAnimation.DynamicFrames[frameIndex].Position[0], Is.EqualTo(originalAnimation.DynamicFrames[frameIndex].Position[0]));
+ Assert.That(convertedAnimation.DynamicFrames[frameIndex].Rotation[0], Is.EqualTo(originalAnimation.DynamicFrames[frameIndex].Rotation[0]));
+
+ Assert.That(sourceAnimation.DynamicFrames[frameIndex].Position[1], Is.EqualTo(originalAnimation.DynamicFrames[frameIndex].Position[1]));
+ Assert.That(sourceAnimation.DynamicFrames[frameIndex].Rotation[1], Is.EqualTo(originalAnimation.DynamicFrames[frameIndex].Rotation[1]));
+ }
+ }
+
+ private static AnimationClip CreateAnimationClip(int boneCount = 2, int frameCount = 2)
+ {
+ var clip = new AnimationClip();
+
+ for (var frameIndex = 0; frameIndex < frameCount; frameIndex++)
+ {
+ var frame = new AnimationClip.KeyFrame();
+ for (var boneIndex = 0; boneIndex < boneCount; boneIndex++)
+ {
+ frame.Position.Add(new Vector3(frameIndex + boneIndex + 1, frameIndex + boneIndex + 2, frameIndex + boneIndex + 3));
+ frame.Rotation.Add(Quaternion.CreateFromYawPitchRoll(frameIndex + boneIndex + 0.1f, frameIndex + boneIndex + 0.2f, frameIndex + boneIndex + 0.3f));
+ frame.Scale.Add(Vector3.One);
+ }
+
+ clip.DynamicFrames.Add(frame);
+ }
+
+ clip.PlayTimeInSec = 0.2f;
+ return clip;
+ }
+ }
+}
diff --git a/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor.csproj b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor.csproj
new file mode 100644
index 000000000..b033db2cb
--- /dev/null
+++ b/Editors/CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor/Test.CampaignAnimationSetEditor.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net10.0-windows
+ latest
+ enable
+ enable
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Shared/GameFiles/AnimationPack/AnimPackFileTypes/Wh3/AnimationBinWh3.cs b/Shared/GameFiles/AnimationPack/AnimPackFileTypes/Wh3/AnimationBinWh3.cs
index 8befe5354..76a9a3e29 100644
--- a/Shared/GameFiles/AnimationPack/AnimPackFileTypes/Wh3/AnimationBinWh3.cs
+++ b/Shared/GameFiles/AnimationPack/AnimPackFileTypes/Wh3/AnimationBinWh3.cs
@@ -154,6 +154,7 @@ private void LoadAnimationBinEntry(ByteChunk chunk)
string IAnimationBinGenericFormat.Name { get => Name; }
string IAnimationBinGenericFormat.SkeletonName { get => SkeletonName; }
+ string IAnimationBinGenericFormat.MountBin { get => MountBin; }
List IAnimationBinGenericFormat.Entries
{
get
diff --git a/Shared/GameFiles/AnimationPack/CampaignAnimationBin.cs b/Shared/GameFiles/AnimationPack/CampaignAnimationBin.cs
index 5f9937256..3f49aa586 100644
--- a/Shared/GameFiles/AnimationPack/CampaignAnimationBin.cs
+++ b/Shared/GameFiles/AnimationPack/CampaignAnimationBin.cs
@@ -70,9 +70,9 @@ public byte[] ToBytes(ref List stringTable)
public class TransitionEntry : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public string Type { get; set; }
public float BlendTime { get; set; }
public string TransitionTo { get; set; }
@@ -80,9 +80,9 @@ public static TransitionEntry FromChunck(ByteChunk byteChunk, string[] stringTab
{
var output = new TransitionEntry();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.BlendTime = byteChunk.ReadSingle();
output.TransitionTo = byteChunk.ReadStringTableIndex(stringTable);
return output;
@@ -92,9 +92,9 @@ public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.Write(BlendTime, ByteParsers.Single);
chuck.WriteStringTableIndex(TransitionTo, ref stringTable);
return chuck.GetBytes();
@@ -104,18 +104,18 @@ public byte[] ToBytes(ref List stringTable)
public class PersistentMeta : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public string Type { get; set; }
public float BlendTime { get; set; }
public static PersistentMeta FromChunck(ByteChunk byteChunk, string[] stringTable)
{
var output = new PersistentMeta();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.BlendTime = byteChunk.ReadSingle();
return output;
}
@@ -124,9 +124,9 @@ public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.Write(BlendTime, ByteParsers.Single);
return chuck.GetBytes();
}
@@ -135,9 +135,9 @@ public byte[] ToBytes(ref List stringTable)
public class PersistentMeta_Pose : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public float Weight { get; set; }
public float BlendTime { get; set; }
public int PoseId { get; set; }
@@ -145,22 +145,21 @@ public static PersistentMeta_Pose FromChunck(ByteChunk byteChunk, string[] strin
{
var output = new PersistentMeta_Pose();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Weight = byteChunk.ReadSingle();
output.BlendTime = byteChunk.ReadSingle();
output.PoseId = byteChunk.ReadInt32();
return output;
}
-
public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.Write(Weight, ByteParsers.Single);
chuck.Write(BlendTime, ByteParsers.Single);
chuck.Write(PoseId, ByteParsers.Int32);
return chuck.GetBytes();
@@ -170,9 +169,9 @@ public byte[] ToBytes(ref List stringTable)
public class PersistentMeta_Dock : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public float Weight { get; set; }
public float BlendTime { get; set; }
public string Dock { get; set; }
@@ -180,58 +179,59 @@ public static PersistentMeta_Dock FromChunck(ByteChunk byteChunk, string[] strin
{
var output = new PersistentMeta_Dock();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Weight = byteChunk.ReadSingle();
output.BlendTime = byteChunk.ReadSingle();
output.Dock = byteChunk.ReadStringTableIndex(stringTable).ToUpper();
return output;
}
-
public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.Write(Weight, ByteParsers.Single);
chuck.Write(BlendTime, ByteParsers.Single);
chuck.WriteStringTableIndex(Dock.ToUpper(), ref stringTable, false);
return chuck.GetBytes();
}
}
+ /// Structurally identical to . Previously used generic
+ /// Value0-5 names because its shape hadn't been identified; confirmed against vanilla data.
public class PortholeEntry : ICampaignAnimationBinEntry
{
- public string Value0 { get; set; }
- public string Value1 { get; set; }
- public string Value2 { get; set; }
- public string Value3 { get; set; }
- public float Value4 { get; set; }
- public float Value5 { get; set; }
+ public string Animation { get; set; }
+ public string Type { get; set; }
+ public string AnimationMeta { get; set; }
+ public string SoundMeta { get; set; }
+ public float BlendTime { get; set; }
+ public float Weight { get; set; }
public static PortholeEntry FromChunck(ByteChunk byteChunk, string[] stringTable)
{
var output = new PortholeEntry();
- output.Value0 = stringTable[byteChunk.ReadInt32()];
- output.Value1 = stringTable[byteChunk.ReadInt32()];
- output.Value2 = stringTable[byteChunk.ReadInt32()];
- output.Value3 = stringTable[byteChunk.ReadInt32()];
- output.Value4 = byteChunk.ReadSingle();
- output.Value5 = byteChunk.ReadSingle();
+ output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
+ output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
+ output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
+ output.BlendTime = byteChunk.ReadSingle();
+ output.Weight = byteChunk.ReadSingle();
return output;
}
public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
- chuck.WriteStringTableIndex(Value0, ref stringTable);
- chuck.WriteStringTableIndex(Value1, ref stringTable);
- chuck.WriteStringTableIndex(Value2, ref stringTable);
- chuck.WriteStringTableIndex(Value3, ref stringTable);
- chuck.Write(Value4, ByteParsers.Single);
- chuck.Write(Value5, ByteParsers.Single);
+ chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
+ chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
+ chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
+ chuck.Write(BlendTime, ByteParsers.Single);
+ chuck.Write(Weight, ByteParsers.Single);
return chuck.GetBytes();
}
}
@@ -245,6 +245,8 @@ public class ActionEntry : ICampaignAnimationBinEntry
public float BlendTime { get; set; }
public string ActionType { get; set; }
public int ActionId { get; set; }
+
+ /// Always false across ~1700 surveyed vanilla rows - purpose unknown.
public bool Unknown { get; set; }
public static ActionEntry FromChunck(ByteChunk byteChunk, string[] stringTable)
@@ -281,11 +283,21 @@ public byte[] ToBytes(ref List stringTable)
public class LocomotionEntry : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public string Type { get; set; }
- public float Weight { get; set; }
+
+ /// Surveyed values (0, 0.1-0.7, 1, 2) match the BlendTime pattern of every
+ /// other entry type, not a 0-1 blend weight - renamed from the original "Weight".
+ public float BlendTime { get; set; }
+
+ /// Always 1.0 in vanilla (660/661); doesn't appear to be used.
public float ModelScale { get; set; }
+
+ /// Not a walk-vs-run distance threshold as the name suggests - values overlap
+ /// between walk/run/fly buckets, and MinDistance sometimes exceeds Distance for the same
+ /// clip. Probably a root-motion distance range for matching playback to movement speed,
+ /// but unconfirmed.
public float DistanceTraveled { get; set; }
public float DistanceMinTravled { get; set; }
@@ -293,10 +305,10 @@ public static LocomotionEntry FromChunck(ByteChunk byteChunk, string[] stringTab
{
var output = new LocomotionEntry();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Type = byteChunk.ReadStringTableIndex(stringTable);
- output.Weight = byteChunk.ReadSingle();
+ output.BlendTime = byteChunk.ReadSingle();
output.ModelScale = byteChunk.ReadSingle();
output.DistanceTraveled = byteChunk.ReadSingle();
output.DistanceMinTravled = byteChunk.ReadSingle();
@@ -307,10 +319,10 @@ public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.WriteStringTableIndex(Type, ref stringTable);
- chuck.Write(Weight, ByteParsers.Single);
+ chuck.Write(BlendTime, ByteParsers.Single);
chuck.Write(ModelScale, ByteParsers.Single);
chuck.Write(DistanceTraveled, ByteParsers.Single);
chuck.Write(DistanceMinTravled, ByteParsers.Single);
@@ -318,12 +330,15 @@ public byte[] ToBytes(ref List stringTable)
}
}
+ /// Rare - only 6 occurrences across 661 vanilla files, all one-off poses for rider
+ /// or vehicle-driver characters. Same shape as plus a trailing
+ /// int (), which was 0 in every occurrence found.
public class UnknownEntry : ICampaignAnimationBinEntry
{
public string Animation { get; set; }
+ public string Type { get; set; }
public string AnimationMeta { get; set; }
public string SoundMeta { get; set; }
- public string Type { get; set; }
public float BlendTime { get; set; }
public float Weight { get; set; }
public int Value { get; set; }
@@ -332,9 +347,9 @@ public static UnknownEntry FromChunck(ByteChunk byteChunk, string[] stringTable)
{
var output = new UnknownEntry();
output.Animation = byteChunk.ReadStringTableIndex(stringTable);
+ output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.AnimationMeta = byteChunk.ReadStringTableIndex(stringTable);
output.SoundMeta = byteChunk.ReadStringTableIndex(stringTable);
- output.Type = byteChunk.ReadStringTableIndex(stringTable);
output.BlendTime = byteChunk.ReadSingle();
output.Weight = byteChunk.ReadSingle();
output.Value = byteChunk.ReadInt32();
@@ -345,12 +360,12 @@ public byte[] ToBytes(ref List stringTable)
{
var chuck = new ChuckWriter();
chuck.WriteStringTableIndex(Animation, ref stringTable);
+ chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.WriteStringTableIndex(AnimationMeta, ref stringTable);
chuck.WriteStringTableIndex(SoundMeta, ref stringTable);
- chuck.WriteStringTableIndex(Type, ref stringTable);
chuck.Write(BlendTime, ByteParsers.Single);
chuck.Write(Weight, ByteParsers.Single);
- chuck.Write(Value, ByteParsers.Single);
+ chuck.Write(Value, ByteParsers.Int32);
return chuck.GetBytes();
}
}
diff --git a/Shared/GameFiles/AnimationPack/IAnimationBinGenericFormat.cs b/Shared/GameFiles/AnimationPack/IAnimationBinGenericFormat.cs
index 7a1815431..be03f517a 100644
--- a/Shared/GameFiles/AnimationPack/IAnimationBinGenericFormat.cs
+++ b/Shared/GameFiles/AnimationPack/IAnimationBinGenericFormat.cs
@@ -6,6 +6,8 @@ public interface IAnimationBinGenericFormat
public string Name { get; }
public string FullPath { get; }
public string SkeletonName { get; }
+ /// Name of the mount's fragment. Only set on rider fragments; empty otherwise.
+ public string MountBin { get; }
public List Entries { get; }
}