diff --git a/AssetEditor.sln b/AssetEditor.sln
index 2fb8122d7..a98d955ce 100644
--- a/AssetEditor.sln
+++ b/AssetEditor.sln
@@ -118,6 +118,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Bmd", "Bmd", "{1D70E263-412
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Editors.BmdEditor", "Editors\BmdEditor\Editors.BmdEditor.csproj", "{9CFEEAF2-8260-F2F5-50FB-8FDA9BD12CF9}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Csc", "Csc", "{A1C5C1E0-52D6-4F61-B8AE-0E0C64F3A9B1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Editors.CscEditor", "Editors\CscEditor\Editors.CscEditor\Editors.CscEditor.csproj", "{D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.CscEditor", "Editors\CscEditor\Test.CscEditor\Test.CscEditor.csproj", "{7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CampaignAnimationCreator", "CampaignAnimationCreator", "{ED432A81-F5C7-0469-2ED7-3AF47D036060}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Editor.CampaignAnimationCreator", "Editors\CampaignAnimationCreator\Editor.CampaignAnimationCreator\Editor.CampaignAnimationCreator.csproj", "{438E6F1B-F5A9-49F3-8516-CB5E9D316C81}"
@@ -568,6 +574,30 @@ Global
{9CFEEAF2-8260-F2F5-50FB-8FDA9BD12CF9}.Release|x64.Build.0 = Release|Any CPU
{9CFEEAF2-8260-F2F5-50FB-8FDA9BD12CF9}.Release|x86.ActiveCfg = Release|Any CPU
{9CFEEAF2-8260-F2F5-50FB-8FDA9BD12CF9}.Release|x86.Build.0 = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|x64.Build.0 = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Debug|x86.Build.0 = Debug|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|x64.ActiveCfg = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|x64.Build.0 = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|x86.ActiveCfg = Release|Any CPU
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17}.Release|x86.Build.0 = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|x64.Build.0 = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Debug|x86.Build.0 = Debug|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|x64.ActiveCfg = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|x64.Build.0 = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|x86.ActiveCfg = Release|Any CPU
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388}.Release|x86.Build.0 = Release|Any CPU
{438E6F1B-F5A9-49F3-8516-CB5E9D316C81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{438E6F1B-F5A9-49F3-8516-CB5E9D316C81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{438E6F1B-F5A9-49F3-8516-CB5E9D316C81}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -658,6 +688,9 @@ Global
{BB9F2AC1-680E-4289-B212-88744460383E} = {D84249ED-B24C-F001-6275-6713CC04DE13}
{1D70E263-4123-4F00-A6AB-A42611732697} = {07AC615B-A8FC-4E1A-BDD5-BC11452429A0}
{9CFEEAF2-8260-F2F5-50FB-8FDA9BD12CF9} = {1D70E263-4123-4F00-A6AB-A42611732697}
+ {A1C5C1E0-52D6-4F61-B8AE-0E0C64F3A9B1} = {07AC615B-A8FC-4E1A-BDD5-BC11452429A0}
+ {D3E7A2B4-9C41-4E8F-A6D2-7B5E9F0C3A17} = {A1C5C1E0-52D6-4F61-B8AE-0E0C64F3A9B1}
+ {7F8AC4BE-11D2-4E60-93A1-C5B2E4F0A388} = {A1C5C1E0-52D6-4F61-B8AE-0E0C64F3A9B1}
{ED432A81-F5C7-0469-2ED7-3AF47D036060} = {07AC615B-A8FC-4E1A-BDD5-BC11452429A0}
{438E6F1B-F5A9-49F3-8516-CB5E9D316C81} = {ED432A81-F5C7-0469-2ED7-3AF47D036060}
{91617891-AD16-423F-B56F-AC0D2CE82B95} = {ED432A81-F5C7-0469-2ED7-3AF47D036060}
diff --git a/AssetEditor/AssetEditor.csproj b/AssetEditor/AssetEditor.csproj
index 96e0a475a..e82ab9678 100644
--- a/AssetEditor/AssetEditor.csproj
+++ b/AssetEditor/AssetEditor.csproj
@@ -29,6 +29,7 @@
+
diff --git a/AssetEditor/Services/DependencyInjectionConfig.cs b/AssetEditor/Services/DependencyInjectionConfig.cs
index fe6ab5542..ebb066dbf 100644
--- a/AssetEditor/Services/DependencyInjectionConfig.cs
+++ b/AssetEditor/Services/DependencyInjectionConfig.cs
@@ -36,6 +36,7 @@ public DependencyInjectionConfig(bool loadResources = true)
new Editors.Twui.DependencyInjectionContainer(),
new Editors.Ipc.DependencyInjectionContainer(),
new Editors.BmdEditor.DependencyInjectionContainer(),
+ new Editors.CscEditor.DependencyInjectionContainer(),
// Host application
new DependencyInjectionContainer(),
diff --git a/Editors/CampaignAnimationCreator/Editor.CampaignAnimationCreator/Editor.CampaignAnimationCreator.csproj b/Editors/CampaignAnimationCreator/Editor.CampaignAnimationCreator/Editor.CampaignAnimationCreator.csproj
index 2c38bb06e..7b1e36202 100644
--- a/Editors/CampaignAnimationCreator/Editor.CampaignAnimationCreator/Editor.CampaignAnimationCreator.csproj
+++ b/Editors/CampaignAnimationCreator/Editor.CampaignAnimationCreator/Editor.CampaignAnimationCreator.csproj
@@ -8,6 +8,10 @@
true
+
+
+
+
diff --git a/Editors/CscEditor/Editors.CscEditor/Data/CscChannel.cs b/Editors/CscEditor/Editors.CscEditor/Data/CscChannel.cs
new file mode 100644
index 000000000..16d4ce2a2
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Data/CscChannel.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Shared.GameFormats.Esf;
+
+namespace Editors.CscEditor.Data
+{
+ ///
+ /// One keyframe of a CSC parameter curve:
+ /// F32(x=time), F32(y=value), Ascii(mode), Coord2d(tangentIn), Coord2d(tangentOut), Ascii(mode)
+ /// on the wire. Mode vocabulary seen in real files: "constant", "linear", "bezier_c".
+ ///
+ public class CscKeyframe
+ {
+ public float Time { get; set; }
+ public float Value { get; set; }
+ public string ModeIn { get; set; } = "linear";
+ public Coord2d TangentIn { get; set; }
+ public Coord2d TangentOut { get; set; }
+ public string ModeOut { get; set; } = "linear";
+
+ public CscKeyframe Clone() => new()
+ {
+ Time = Time,
+ Value = Value,
+ ModeIn = ModeIn,
+ TangentIn = TangentIn,
+ TangentOut = TangentOut,
+ ModeOut = ModeOut,
+ };
+ }
+
+ ///
+ /// One channel of a parameter group. The header F32 is the channel's live value only when it
+ /// has zero keyframes; any keyframe overrides it entirely (in-game confirmed behaviour).
+ /// tracks which of the two wire encodings the channel came from -
+ /// the modern header form (F32, Ascii, Ascii) or the older bare-F32 form - so it round-trips
+ /// in the same shape it was read in.
+ ///
+ public class CscChannel
+ {
+ public float Header { get; set; }
+ public string ModeA { get; set; } = "constant";
+ public string ModeB { get; set; } = "constant";
+ public bool HasModeTags { get; set; } = true;
+ public List Keyframes { get; } = [];
+
+ public float Evaluate(float time)
+ {
+ if (Keyframes.Count == 0)
+ return Header;
+ if (time <= Keyframes[0].Time)
+ return Keyframes[0].Value;
+ if (time >= Keyframes[^1].Time)
+ return Keyframes[^1].Value;
+
+ var i = 0;
+ while (i < Keyframes.Count - 1 && Keyframes[i + 1].Time < time)
+ i++;
+
+ var a = Keyframes[i];
+ var b = Keyframes[i + 1];
+ var span = b.Time - a.Time;
+ if (span <= 0)
+ return b.Value;
+
+ if (a.ModeOut.StartsWith("constant", StringComparison.OrdinalIgnoreCase))
+ return a.Value;
+ if (a.ModeOut.StartsWith("linear", StringComparison.OrdinalIgnoreCase))
+ return a.Value + (b.Value - a.Value) * ((time - a.Time) / span);
+
+ return EvaluateBezier(a, b, time);
+ }
+
+ ///
+ /// Cubic bezier segment with the keyframes' tangent handles as relative control-point
+ /// offsets: P0=(a.t,a.v), P1=P0+a.TangentOut, P2=P3+b.TangentIn, P3=(b.t,b.v). Control X
+ /// is clamped inside [a.t, b.t] so x(s) stays monotonic and the x->s inversion (bisection)
+ /// is well defined.
+ ///
+ static float EvaluateBezier(CscKeyframe a, CscKeyframe b, float time)
+ {
+ var x0 = a.Time;
+ var y0 = a.Value;
+ var x3 = b.Time;
+ var y3 = b.Value;
+ var x1 = Math.Clamp(x0 + a.TangentOut.X, x0, x3);
+ var y1 = y0 + a.TangentOut.Y;
+ var x2 = Math.Clamp(x3 + b.TangentIn.X, x0, x3);
+ var y2 = y3 + b.TangentIn.Y;
+
+ float BezierX(float s)
+ {
+ var u = 1 - s;
+ return u * u * u * x0 + 3 * u * u * s * x1 + 3 * u * s * s * x2 + s * s * s * x3;
+ }
+
+ float lo = 0, hi = 1;
+ for (var iter = 0; iter < 32; iter++)
+ {
+ var mid = (lo + hi) * 0.5f;
+ if (BezierX(mid) < time)
+ lo = mid;
+ else
+ hi = mid;
+ }
+
+ var t = (lo + hi) * 0.5f;
+ var v = 1 - t;
+ return v * v * v * y0 + 3 * v * v * t * y1 + 3 * v * t * t * y2 + t * t * t * y3;
+ }
+
+ /// The value shown/edited when treating the channel as static (no time context).
+ public float StaticValue => Keyframes.Count == 0 ? Header : Keyframes[0].Value;
+
+ /// Adds a delta to the channel everywhere - header and every keyframe - so an
+ /// animated channel keeps its motion but shifts its base value.
+ public void OffsetAll(float delta)
+ {
+ Header += delta;
+ foreach (var k in Keyframes)
+ k.Value += delta;
+ }
+
+ public void ScaleAll(float factor)
+ {
+ Header *= factor;
+ foreach (var k in Keyframes)
+ k.Value *= factor;
+ }
+
+ public void SetStatic(float value)
+ {
+ if (Keyframes.Count == 0)
+ Header = value;
+ else
+ OffsetAll(value - StaticValue);
+ }
+
+ public CscKeyframe AddKeyframe(float time, float value)
+ {
+ var key = new CscKeyframe { Time = time, Value = value, ModeIn = "linear", ModeOut = "linear" };
+ Keyframes.Add(key);
+ SortKeyframes();
+ return key;
+ }
+
+ public void SortKeyframes() => Keyframes.Sort((a, b) => a.Time.CompareTo(b.Time));
+
+ public CscChannel Clone()
+ {
+ var clone = new CscChannel { Header = Header, ModeA = ModeA, ModeB = ModeB, HasModeTags = HasModeTags };
+ clone.Keyframes.AddRange(Keyframes.Select(k => k.Clone()));
+ return clone;
+ }
+ }
+
+ ///
+ /// A parameter group: U32(marker) + one or more channels. The marker is load-bearing
+ /// (flipping it from 1 crashed the game's loader in testing) so it is preserved verbatim.
+ ///
+ public class CscChannelGroup
+ {
+ public uint Marker { get; set; } = 1;
+ public List Channels { get; } = [];
+
+ public static CscChannelGroup CreateStatic(params float[] channelHeaders)
+ {
+ var group = new CscChannelGroup();
+ foreach (var header in channelHeaders)
+ group.Channels.Add(new CscChannel { Header = header });
+ return group;
+ }
+
+ public CscChannelGroup Clone()
+ {
+ var clone = new CscChannelGroup { Marker = Marker };
+ clone.Channels.AddRange(Channels.Select(c => c.Clone()));
+ return clone;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Data/CscElement.cs b/Editors/CscEditor/Editors.CscEditor/Data/CscElement.cs
new file mode 100644
index 000000000..4fdc77c72
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Data/CscElement.cs
@@ -0,0 +1,425 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using Shared.GameFormats.Csc;
+using Shared.GameFormats.Esf;
+
+namespace Editors.CscEditor.Data
+{
+ public enum CscElementKind
+ {
+ Locator,
+ Model,
+ VariantModel,
+ Vfx,
+ Sfx,
+ Animation,
+ AnimationNodeTransform,
+ Camera,
+ PointLight,
+ SpotLight,
+ Prefab,
+ RootRef,
+ SoundSphere,
+ CameraShake,
+ Unknown,
+ }
+
+ ///
+ /// One scene element: the ELEMENT record plus its type-specific sibling records from a single
+ /// labeled element group under ROOT, decoded into an editable form. Unrecognized fields are
+ /// carried along as raw s so saving preserves them byte-for-byte.
+ ///
+ public class CscElement
+ {
+ public int Id { get; set; }
+ public string GroupLabel { get; set; } = "model";
+ public CscElementKind Kind { get; set; } = CscElementKind.Unknown;
+
+ // ---- ELEMENT record header fields ----
+ public string HeaderString { get; set; } = "";
+ public float Begin { get; set; }
+ public float End { get; set; } = 10;
+ public string TimingMode { get; set; } = "infinite"; // "duration" | "infinite" | "pulse"
+ public string AnchorMode { get; set; } = "free"; // terrain anchoring mode
+ public byte ElementVersion { get; set; } = 100;
+
+ // ---- ELEMENT keyframable channel groups (null on legacy v5 records) ----
+ public CscChannelGroup? Position { get; set; }
+ public CscChannelGroup? Rotation { get; set; }
+ public CscChannelGroup? Scale { get; set; }
+ public CscChannelGroup? Weight { get; set; }
+
+ // ---- ELEMENT trailing placement transform (in-game confirmed) ----
+ public Vector3 BasePosition { get; set; }
+ public Vector3 BaseRotation { get; set; } // Euler radians
+
+ /// Original ELEMENT fields after the second trailing Coord3d (a Bool on v7/v100), preserved verbatim.
+ public List ElementTrailingFields { get; set; } = [];
+
+ /// True when holds the v7/v100 trailing Bool
+ /// (absent on v6 and earlier ELEMENT shapes).
+ /// Meaning unconfirmed, but real files show it varying rather than sitting at one
+ /// constant value, so it's exposed for inspection/editing instead of only round-tripped
+ /// verbatim.
+ public bool HasElementTrailingBool => ElementTrailingFields is [{ Kind: EsfNodeKind.Bool }, ..];
+
+ public bool ElementTrailingBool
+ {
+ get => HasElementTrailingBool && ElementTrailingFields[0].Value is true;
+ set
+ {
+ if (HasElementTrailingBool)
+ ElementTrailingFields[0] = EsfNode.Leaf(EsfNodeKind.Bool, value);
+ }
+ }
+
+ /// True when the ELEMENT record's channel-group layout was not recognized (e.g.
+ /// legacy v5). The whole element group is then preserved verbatim and is read-only.
+ public bool IsLegacyRaw { get; set; }
+
+ /// All records of the element group in encounter order (ELEMENT first, usually).
+ /// Used for verbatim preservation and as the patch target on save.
+ public List Records { get; set; } = [];
+ public EsfNode? ElementRecord { get; set; }
+ public EsfNode? TypeRecord { get; set; }
+
+ /// Channel-group runs detected inside at load time
+ /// (camera parameters, light colour/intensity/... groups), edited in place.
+ public List TypeGroups { get; } = [];
+
+ /// Primary asset reference: model path, VFX name, .anim path, .bmd prefab path,
+ /// nested .csc path or the Wwise start event, depending on .
+ public string AssetPath { get; set; } = "";
+
+ /// Second Wwise event (the stop event) for SFX elements - paired with
+ /// (event 1's start event).
+ public string SfxStopEvent { get; set; } = "";
+
+ /// SFX_ELEMENT can carry two more Ascii fields after the (start, stop) pair -
+ /// confirmed (corpus-wide, versions with 4+ leading Ascii fields) to be a second,
+ /// independent Wwise (start, stop) event pair: real instances show completely unrelated
+ /// event names between the two pairs in the same record (e.g. one Battle_IND_Magic_...
+ /// event and one unrelated Cam_Env_Sea_Lane_Teleport_... event together). Usually empty in
+ /// any given file simply because most SFX elements only use one event pair, not because the
+ /// slot is unused in general.
+ public bool SfxHasSecondEventPair { get; set; }
+ public string SfxEvent2Start { get; set; } = "";
+ public string SfxEvent2Stop { get; set; } = "";
+
+ // ---- ELEMENT_PERIOD (SFX/animation bundles): (flag, speed multiplier, time offset) ----
+ public EsfNode? PeriodRecord { get; set; }
+ public bool PeriodFlag { get; set; }
+ public float PeriodSpeedMultiplier { get; set; } = 1;
+ public float PeriodTimeOffset { get; set; }
+
+ // ---- ANIMATION_SPLICE_ELEMENT: a sibling record alongside ANIMATION_ELEMENT within the
+ // same element group (not nested inside it) - splices this .anim onto a specific bone and
+ // its descendants rather than the whole skeleton. Field 0 = bone id (fairly confident
+ // guess); fields 1/2 = depth/scope values whose exact meaning isn't confirmed. Everything
+ // after that varies by record version (bools plus floats/an Ascii name) and is preserved
+ // verbatim, shown read-only.
+ public EsfNode? SpliceRecord { get; set; }
+ public int SpliceBoneId { get; set; }
+ public int SpliceDepthA { get; set; }
+ public int SpliceDepthB { get; set; }
+ public string SpliceRemainingFieldsDisplay { get; set; } = "";
+
+ // ---- ANIMATION_NODE_TRANSFORM_ELEMENT (its own element kind, "Node Transform" in the
+ // tree): this element's own Position/Rotation/Scale/Weight keyframe curves drive the bone
+ // named here rather than placing the element itself - e.g. animating a jaw bone open while
+ // the rest of the body plays a different/no clip. Field 1 is corpus-wide almost always the
+ // -1 "no reference" sentinel; shown but not asserted to mean anything else. ----
+ public int NodeTransformBoneId { get; set; } = -1;
+ public int NodeTransformSecondValue { get; set; } = -1;
+
+ // ---- Hierarchy (ROOT's attach tree) ----
+ public CscElement? Parent { get; set; }
+ public List Children { get; } = [];
+
+ /// The scene this element belongs to - the main for a
+ /// normal element, or a root-ref'd sub-scene's own instance for one
+ /// of its elements. Stamped by /;
+ /// used only to resolve 's wrap boundary, never serialized.
+ public CscScene? Scene { get; set; }
+
+ /// ATTACH_TO_PARAMETERS value: bone index within the parent model, -1 = model origin/animroot.
+ public int AttachBoneIndex { get; set; } = -1;
+ public EsfNode? AttachRecord { get; set; }
+
+ /// True for elements nested inside another element's type record (e.g. an idle
+ /// animation carried inside a VARIANT_MODEL_ELEMENT). Nested elements are not part of
+ /// ROOT's attach tree - their records are written back in place inside the carrier - so
+ /// they can be edited but not re-parented or deleted individually.
+ public bool IsNested { get; set; }
+
+ /// True for elements of a .csc loaded through a ROOT_REF_ELEMENT. They are
+ /// display-only: shown in the 3D view and the tree, but never part of the host scene's
+ /// data, so they cannot be edited, re-parented or deleted.
+ public bool IsExternal { get; set; }
+
+ /// This element's COMPOSITE_SCENE manifest group fields (raw slice), captured at
+ /// load so channel names/flags survive a save even though keyframe counts are re-derived.
+ public List? ManifestGroupFields { get; set; }
+
+ public string DisplayName
+ {
+ get
+ {
+ var asset = AssetPath;
+ if (!string.IsNullOrEmpty(asset))
+ {
+ var slash = asset.Replace('/', '\\').LastIndexOf('\\');
+ if (slash >= 0 && slash < asset.Length - 1)
+ asset = asset[(slash + 1)..];
+ }
+
+ if (string.IsNullOrEmpty(asset))
+ asset = string.IsNullOrEmpty(HeaderString) ? GroupLabel : HeaderString;
+
+ return $"[{Id}] {asset}";
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Channel access helpers
+ // ---------------------------------------------------------------------
+
+ public Vector3 EvaluatePosition(float t) => EvaluateVector(Position, t, Vector3.Zero);
+ public Vector3 EvaluateRotation(float t) => EvaluateVector(Rotation, t, Vector3.Zero);
+ public float EvaluateScale(float t) => Scale?.Channels.Count > 0 ? Scale.Channels[0].Evaluate(t) : 1;
+ public float EvaluateWeight(float t) => Weight?.Channels.Count > 0 ? Weight.Channels[0].Evaluate(t) : 1;
+
+ static Vector3 EvaluateVector(CscChannelGroup? group, float t, Vector3 fallback)
+ {
+ if (group == null || group.Channels.Count < 3)
+ return fallback;
+ return new Vector3(group.Channels[0].Evaluate(t), group.Channels[1].Evaluate(t), group.Channels[2].Evaluate(t));
+ }
+
+ ///
+ /// / clamped into [0, Scene.Duration]. Real files (and
+ /// hand edits) can carry a negative Begin/End or one past the scene's own duration - a
+ /// negative time just reads as 0, a time past the scene's own duration reads as the
+ /// duration (no wraparound). The raw / properties are
+ /// left untouched - they're the authored/editable values shown as-is in the UI (and in the
+ /// curve editor, which draws the true out-of-bounds region rather than hiding it) - every
+ /// runtime consumer of the active window should read this instead. Falls back to just a
+ /// lower clamp at 0 when there's no to size the upper bound against
+ /// (e.g. a bare element in a unit test) or its duration is non-positive.
+ ///
+ public (float Begin, float End) NormalizedWindow
+ {
+ get
+ {
+ var duration = Scene?.Duration ?? 0f;
+ var begin = MathF.Max(Begin, 0f);
+ var end = MathF.Max(End, 0f);
+ if (duration > 0)
+ {
+ begin = MathF.Min(begin, duration);
+ end = MathF.Min(end, duration);
+ }
+ return (begin, end);
+ }
+ }
+
+ /// Convenience accessors for 's two components -
+ /// every runtime (non-UI) consumer of the active window should read these, not the raw
+ /// /.
+ public float NormalizedBegin => NormalizedWindow.Begin;
+ public float NormalizedEnd => NormalizedWindow.End;
+
+ ///
+ /// Clamps into the element's own (normalized) active window before it
+ /// reaches the keyframe curves, so begin/end timing is authoritative over what the curves
+ /// would otherwise keep producing - an element frozen or force-shown (e.g. while selected)
+ /// outside its window holds its boundary pose instead of continuing to animate past it.
+ ///
+ float ClampToActiveWindow(float t)
+ {
+ var (begin, end) = NormalizedWindow;
+
+ if (TimingMode == "infinite")
+ return MathF.Min(t, end);
+ if (end < begin) // malformed data guard
+ return MathF.Max(t, begin);
+ return Math.Clamp(t, begin, end);
+ }
+
+ ///
+ /// Local transform at scene time . The trailing base placement is
+ /// applied before the channel transform - in-game testing showed the base position offsets
+ /// the mesh away from the channel rotation's pivot while the spin continues around that
+ /// pivot, which is exactly this composition order (row-vector convention: base first).
+ ///
+ public Matrix LocalTransform(float t)
+ {
+ var channelTime = ClampToActiveWindow(t);
+
+ var pos = EvaluatePosition(channelTime);
+ var rot = EvaluateRotation(channelTime);
+ var scale = EvaluateScale(channelTime);
+
+ var channelMatrix = Matrix.CreateScale(scale) * EulerMatrix(rot) * Matrix.CreateTranslation(pos);
+ var baseMatrix = EulerMatrix(BaseRotation) * Matrix.CreateTranslation(BasePosition);
+ return baseMatrix * channelMatrix;
+ }
+
+ public static Matrix EulerMatrix(Vector3 euler) =>
+ Matrix.CreateRotationX(euler.X) * Matrix.CreateRotationY(euler.Y) * Matrix.CreateRotationZ(euler.Z);
+
+ /// World transform at time t, composed through the attach-tree parent chain.
+ public Matrix WorldTransform(float t)
+ {
+ var m = LocalTransform(t);
+ var p = Parent;
+ while (p != null)
+ {
+ m *= p.LocalTransform(t);
+ p = p.Parent;
+ }
+ return m;
+ }
+
+ /// Whether the element is showing at scene time : inside its
+ /// own normalized Begin/End window (see ) and its Weight
+ /// channel isn't zeroed out there. "infinite" mode has no upper cutoff - alive forever once
+ /// past Begin.
+ public bool IsAliveAt(float t)
+ {
+ var (begin, end) = NormalizedWindow;
+
+ if (t < begin)
+ return false;
+ if (TimingMode != "infinite" && t > end)
+ return false;
+ return EvaluateWeight(ClampToActiveWindow(t)) > 0;
+ }
+
+ // ---------------------------------------------------------------------
+ // Type-specific channel-group aliases (indexes into TypeGroups)
+ // ---------------------------------------------------------------------
+
+ public CscChannel? TypeChannel(int groupIndex, int channelIndex = 0)
+ {
+ if (groupIndex < 0 || groupIndex >= TypeGroups.Count)
+ return null;
+ var channels = TypeGroups[groupIndex].Channels;
+ return channelIndex < channels.Count ? channels[channelIndex] : null;
+ }
+
+ // Lights: group 0 = colour (R/G/B, 3 channels).
+ public Vector3 LightColour(float t)
+ {
+ if (TypeGroups.Count == 0 || TypeGroups[0].Channels.Count < 3)
+ return Vector3.One;
+ var g = TypeGroups[0];
+ return new Vector3(g.Channels[0].Evaluate(t), g.Channels[1].Evaluate(t), g.Channels[2].Evaluate(t));
+ }
+
+ // Point light group order: colour(3), intensity, range, scatter_diameter (CA's own manifest names).
+ public CscChannel? PointLightIntensity => Kind == CscElementKind.PointLight ? TypeChannel(1) : null;
+ public CscChannel? PointLightRange => Kind == CscElementKind.PointLight ? TypeChannel(2) : null;
+
+ // Spot light group order: colour(3), intensity, length, inner_angle, outer_angle, scatter_diameter.
+ public CscChannel? SpotLightIntensity => Kind == CscElementKind.SpotLight ? TypeChannel(1) : null;
+ public CscChannel? SpotLightLength => Kind == CscElementKind.SpotLight ? TypeChannel(2) : null;
+ public CscChannel? SpotLightInnerAngle => Kind == CscElementKind.SpotLight ? TypeChannel(3) : null;
+ public CscChannel? SpotLightOuterAngle => Kind == CscElementKind.SpotLight ? TypeChannel(4) : null;
+
+ // Camera group order (group indexes; interleaved Bools don't count) - corpus-confirmed,
+ // same for every CAMERA_ELEMENT version: fov(deg), roll(radians), near, far, focal depth,
+ // focal width, focal falloff near/far, kernal radius fg/bg, pivot distance. Every group
+ // after far sits at its default value in every file checked so far, so their meaning
+ // (beyond the pre-existing manifest channel names below) is unconfirmed.
+ static int CameraGroupIndex(string name) => name switch
+ {
+ "fov" => 0, "roll" => 1, "near" => 2, "far" => 3, "focal_depth" => 4, _ => -1
+ };
+
+ /// The real near/far render clip planes (not depth-of-field - camera_focal_depth,
+ /// group index 4, is always some default value in every file checked and isn't used for
+ /// anything; see CameraGroupNames for the full group list).
+ public CscChannel? CameraFov => Kind == CscElementKind.Camera ? TypeChannel(CameraGroupIndex("fov")) : null;
+ public CscChannel? CameraRoll => Kind == CscElementKind.Camera ? TypeChannel(CameraGroupIndex("roll")) : null;
+ public CscChannel? CameraNear => Kind == CscElementKind.Camera ? TypeChannel(CameraGroupIndex("near")) : null;
+ public CscChannel? CameraFar => Kind == CscElementKind.Camera ? TypeChannel(CameraGroupIndex("far")) : null;
+
+ /// Identity of the original Bool node sitting between the roll and near groups of
+ /// every CAMERA_ELEMENT (any version) - not part of the Group/Channel/Curve grammar (see
+ /// CurveDetector), so it isn't a entry. Never reassigned after
+ /// decode: matches on this exact reference to splice
+ /// 's edited value back into the right raw field on save.
+ /// Meaning unconfirmed.
+ public EsfNode? CameraUnknownFlagNode { get; set; }
+
+ public bool HasCameraUnknownFlag => CameraUnknownFlagNode != null;
+
+ public bool CameraUnknownFlag { get; set; }
+
+ public static readonly string[] CameraGroupNames =
+ [
+ "camera_fov", "camera_roll", "camera_near_distance", "camera_far_distance", "camera_focal_depth",
+ "camera_focal_width", "camera_focal_falloff_near", "camera_focal_falloff_far",
+ "camera_kernal_radius_foreground", "camera_kernal_radius_background", "camera_pivot_distance",
+ ];
+
+ public static readonly string[] PointLightGroupNames =
+ ["point_light_colour", "point_light_intensity", "point_light_range", "point_light_scatter_diameter"];
+
+ public static readonly string[] SpotLightGroupNames =
+ [
+ "spot_light_colour", "spot_light_intensity", "spot_light_length",
+ "spot_light_inner_angle", "spot_light_outer_angle", "spot_light_scatter_diameter",
+ ];
+
+ public string TypeGroupName(int groupIndex)
+ {
+ var names = Kind switch
+ {
+ CscElementKind.Camera => CameraGroupNames,
+ CscElementKind.PointLight => PointLightGroupNames,
+ CscElementKind.SpotLight => SpotLightGroupNames,
+ CscElementKind.RootRef => (string[])["game_time_multiplier", "scene_time_multiplier"],
+ _ => [],
+ };
+ return groupIndex < names.Length ? names[groupIndex] : $"param {groupIndex}";
+ }
+
+ ///
+ /// Keyframe counts of every channel, ELEMENT groups first (position, rotation, scale,
+ /// weight) then the type record's groups - the exact order COMPOSITE_SCENE's manifest
+ /// restates them in.
+ ///
+ public List AllChannelKeyframeCounts()
+ {
+ var counts = new List();
+ foreach (var group in ElementGroups().Concat(TypeGroups))
+ foreach (var channel in group.Channels)
+ counts.Add(channel.Keyframes.Count);
+ return counts;
+ }
+
+ public IEnumerable ElementGroups()
+ {
+ if (Position != null) yield return Position;
+ if (Rotation != null) yield return Rotation;
+ if (Scale != null) yield return Scale;
+ if (Weight != null) yield return Weight;
+ }
+
+ public bool IsInSubtreeOf(CscElement other)
+ {
+ var current = this;
+ while (current != null)
+ {
+ if (current == other)
+ return true;
+ current = current.Parent;
+ }
+ return false;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Data/CscElementFactory.cs b/Editors/CscEditor/Editors.CscEditor/Data/CscElementFactory.cs
new file mode 100644
index 000000000..8ac7bb329
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Data/CscElementFactory.cs
@@ -0,0 +1,317 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Shared.GameFormats.Esf;
+
+namespace Editors.CscEditor.Data
+{
+ ///
+ /// Builds new elements (domain object + backing ESF records) matching the corpus-dominant
+ /// v100 record shapes. Records with version > 15 must carry the HasNonOptimizedInfo flag -
+ /// the packed 2-byte record header only has 4 bits for the version.
+ ///
+ public static class CscElementFactory
+ {
+ const EsfRecordFlags RecordV100Flags = EsfRecordFlags.IsRecordNode | EsfRecordFlags.HasNonOptimizedInfo;
+
+ public static CscElement Create(CscScene scene, CscElementKind kind, string assetPath = "")
+ {
+ var element = new CscElement
+ {
+ Id = scene.NextFreeElementId(),
+ Kind = kind,
+ GroupLabel = DefaultGroupLabel(kind),
+ AssetPath = assetPath,
+ Begin = 0,
+ End = scene.Duration,
+ TimingMode = "duration",
+ AnchorMode = "free",
+ ElementVersion = 100,
+ Position = CscChannelGroup.CreateStatic(0, 0, 0),
+ Rotation = CscChannelGroup.CreateStatic(0, 0, 0),
+ Scale = CscChannelGroup.CreateStatic(1),
+ Weight = CscChannelGroup.CreateStatic(1),
+ };
+
+ // Points the cone downward by default (identity rotation aims it along the
+ // horizontal forward axis, which is rarely what's wanted for a fresh light).
+ if (kind == CscElementKind.SpotLight)
+ element.Rotation = CscChannelGroup.CreateStatic(0, 0, -MathF.PI / 2);
+
+ foreach (var group in DefaultTypeGroups(kind))
+ element.TypeGroups.Add(group);
+
+ element.ElementRecord = BuildElementRecord(element);
+ element.Records.Add(element.ElementRecord);
+
+ if (kind == CscElementKind.Sfx)
+ {
+ // The name typed on creation goes into the second (start, stop) event pair's
+ // start slot; the first pair and the second stop are left empty.
+ element.AssetPath = "";
+ element.SfxEvent2Start = assetPath;
+ element.SfxHasSecondEventPair = true;
+
+ // ELEMENT_PERIOD (speed/offset/unknown-flag) is an optional sibling record, not
+ // something every SFX carries in the corpus (well under half of SFX/animation
+ // bundles combined have one) - left absent by default rather than always created.
+ }
+
+ var typeRecord = BuildTypeRecord(element);
+ if (typeRecord != null)
+ {
+ element.TypeRecord = typeRecord;
+ element.Records.Add(typeRecord);
+ }
+
+ return element;
+ }
+
+ static string DefaultGroupLabel(CscElementKind kind) => kind switch
+ {
+ CscElementKind.Model => "model",
+ CscElementKind.VariantModel => "model",
+ CscElementKind.Vfx => "vfx",
+ CscElementKind.Sfx => "sfx",
+ CscElementKind.Animation => "animation",
+ CscElementKind.Camera => "camera",
+ CscElementKind.PointLight => "point_light",
+ CscElementKind.SpotLight => "spot_light",
+ CscElementKind.Prefab => "prefab",
+ CscElementKind.RootRef => "root_ref",
+ _ => "group",
+ };
+
+ static List DefaultTypeGroups(CscElementKind kind) => kind switch
+ {
+ // colour(R,G,B), intensity, range, scatter_diameter
+ CscElementKind.PointLight =>
+ [
+ CscChannelGroup.CreateStatic(1, 1, 1),
+ CscChannelGroup.CreateStatic(100),
+ CscChannelGroup.CreateStatic(10),
+ CscChannelGroup.CreateStatic(0.05f),
+ ],
+ // colour(R,G,B), intensity, length, inner_angle, outer_angle, scatter_diameter
+ CscElementKind.SpotLight =>
+ [
+ CscChannelGroup.CreateStatic(1, 1, 1),
+ CscChannelGroup.CreateStatic(10000),
+ CscChannelGroup.CreateStatic(30),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(MathF.PI / 6),
+ CscChannelGroup.CreateStatic(0.05f),
+ ],
+ // fov(deg), roll(rad), near, far, focal_depth/width, falloff near/far, kernal fg/bg,
+ // pivot distance + 2 unnamed trailing groups (the v7/v100 shape has 13 groups) - see
+ // CscElement.CameraGroupIndex for the confirmed order (applies to every version).
+ CscElementKind.Camera =>
+ [
+ CscChannelGroup.CreateStatic(45),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0.01f),
+ CscChannelGroup.CreateStatic(10000),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ CscChannelGroup.CreateStatic(0),
+ ],
+ // game_time_multiplier, scene_time_multiplier - both default to 1 (neutral speed).
+ CscElementKind.RootRef =>
+ [
+ CscChannelGroup.CreateStatic(1),
+ CscChannelGroup.CreateStatic(1),
+ ],
+ _ => [],
+ };
+
+ static EsfNode BuildElementRecord(CscElement element)
+ {
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.U32, (uint)element.Id, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.HeaderString),
+ EsfNode.Leaf(EsfNodeKind.F32, element.Begin, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.F32, element.End),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.TimingMode),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AnchorMode),
+ };
+
+ foreach (var group in element.ElementGroups())
+ CscSceneWriter.AppendChannelGroup(fields, group);
+
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord3d, new Coord3d(0, 0, 0)));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord3d, new Coord3d(0, 0, 0)));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ element.ElementTrailingFields = [fields[^1]];
+
+ return EsfNode.NewRecord("ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ static EsfNode? BuildTypeRecord(CscElement element)
+ {
+ switch (element.Kind)
+ {
+ case CscElementKind.Model:
+ {
+ // Ascii(path), Ascii(faction override), U32(colour-triplet count = 3),
+ // 9 x F32 player colour (white), Bool, Bool, Bool.
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath),
+ EsfNode.Leaf(EsfNodeKind.Ascii, ""),
+ EsfNode.Leaf(EsfNodeKind.U32, 3u, optimized: true),
+ };
+ for (var i = 0; i < 9; i++)
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, 1f));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true));
+ return EsfNode.NewRecord("MODEL_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ case CscElementKind.VariantModel:
+ {
+ // Shares MODEL_ELEMENT's confirmed prefix (path, faction override, colour-triplet
+ // count = 3, 9 x F32 player colour, Bool, Bool, Bool), followed by a variable-length
+ // list of named material/mesh-part overrides (corpus-observed, not decoded/edited by
+ // this editor) terminated by a trailing Bool - empty here (no overrides).
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath),
+ EsfNode.Leaf(EsfNodeKind.Ascii, ""),
+ EsfNode.Leaf(EsfNodeKind.U32, 3u, optimized: true),
+ };
+ for (var i = 0; i < 9; i++)
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, 1f));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ return EsfNode.NewRecord("VARIANT_MODEL_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ case CscElementKind.Animation:
+ // Most-common real v100 shape in the corpus (904/1255
+ // instances): Ascii(path), Bool, Bool(true), Bool, Ascii("linear"), Ascii,
+ // Unknown23, Ascii, U32(0), U32(repeat count = 0, no extra Ascii names), then
+ // Bool, Bool(false), Ascii("spherical_linear"), Bool(true), U8(255), U8(0),
+ // U8(255). Only some of these positions are corpus-confirmed constants (the
+ // ones with a literal default here); the rest vary per instance and are given
+ // a plausible default rather than a verified one.
+ return EsfNode.NewRecord("ANIMATION_ELEMENT", 100, RecordV100Flags,
+ [[
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Ascii, "linear"),
+ EsfNode.Leaf(EsfNodeKind.Ascii, ""),
+ EsfNode.Leaf(EsfNodeKind.Unknown23, (byte)0),
+ EsfNode.Leaf(EsfNodeKind.Ascii, ""),
+ EsfNode.Leaf(EsfNodeKind.U32, 0u, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.U32, 0u, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Ascii, "spherical_linear"),
+ EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.U8, (byte)255),
+ EsfNode.Leaf(EsfNodeKind.U8, (byte)0),
+ EsfNode.Leaf(EsfNodeKind.U8, (byte)255),
+ ]]);
+
+ case CscElementKind.Vfx:
+ return EsfNode.NewRecord("VFX_ELEMENT", 100, RecordV100Flags,
+ [[
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Ascii, "default"),
+ ]]);
+
+ case CscElementKind.Sfx:
+ // Two (start, stop) Wwise event pairs + flags/parameters + trailing
+ // U32(rtpc curve count = 0).
+ return EsfNode.NewRecord("SFX_ELEMENT", 100, RecordV100Flags,
+ [[
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxStopEvent),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxEvent2Start),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxEvent2Stop),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.U8, (byte)0),
+ EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.U32, 0u, optimized: true),
+ ]]);
+
+ case CscElementKind.PointLight:
+ {
+ // Group(3) colour, Group(1) intensity, Group(1) range, Bool, Group(1) scatter.
+ var fields = new List();
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[0]);
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[1]);
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[2]);
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[3]);
+ return EsfNode.NewRecord("POINT_LIGHT_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ case CscElementKind.SpotLight:
+ {
+ // Group(3) colour, Group(1) intensity/length/inner/outer, F32(falloff),
+ // Ascii(gobo), F32, Bool, Group(1) scatter.
+ var fields = new List();
+ for (var i = 0; i < 5; i++)
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[i]);
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, 1f));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, ""));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, 0f, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[5]);
+ return EsfNode.NewRecord("SPOT_LIGHT_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ case CscElementKind.Camera:
+ {
+ // Group(fov), Group(roll), Bool(unidentified), Group(near), Group(far), then
+ // the remaining 9 groups (always default/inert) - confirmed order, every version.
+ var fields = new List();
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[0]);
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[1]);
+ var unknownFlagNode = EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true);
+ fields.Add(unknownFlagNode);
+ element.CameraUnknownFlagNode = unknownFlagNode;
+ element.CameraUnknownFlag = false;
+ for (var i = 2; i < element.TypeGroups.Count; i++)
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[i]);
+ return EsfNode.NewRecord("CAMERA_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ case CscElementKind.Prefab:
+ return EsfNode.NewRecord("PREFAB_ELEMENT", 0, EsfRecordFlags.IsRecordNode,
+ [[EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath)]]);
+
+ case CscElementKind.RootRef:
+ {
+ var fields = new List { EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath) };
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[0]);
+ CscSceneWriter.AppendChannelGroup(fields, element.TypeGroups[1]);
+ return EsfNode.NewRecord("ROOT_REF_ELEMENT", 100, RecordV100Flags, [fields]);
+ }
+
+ default:
+ return null; // Locator: an ELEMENT with no type record.
+ }
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Data/CscScene.cs b/Editors/CscEditor/Editors.CscEditor/Data/CscScene.cs
new file mode 100644
index 000000000..4be6c521c
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Data/CscScene.cs
@@ -0,0 +1,691 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using Shared.GameFormats.Csc;
+using Shared.GameFormats.Esf;
+
+namespace Editors.CscEditor.Data
+{
+ public enum CscManifestKind
+ {
+ /// No composite-scene tail at all (scenes with no keyframe manifest).
+ None,
+ /// Tail is a COMPOSITE_SCENE record (the modern, common form).
+ WrappedRecord,
+ /// Tail is the same payload inlined without a record wrapper (old ROOT v1-v3 files).
+ Inline,
+ }
+
+ /// One entry of COMPOSITE_SCENE's entry section, kept raw plus its decoded element ids.
+ public class CscManifestEntry
+ {
+ public List Fields { get; set; } = [];
+ public List ElementIds { get; set; } = [];
+ }
+
+ ///
+ /// A decoded .csc scene: ROOT's header, the element list, the parent/child attach tree and
+ /// COMPOSITE_SCENE's manifest, all editable, with every not-understood field preserved as raw
+ /// ESF nodes so a save round-trips everything the editor doesn't model.
+ ///
+ public class CscScene
+ {
+ public EsfDocument Document { get; private set; } = null!;
+ public byte RootVersion { get; private set; }
+
+ /// ROOT's version-dependent header fields, preserved verbatim (field 0 is the scene duration).
+ public List HeaderFields { get; private set; } = [];
+
+ /// All elements in ROOT encounter order (the order element groups are written back in).
+ public List Elements { get; } = [];
+
+ /// Top-level elements (attach-tree children of -1), in attach order.
+ public List RootElements { get; } = [];
+
+ // ---- Composite-scene tail ----
+ public CscManifestKind ManifestKind { get; private set; } = CscManifestKind.None;
+ public bool ManifestParsed { get; private set; }
+ public string CompositeSceneRecordName { get; private set; } = "COMPOSITE_SCENE";
+ public byte CompositeSceneVersion { get; private set; }
+ public EsfRecordFlags CompositeSceneFlags { get; private set; }
+ public List ManifestEntries { get; } = [];
+ public List ManifestLeftoverFields { get; private set; } = [];
+
+ /// Tail fields (everything after the attach section) preserved raw; when the tail
+ /// contains a parsed COMPOSITE_SCENE record, marks the
+ /// slot the rebuilt record is written back into.
+ public List TailFields { get; private set; } = [];
+ public int TailSceneRecordIndex { get; private set; } = -1;
+
+ public float Duration
+ {
+ get => HeaderFields.Count > 0 && HeaderFields[0].Value is float f ? f : 20f;
+ set
+ {
+ if (HeaderFields.Count > 0 && HeaderFields[0].Kind == EsfNodeKind.F32)
+ HeaderFields[0] = EsfNode.Leaf(EsfNodeKind.F32, value, HeaderFields[0].Optimized);
+ }
+ }
+
+ /// ROOT header field 1 (Coord3d, present in every version): a world-space focus/
+ /// anchor point for the scene - corpus-confirmed usually zero, non-zero only
+ /// on dramatic set-piece scenes, and never matching a placed element's own position, so
+ /// it's independently authored rather than derived.
+ public Vector3 FocusPoint
+ {
+ get => HeaderFields.Count > 1 && HeaderFields[1].Value is Coord3d c ? new Vector3(c.X, c.Y, c.Z) : Vector3.Zero;
+ set
+ {
+ if (HeaderFields.Count > 1 && HeaderFields[1].Kind == EsfNodeKind.Coord3d)
+ HeaderFields[1] = EsfNode.Leaf(EsfNodeKind.Coord3d, new Coord3d(value.X, value.Y, value.Z), HeaderFields[1].Optimized);
+ }
+ }
+
+ /// ROOT header field 2 (F32, present in every version): a secondary, rarely-used
+ /// distance/radius - corpus-confirmed dominated by sentinel values 0 ("unset")
+ /// and -1 ("disabled"), otherwise a real round distance, usually smaller than Duration.
+ public float Radius
+ {
+ get => HeaderFields.Count > 2 && HeaderFields[2].Value is float f ? f : 0f;
+ set
+ {
+ if (HeaderFields.Count > 2 && HeaderFields[2].Kind == EsfNodeKind.F32)
+ HeaderFields[2] = EsfNode.Leaf(EsfNodeKind.F32, value, HeaderFields[2].Optimized);
+ }
+ }
+
+ /// Whether this file's ROOT version has the weather/environment path header field
+ /// at all (v1 doesn't - its header is only the leading Duration/FocusPoint/Radius trio).
+ public bool HasWeatherPath => HeaderFields.Count > 3;
+
+ /// ROOT header field 3 (Ascii, v2+ only): a weather/environment preset asset path
+ /// - corpus-confirmed as a real .environment file path on the ~5% of files
+ /// that use it (frontend lord-select-screen ambience scenes and similar), empty otherwise.
+ public string WeatherPath
+ {
+ get => HeaderFields.Count > 3 && HeaderFields[3].Value is string s ? s : "";
+ set
+ {
+ if (HeaderFields.Count > 3 && HeaderFields[3].Kind == EsfNodeKind.Ascii)
+ HeaderFields[3] = EsfNode.Leaf(EsfNodeKind.Ascii, value, HeaderFields[3].Optimized);
+ }
+ }
+
+ /// Whether nested elements sit after their carrier's attach-tree children (rather
+ /// than directly after the carrier) in the manifest's DFS order. Resolved per file at load
+ /// by validating manifest keyframe counts against the actual channel data.
+ public bool NestedAfterAttachChildren { get; private set; }
+
+ public IEnumerable AllElementsIncludingNested()
+ {
+ foreach (var element in Elements)
+ {
+ yield return element;
+ foreach (var nested in NestedDescendants(element))
+ yield return nested;
+ }
+ }
+
+ static IEnumerable NestedDescendants(CscElement element)
+ {
+ foreach (var child in element.Children.Where(c => c.IsNested))
+ {
+ yield return child;
+ foreach (var deeper in NestedDescendants(child))
+ yield return deeper;
+ }
+ }
+
+ public CscElement? FindElement(int id) => AllElementsIncludingNested().FirstOrDefault(e => e.Id == id);
+
+ public int NextFreeElementId()
+ {
+ var all = AllElementsIncludingNested().ToList();
+ return all.Count == 0 ? 0 : all.Max(e => e.Id) + 1;
+ }
+
+ /// Attach-tree depth-first order: roots in attach order, then children recursively
+ /// - the order COMPOSITE_SCENE's manifest groups are stored in (corpus-verified). Nested
+ /// elements (inside variant models) are included only when
+ /// is set, positioned according to .
+ public List DfsOrder(bool includeNested = false)
+ {
+ var result = new List();
+ void Visit(CscElement e)
+ {
+ result.Add(e);
+ var nested = e.Children.Where(c => c.IsNested);
+ var attached = e.Children.Where(c => !c.IsNested);
+ var ordered = NestedAfterAttachChildren ? attached.Concat(nested) : nested.Concat(attached);
+ foreach (var child in ordered)
+ {
+ if (child.IsNested && !includeNested)
+ continue;
+ Visit(child);
+ }
+ }
+ foreach (var root in RootElements)
+ Visit(root);
+ return result;
+ }
+
+ // ---------------------------------------------------------------------
+ // Loading
+ // ---------------------------------------------------------------------
+
+ public static CscScene Load(byte[] fileData)
+ {
+ var scene = new CscScene();
+ using var stream = new MemoryStream(fileData);
+ scene.Document = EsfReader.Read(stream);
+
+ var root = scene.Document.Root;
+ if (root.Groups == null || root.Groups.Count != 1)
+ throw new InvalidDataException("ROOT record does not have the expected single field group.");
+
+ var rootFields = root.Groups[0];
+ scene.RootVersion = root.Version;
+
+ var structure = RootStructureDetector.Detect(rootFields, root.Version)
+ ?? throw new InvalidDataException(
+ $"ROOT v{root.Version} did not match the known .csc scene grammar - the file may use an unseen layout.");
+
+ scene.HeaderFields = rootFields.Take(structure.HeaderLength).ToList();
+
+ foreach (var group in structure.ElementGroups)
+ {
+ var element = DecodeElement(group);
+ element.Scene = scene;
+ scene.Elements.Add(element);
+ }
+
+ // Attach tree: every element appears exactly once as a child (of -1 or a real parent).
+ var elementById = scene.Elements.ToDictionary(e => e.Id);
+ foreach (var attachGroup in structure.AttachGroups)
+ {
+ var parent = attachGroup.ParentId >= 0 ? elementById.GetValueOrDefault(attachGroup.ParentId) : null;
+ foreach (var childInfo in attachGroup.Children)
+ {
+ if (!elementById.TryGetValue(childInfo.ElementId, out var child))
+ continue;
+
+ child.AttachBoneIndex = childInfo.AttachValue;
+ if (rootFields[childInfo.StartIndex + 1] is { IsRecord: true } attachRecord)
+ child.AttachRecord = attachRecord;
+
+ if (parent != null)
+ {
+ child.Parent = parent;
+ parent.Children.Add(child);
+ }
+ else
+ {
+ scene.RootElements.Add(child);
+ }
+ }
+ }
+
+ scene.LoadTail(rootFields, structure.TailStartIndex);
+ return scene;
+ }
+
+ void LoadTail(List rootFields, int tailStartIndex)
+ {
+ TailFields = rootFields.Skip(tailStartIndex).ToList();
+ TailSceneRecordIndex = TailFields.FindIndex(f => f is { IsRecord: true, Name: "COMPOSITE_SCENE" });
+
+ List manifestRegion;
+ if (TailSceneRecordIndex >= 0)
+ {
+ var sceneRecord = TailFields[TailSceneRecordIndex];
+ ManifestKind = CscManifestKind.WrappedRecord;
+ CompositeSceneRecordName = sceneRecord.Name!;
+ CompositeSceneVersion = sceneRecord.Version;
+ CompositeSceneFlags = sceneRecord.RecordFlags;
+ if (sceneRecord.Groups == null || sceneRecord.Groups.Count != 1)
+ return;
+ manifestRegion = sceneRecord.Groups[0];
+ }
+ else if (TailFields.Count > 0)
+ {
+ ManifestKind = CscManifestKind.Inline;
+ manifestRegion = TailFields;
+ }
+ else
+ {
+ ManifestKind = CscManifestKind.None;
+ ManifestParsed = true;
+ return;
+ }
+
+ ParseManifest(manifestRegion);
+ }
+
+ void ParseManifest(List region)
+ {
+ // Leading I32(groupCount) + groups in attach-tree DFS order.
+ if (region.Count == 0 || region[0].Kind != EsfNodeKind.I32 || region[0].Value is not int groupCount || groupCount < 0)
+ return;
+
+ var groups = CompositeSceneDetector.DetectGroups(region);
+
+ // DetectGroups scans the whole region; only the run starting right after the count
+ // field, containing exactly groupCount groups, is the real manifest.
+ groups = groups.Where(g => g.StartIndex >= 1).Take(groupCount).ToList();
+ if (groups.Count != groupCount)
+ return;
+
+ // Contiguity check: groups must butt up against each other starting at field 1.
+ var expectedStart = 1;
+ foreach (var group in groups)
+ {
+ if (group.StartIndex != expectedStart)
+ return;
+ expectedStart += group.FieldCount;
+ }
+
+ // Resolve the DFS order over all elements (nested included). The manifest is a
+ // keyframe-count restatement, so a candidate order is provably right when every
+ // group's counts match its element's actual channel keyframe counts - this also
+ // settles where nested elements sit relative to attach children per file.
+ List? dfs = null;
+ foreach (var nestedAfter in new[] { false, true })
+ {
+ NestedAfterAttachChildren = nestedAfter;
+ var candidate = DfsOrder(includeNested: true);
+ if (candidate.Count == groupCount && ManifestCountsMatch(candidate, groups))
+ {
+ dfs = candidate;
+ break;
+ }
+ }
+
+ if (dfs == null)
+ {
+ NestedAfterAttachChildren = false;
+ return; // unverifiable mapping (e.g. legacy layouts) - keep the manifest verbatim.
+ }
+
+ for (var i = 0; i < dfs.Count; i++)
+ {
+ var g = groups[i];
+ dfs[i].ManifestGroupFields = region.Skip(g.StartIndex).Take(g.FieldCount).ToList();
+ }
+
+ var pos = expectedStart;
+ if (CompositeSceneDetector.DetectEntrySection(region, pos) is not { } entrySection)
+ return;
+ pos += entrySection.FieldCount;
+
+ foreach (var entry in entrySection.Entries)
+ {
+ ManifestEntries.Add(new CscManifestEntry
+ {
+ Fields = region.Skip(entry.StartIndex).Take(entry.FieldCount).ToList(),
+ ElementIds = entry.ElementIds.ToList(),
+ });
+ }
+
+ if (CompositeSceneDetector.DetectFooter(region, pos, entrySection.Entries.Count) is not { } footer)
+ {
+ ManifestEntries.Clear();
+ return;
+ }
+ pos += footer.FieldCount;
+
+ ManifestLeftoverFields = region.Skip(pos).ToList();
+ ManifestParsed = true;
+ }
+
+ static bool ManifestCountsMatch(List candidate, List groups)
+ {
+ for (var i = 0; i < candidate.Count; i++)
+ {
+ var manifestCounts = groups[i].Channels
+ .SelectMany(c => c.SubComponents)
+ .Select(s => s.KeyframeCount);
+ if (!manifestCounts.SequenceEqual(candidate[i].AllChannelKeyframeCounts()))
+ return false;
+ }
+ return true;
+ }
+
+ // ---------------------------------------------------------------------
+ // Element decoding
+ // ---------------------------------------------------------------------
+
+ static readonly Dictionary _kindByRecordName = new()
+ {
+ ["MODEL_ELEMENT"] = CscElementKind.Model,
+ ["VARIANT_MODEL_ELEMENT"] = CscElementKind.VariantModel,
+ ["VFX_ELEMENT"] = CscElementKind.Vfx,
+ ["SFX_ELEMENT"] = CscElementKind.Sfx,
+ ["ANIMATION_ELEMENT"] = CscElementKind.Animation,
+ ["ANIMATION_NODE_TRANSFORM_ELEMENT"] = CscElementKind.AnimationNodeTransform,
+ ["CAMERA_ELEMENT"] = CscElementKind.Camera,
+ ["POINT_LIGHT_ELEMENT"] = CscElementKind.PointLight,
+ ["SPOT_LIGHT_ELEMENT"] = CscElementKind.SpotLight,
+ ["PREFAB_ELEMENT"] = CscElementKind.Prefab,
+ ["ROOT_REF_ELEMENT"] = CscElementKind.RootRef,
+ ["SOUND_SPHERE_ELEMENT"] = CscElementKind.SoundSphere,
+ ["CAMERA_SHAKE_ELEMENT"] = CscElementKind.CameraShake,
+ };
+
+ static CscElement DecodeElement(RootStructureDetector.ElementGroupInfo group) =>
+ DecodeElementBundle(group.Label, group.Records.ToList());
+
+ static CscElement DecodeElementBundle(string label, List records)
+ {
+ var element = new CscElement
+ {
+ GroupLabel = label,
+ Records = records,
+ };
+
+ element.ElementRecord = records.FirstOrDefault(r => r.Name == "ELEMENT");
+ element.PeriodRecord = records.FirstOrDefault(r => r.Name == "ELEMENT_PERIOD");
+ element.SpliceRecord = records.FirstOrDefault(r => r.Name == "ANIMATION_SPLICE_ELEMENT");
+
+ foreach (var record in records)
+ {
+ if (record.Name != null && _kindByRecordName.TryGetValue(record.Name, out var kind))
+ {
+ element.Kind = kind;
+ element.TypeRecord = record;
+ break;
+ }
+ }
+ if (element.TypeRecord == null)
+ element.Kind = CscElementKind.Locator;
+
+ if (element.ElementRecord != null)
+ DecodeElementRecord(element, element.ElementRecord);
+ if (element.PeriodRecord != null)
+ DecodePeriodRecord(element, element.PeriodRecord);
+ if (element.SpliceRecord != null)
+ DecodeSpliceRecord(element, element.SpliceRecord);
+ if (element.TypeRecord != null)
+ DecodeTypeRecord(element, element.TypeRecord);
+
+ return element;
+ }
+
+ static void DecodeElementRecord(CscElement element, EsfNode record)
+ {
+ element.ElementVersion = record.Version;
+ var fields = record.Children.ToList();
+
+ if (fields.Count > 0 && fields[0].Value is uint id)
+ element.Id = (int)id;
+ if (fields.Count > 1 && fields[1].Value is string headerString)
+ element.HeaderString = headerString;
+ if (fields.Count > 3 && fields[2].Value is float begin && fields[3].Value is float end)
+ {
+ element.Begin = begin;
+ element.End = end;
+ }
+ if (fields.Count > 5 && fields[4].Value is string timing && fields[5].Value is string anchor)
+ {
+ element.TimingMode = timing;
+ element.AnchorMode = anchor;
+ }
+
+ // The v6/v7/v100 shape: header(6) + Group(3) pos + Group(3) rot + Group(1) scale
+ // + Group(1) weight + Coord3d + Coord3d + trailing extras. Anything else (legacy v5)
+ // is preserved verbatim and read-only.
+ var groups = CurveDetector.DetectGroups(fields);
+ var contiguous = groups.Count == 4 && groups[0].StartIndex == 6;
+ if (contiguous)
+ {
+ var expected = 6;
+ foreach (var g in groups)
+ {
+ if (g.StartIndex != expected)
+ {
+ contiguous = false;
+ break;
+ }
+ expected += g.FieldCount;
+ }
+
+ var trailingStart = expected;
+ contiguous = contiguous
+ && groups[0].Channels.Count == 3 && groups[1].Channels.Count == 3
+ && groups[2].Channels.Count == 1 && groups[3].Channels.Count == 1
+ && trailingStart + 1 < fields.Count
+ && fields[trailingStart].Kind == EsfNodeKind.Coord3d
+ && fields[trailingStart + 1].Kind == EsfNodeKind.Coord3d;
+
+ if (contiguous)
+ {
+ element.Position = ToChannelGroup(fields, groups[0]);
+ element.Rotation = ToChannelGroup(fields, groups[1]);
+ element.Scale = ToChannelGroup(fields, groups[2]);
+ element.Weight = ToChannelGroup(fields, groups[3]);
+
+ var basePos = (Coord3d)fields[trailingStart].Value!;
+ var baseRot = (Coord3d)fields[trailingStart + 1].Value!;
+ element.BasePosition = new Vector3(basePos.X, basePos.Y, basePos.Z);
+ element.BaseRotation = new Vector3(baseRot.X, baseRot.Y, baseRot.Z);
+ element.ElementTrailingFields = fields.Skip(trailingStart + 2).ToList();
+ }
+ }
+
+ if (!contiguous)
+ element.IsLegacyRaw = true;
+ }
+
+ static void DecodePeriodRecord(CscElement element, EsfNode record)
+ {
+ var fields = record.Children.ToList();
+ if (fields.Count >= 3 && fields[0].Value is bool flag && fields[1].Value is float speed && fields[2].Value is float offset)
+ {
+ element.PeriodFlag = flag;
+ element.PeriodSpeedMultiplier = speed;
+ element.PeriodTimeOffset = offset;
+ }
+ }
+
+ static void DecodeSpliceRecord(CscElement element, EsfNode record)
+ {
+ var fields = record.Children.ToList();
+ if (fields.Count > 0 && fields[0].Value is uint bone)
+ element.SpliceBoneId = unchecked((int)bone);
+ if (fields.Count > 1 && fields[1].Value is uint depthA)
+ element.SpliceDepthA = unchecked((int)depthA);
+ if (fields.Count > 2 && fields[2].Value is uint depthB)
+ element.SpliceDepthB = unchecked((int)depthB);
+ element.SpliceRemainingFieldsDisplay = string.Join(", ", fields.Skip(3).Select(f => $"{f.Kind}={f.Value}"));
+ }
+
+ static void DecodeTypeRecord(CscElement element, EsfNode record)
+ {
+ var fields = record.Children.ToList();
+
+ if (element.Kind == CscElementKind.AnimationNodeTransform)
+ {
+ if (fields.Count > 0 && fields[0].Value is uint boneId)
+ element.NodeTransformBoneId = unchecked((int)boneId);
+ if (fields.Count > 1 && fields[1].Value is uint second)
+ element.NodeTransformSecondValue = unchecked((int)second);
+ }
+
+ if (fields.Count > 0 && fields[0].Kind == EsfNodeKind.Ascii && fields[0].Value is string path)
+ element.AssetPath = path;
+ if (element.Kind == CscElementKind.Sfx)
+ {
+ if (fields.Count > 1 && fields[1].Value is string stopEvent)
+ element.SfxStopEvent = stopEvent;
+
+ // Versions with 4+ leading Ascii fields carry a second, independent (start, stop)
+ // Wwise event pair - confirmed, see CscElement.SfxHasSecondEventPair.
+ if (fields.Count > 3 && fields[2].Kind == EsfNodeKind.Ascii && fields[3].Kind == EsfNodeKind.Ascii)
+ {
+ element.SfxHasSecondEventPair = true;
+ element.SfxEvent2Start = fields[2].Value as string ?? "";
+ element.SfxEvent2Stop = fields[3].Value as string ?? "";
+ }
+ }
+
+ // Camera/light/root-ref records carry the same Group/Channel/Curve grammar as ELEMENT.
+ if (element.Kind is CscElementKind.Camera or CscElementKind.PointLight or CscElementKind.SpotLight or CscElementKind.RootRef)
+ {
+ var runs = CurveDetector.DetectGroups(fields);
+ foreach (var run in runs)
+ element.TypeGroups.Add(ToChannelGroup(fields, run));
+
+ // Every CAMERA_ELEMENT (any version) carries an extra Bool between the roll and near
+ // groups (group indexes 1 and 2) that isn't part of the Group/Channel/Curve grammar
+ // itself - corpus-confirmed field order (fov, roll, bool, near, far), meaning not yet identified.
+ if (element.Kind == CscElementKind.Camera && runs.Count > 1)
+ {
+ var boolIndex = runs[1].StartIndex + runs[1].FieldCount;
+ if (boolIndex < fields.Count && fields[boolIndex].Kind == EsfNodeKind.Bool)
+ {
+ element.CameraUnknownFlagNode = fields[boolIndex];
+ element.CameraUnknownFlag = fields[boolIndex].Value is true;
+ }
+ }
+ }
+
+ DecodeNestedElements(element, fields);
+ }
+
+ ///
+ /// Type records (variant models especially) can carry nested ELEMENT bundles as record
+ /// nodes among their fields - e.g. a prop's idle animation attached to the prop itself.
+ /// Each bundle is an ELEMENT record plus the immediately following non-ELEMENT records.
+ ///
+ static void DecodeNestedElements(CscElement carrier, List fields)
+ {
+ var i = 0;
+ while (i < fields.Count)
+ {
+ if (fields[i] is not { IsRecord: true, Name: "ELEMENT" })
+ {
+ i++;
+ continue;
+ }
+
+ var bundle = new List { fields[i] };
+ var j = i + 1;
+ while (j < fields.Count && fields[j].IsRecord && fields[j].Name != "ELEMENT")
+ bundle.Add(fields[j++]);
+
+ var nested = DecodeElementBundle("nested", bundle);
+ nested.IsNested = true;
+ nested.Parent = carrier;
+ carrier.Children.Add(nested);
+ i = j;
+ }
+ }
+
+ internal static CscChannelGroup ToChannelGroup(List fields, CurveDetector.GroupRun run)
+ {
+ var group = new CscChannelGroup
+ {
+ Marker = fields[run.StartIndex].Value is uint marker ? marker : 1,
+ };
+
+ foreach (var channelRun in run.Channels)
+ {
+ var channel = new CscChannel
+ {
+ Header = channelRun.Curve.Header,
+ HasModeTags = channelRun.HeaderFieldCount == 3,
+ };
+ if (channel.HasModeTags)
+ {
+ channel.ModeA = (string)fields[channelRun.StartIndex + 1].Value!;
+ channel.ModeB = (string)fields[channelRun.StartIndex + 2].Value!;
+ }
+
+ foreach (var point in channelRun.Curve.Points)
+ {
+ channel.Keyframes.Add(new CscKeyframe
+ {
+ Time = point.X,
+ Value = point.Y,
+ ModeIn = point.ModeIn,
+ TangentIn = point.TangentIn,
+ TangentOut = point.TangentOut,
+ ModeOut = point.ModeOut,
+ });
+ }
+
+ group.Channels.Add(channel);
+ }
+
+ return group;
+ }
+
+ // ---------------------------------------------------------------------
+ // Structural edits
+ // ---------------------------------------------------------------------
+
+ public void AddElement(CscElement element, CscElement? parent = null)
+ {
+ element.Scene = this;
+ Elements.Add(element);
+ if (parent != null)
+ {
+ element.Parent = parent;
+ parent.Children.Add(element);
+ }
+ else
+ {
+ RootElements.Add(element);
+ }
+ }
+
+ public void RemoveElementSubtree(CscElement element)
+ {
+ if (element.IsNested || element.IsExternal)
+ return; // nested/external elements are not part of this scene's editable data
+
+ foreach (var child in element.Children.Where(c => !c.IsNested).ToList())
+ RemoveElementSubtree(child);
+
+ // Nested descendants disappear with the carrier - drop their manifest entries too.
+ foreach (var nested in NestedDescendants(element))
+ ManifestEntries.RemoveAll(entry => entry.ElementIds.Contains(nested.Id));
+
+ if (element.Parent != null)
+ element.Parent.Children.Remove(element);
+ else
+ RootElements.Remove(element);
+
+ Elements.Remove(element);
+
+ var deadId = element.Id;
+ ManifestEntries.RemoveAll(entry => entry.ElementIds.Contains(deadId));
+ }
+
+ /// Reparents an element (null = make top-level). Returns false when the move
+ /// would create a cycle.
+ public bool Reparent(CscElement element, CscElement? newParent)
+ {
+ if (element.IsNested || (newParent?.IsNested ?? false))
+ return false; // nested elements are bound to their carrier record
+ if (element.IsExternal || (newParent?.IsExternal ?? false))
+ return false; // root-ref sub-scene elements are display-only
+ if (newParent == element || (newParent != null && newParent.IsInSubtreeOf(element)))
+ return false;
+
+ if (element.Parent != null)
+ element.Parent.Children.Remove(element);
+ else
+ RootElements.Remove(element);
+
+ element.Parent = newParent;
+ if (newParent != null)
+ newParent.Children.Add(element);
+ else
+ RootElements.Add(element);
+
+ return true;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Data/CscSceneWriter.cs b/Editors/CscEditor/Editors.CscEditor/Data/CscSceneWriter.cs
new file mode 100644
index 000000000..c8e254b7f
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Data/CscSceneWriter.cs
@@ -0,0 +1,458 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Shared.GameFormats.Csc;
+using Shared.GameFormats.Esf;
+
+namespace Editors.CscEditor.Data
+{
+ ///
+ /// Serializes an edited back to .csc bytes. Rebuilds exactly the parts
+ /// the editor models - ELEMENT records, the attach tree, and COMPOSITE_SCENE's manifest/
+ /// entries/footer - and passes every other field through verbatim from the loaded file.
+ /// The output is re-read and re-detected as a self-check before being returned.
+ ///
+ public static class CscSceneWriter
+ {
+ public static byte[] Write(CscScene scene)
+ {
+ var rootFields = new List(scene.HeaderFields);
+
+ // Element groups, in scene order.
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.U32, (uint)scene.Elements.Count, optimized: true));
+ foreach (var element in scene.Elements)
+ {
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, element.GroupLabel));
+ rootFields.AddRange(RebuildElementRecords(element));
+ }
+
+ // Attach tree: the -1 bucket (top-level elements) first, then a bucket per parent that
+ // has children, in DFS order - every top-level element appears exactly once as a
+ // child. Nested elements live inside their carrier's type record, not here.
+ var buckets = new List<(int ParentId, List Children)>();
+ if (scene.RootElements.Count > 0)
+ buckets.Add((-1, scene.RootElements.ToList()));
+ foreach (var element in scene.DfsOrder())
+ {
+ var attachChildren = element.Children.Where(c => !c.IsNested).ToList();
+ if (attachChildren.Count > 0)
+ buckets.Add((element.Id, attachChildren));
+ }
+
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.U32, (uint)buckets.Count, optimized: true));
+ foreach (var (parentId, children) in buckets)
+ {
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)parentId), optimized: true));
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.U32, (uint)children.Count, optimized: true));
+ foreach (var child in children)
+ {
+ rootFields.Add(EsfNode.Leaf(EsfNodeKind.U32, (uint)child.Id, optimized: true));
+ rootFields.Add(BuildAttachRecord(child));
+ }
+ }
+
+ rootFields.AddRange(RebuildTail(scene));
+
+ var oldRoot = scene.Document.Root;
+ var newRoot = EsfNode.NewRecord(oldRoot.Name!, oldRoot.Version, oldRoot.RecordFlags, [rootFields]);
+ var newDocument = new EsfDocument
+ {
+ Signature = scene.Document.Signature,
+ Unknown1 = scene.Document.Unknown1,
+ CreationDate = scene.Document.CreationDate,
+ Root = newRoot,
+ };
+
+ using var stream = new MemoryStream();
+ EsfWriter.Write(newDocument, stream);
+ var bytes = stream.ToArray();
+
+ SelfCheck(scene, bytes);
+ return bytes;
+ }
+
+ static void SelfCheck(CscScene scene, byte[] bytes)
+ {
+ using var checkStream = new MemoryStream(bytes);
+ var reread = EsfReader.Read(checkStream);
+ var structure = RootStructureDetector.Detect(reread.Root.Groups![0], reread.Root.Version);
+ if (structure == null || structure.ElementGroups.Count != scene.Elements.Count)
+ throw new InvalidOperationException(
+ "Save self-check failed: the written file does not re-detect as a valid scene structure. The file was NOT saved.");
+ }
+
+ // ---------------------------------------------------------------------
+ // Element records
+ // ---------------------------------------------------------------------
+
+ static List RebuildElementRecords(CscElement element)
+ {
+ var records = new List();
+ foreach (var record in element.Records)
+ {
+ if (element.IsLegacyRaw)
+ {
+ records.Add(record);
+ continue;
+ }
+
+ if (record == element.ElementRecord)
+ records.Add(RebuildElementRecord(element, record));
+ else if (record == element.PeriodRecord)
+ records.Add(RebuildPeriodRecord(element, record));
+ else if (record == element.SpliceRecord)
+ records.Add(RebuildSpliceRecord(element, record));
+ else if (record == element.TypeRecord)
+ records.Add(RebuildTypeRecord(element, record));
+ else
+ records.Add(record);
+ }
+ return records;
+ }
+
+ static EsfNode RebuildRecord(EsfNode original, List newFields) =>
+ EsfNode.NewRecord(original.Name!, original.Version, original.RecordFlags, [newFields]);
+
+ static bool HasSingleGroup(EsfNode record) => record.Groups is { Count: 1 };
+
+ static EsfNode RebuildElementRecord(CscElement element, EsfNode record)
+ {
+ if (!HasSingleGroup(record) || element.Position == null || element.Rotation == null ||
+ element.Scale == null || element.Weight == null)
+ return record;
+
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.U32, (uint)element.Id, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.HeaderString),
+ EsfNode.Leaf(EsfNodeKind.F32, element.Begin, optimized: element.Begin == 0),
+ EsfNode.Leaf(EsfNodeKind.F32, element.End),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.TimingMode),
+ EsfNode.Leaf(EsfNodeKind.Ascii, element.AnchorMode),
+ };
+
+ foreach (var group in element.ElementGroups())
+ AppendChannelGroup(fields, group);
+
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord3d, new Coord3d(element.BasePosition.X, element.BasePosition.Y, element.BasePosition.Z)));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord3d, new Coord3d(element.BaseRotation.X, element.BaseRotation.Y, element.BaseRotation.Z)));
+ fields.AddRange(element.ElementTrailingFields);
+
+ return RebuildRecord(record, fields);
+ }
+
+ static EsfNode RebuildPeriodRecord(CscElement element, EsfNode record)
+ {
+ if (!HasSingleGroup(record) || record.Groups![0].Count < 3)
+ return record;
+
+ var fields = record.Groups[0].ToList();
+ fields[0] = EsfNode.Leaf(EsfNodeKind.Bool, element.PeriodFlag, fields[0].Optimized);
+ fields[1] = EsfNode.Leaf(EsfNodeKind.F32, element.PeriodSpeedMultiplier, fields[1].Optimized && element.PeriodSpeedMultiplier == 0);
+ fields[2] = EsfNode.Leaf(EsfNodeKind.F32, element.PeriodTimeOffset, fields[2].Optimized && element.PeriodTimeOffset == 0);
+ return RebuildRecord(record, fields);
+ }
+
+ static EsfNode RebuildSpliceRecord(CscElement element, EsfNode record)
+ {
+ if (!HasSingleGroup(record) || record.Groups![0].Count < 3)
+ return record;
+
+ var fields = record.Groups[0].ToList();
+ fields[0] = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.SpliceBoneId), fields[0].Optimized);
+ fields[1] = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.SpliceDepthA), fields[1].Optimized);
+ fields[2] = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.SpliceDepthB), fields[2].Optimized);
+ return RebuildRecord(record, fields);
+ }
+
+ static EsfNode RebuildTypeRecord(CscElement element, EsfNode record)
+ {
+ if (!HasSingleGroup(record))
+ return record;
+
+ var originalFields = record.Groups![0];
+ var fields = new List(originalFields.Count);
+
+ // Channel-group runs are re-detected on the original fields and spliced with the
+ // edited groups; everything between/around them passes through verbatim.
+ var runs = element.Kind is CscElementKind.Camera or CscElementKind.PointLight or CscElementKind.SpotLight or CscElementKind.RootRef
+ ? CurveDetector.DetectGroups(originalFields)
+ : [];
+ var canSpliceGroups = runs.Count == element.TypeGroups.Count;
+
+ // Nested elements (inside variant models etc.) have their records rebuilt in place.
+ var nestedRecordReplacements = new Dictionary();
+ foreach (var nested in element.Children.Where(c => c.IsNested))
+ {
+ var rebuilt = RebuildElementRecords(nested);
+ for (var r = 0; r < nested.Records.Count; r++)
+ nestedRecordReplacements[nested.Records[r]] = rebuilt[r];
+ }
+
+ var i = 0;
+ var runIndex = 0;
+ while (i < originalFields.Count)
+ {
+ if (canSpliceGroups && runIndex < runs.Count && runs[runIndex].StartIndex == i)
+ {
+ AppendChannelGroup(fields, element.TypeGroups[runIndex]);
+ i += runs[runIndex].FieldCount;
+ runIndex++;
+ continue;
+ }
+
+ if (nestedRecordReplacements.TryGetValue(originalFields[i], out var replacement))
+ {
+ fields.Add(replacement);
+ i++;
+ continue;
+ }
+
+ if (element.CameraUnknownFlagNode != null && ReferenceEquals(originalFields[i], element.CameraUnknownFlagNode))
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, element.CameraUnknownFlag));
+ i++;
+ continue;
+ }
+
+ fields.Add(originalFields[i]);
+ i++;
+ }
+
+ if (element.Kind == CscElementKind.AnimationNodeTransform)
+ {
+ if (fields.Count > 0 && fields[0].Kind == EsfNodeKind.U32)
+ fields[0] = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.NodeTransformBoneId), fields[0].Optimized);
+ if (fields.Count > 1 && fields[1].Kind == EsfNodeKind.U32)
+ fields[1] = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.NodeTransformSecondValue), fields[1].Optimized);
+ }
+
+ // Asset path / event-name patches on the leading Ascii fields.
+ if (fields.Count > 0 && fields[0].Kind == EsfNodeKind.Ascii)
+ fields[0] = EsfNode.Leaf(EsfNodeKind.Ascii, element.AssetPath);
+ if (element.Kind == CscElementKind.Sfx)
+ {
+ if (fields.Count > 1 && fields[1].Kind == EsfNodeKind.Ascii)
+ fields[1] = EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxStopEvent);
+ if (element.SfxHasSecondEventPair && fields.Count > 3 &&
+ fields[2].Kind == EsfNodeKind.Ascii && fields[3].Kind == EsfNodeKind.Ascii)
+ {
+ fields[2] = EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxEvent2Start);
+ fields[3] = EsfNode.Leaf(EsfNodeKind.Ascii, element.SfxEvent2Stop);
+ }
+ }
+
+ return RebuildRecord(record, fields);
+ }
+
+ static EsfNode BuildAttachRecord(CscElement element)
+ {
+ var value = EsfNode.Leaf(EsfNodeKind.U32, unchecked((uint)element.AttachBoneIndex), optimized: true);
+ if (element.AttachRecord != null)
+ return EsfNode.NewRecord(element.AttachRecord.Name!, element.AttachRecord.Version, element.AttachRecord.RecordFlags, [[value]]);
+ return EsfNode.NewRecord("ATTACH_TO_PARAMETERS", 0, EsfRecordFlags.IsRecordNode, [[value]]);
+ }
+
+ internal static void AppendChannelGroup(List fields, CscChannelGroup group)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.U32, group.Marker, optimized: true));
+ foreach (var channel in group.Channels)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, channel.Header, optimized: channel.Header == 0));
+ if (channel.HasModeTags)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, channel.ModeA));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, channel.ModeB));
+ }
+
+ fields.Add(EsfNode.Leaf(EsfNodeKind.U32, (uint)channel.Keyframes.Count, optimized: true));
+ foreach (var key in channel.Keyframes)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, key.Time, optimized: key.Time == 0));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.F32, key.Value, optimized: key.Value == 0));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, key.ModeIn));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord2d, key.TangentIn));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Coord2d, key.TangentOut));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Ascii, key.ModeOut));
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // COMPOSITE_SCENE tail
+ // ---------------------------------------------------------------------
+
+ static List RebuildTail(CscScene scene)
+ {
+ if (scene.ManifestKind == CscManifestKind.None)
+ return scene.TailFields;
+
+ if (!scene.ManifestParsed)
+ return scene.TailFields; // nested-element (porthole) layouts etc: preserved verbatim.
+
+ var manifestFields = BuildManifestFields(scene);
+
+ if (scene.ManifestKind == CscManifestKind.Inline)
+ return manifestFields;
+
+ var sceneRecord = EsfNode.NewRecord(
+ scene.CompositeSceneRecordName, scene.CompositeSceneVersion, scene.CompositeSceneFlags, [manifestFields]);
+
+ var tail = new List(scene.TailFields);
+ tail[scene.TailSceneRecordIndex] = sceneRecord;
+ return tail;
+ }
+
+ static List BuildManifestFields(CscScene scene)
+ {
+ var dfs = scene.DfsOrder(includeNested: true);
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.I32, dfs.Count, optimized: true),
+ };
+
+ foreach (var element in dfs)
+ fields.AddRange(BuildManifestGroup(element));
+
+ // Entry section: original entries whose elements all still exist (in original order),
+ // then a fresh single-id entry for any element no entry covers.
+ var liveIds = scene.AllElementsIncludingNested().Select(e => e.Id).ToHashSet();
+ var entries = scene.ManifestEntries.Where(e => e.ElementIds.All(liveIds.Contains)).ToList();
+ var coveredIds = entries.SelectMany(e => e.ElementIds).ToHashSet();
+ foreach (var element in dfs)
+ {
+ if (coveredIds.Contains(element.Id))
+ continue;
+ entries.Add(new CscManifestEntry
+ {
+ Fields =
+ [
+ EsfNode.Leaf(EsfNodeKind.Ascii, ""),
+ EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, 0, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, 0, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, 0, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, 1, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, element.Id, optimized: true),
+ ],
+ ElementIds = [element.Id],
+ });
+ }
+
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, entries.Count, optimized: true));
+ foreach (var entry in entries)
+ fields.AddRange(entry.Fields);
+
+ // Footer: entry i's unit lists the entry indexes of that entry's element's attachment
+ // children (single-id entries only - the corpus-confirmed adjacency reading).
+ var entryIndexByElementId = new Dictionary();
+ for (var i = 0; i < entries.Count; i++)
+ if (entries[i].ElementIds.Count == 1)
+ entryIndexByElementId.TryAdd(entries[i].ElementIds[0], i);
+
+ foreach (var entry in entries)
+ {
+ var childIndexes = new List();
+ if (entry.ElementIds.Count == 1)
+ {
+ var element = scene.FindElement(entry.ElementIds[0]);
+ if (element != null)
+ foreach (var child in element.Children)
+ if (entryIndexByElementId.TryGetValue(child.Id, out var idx))
+ childIndexes.Add(idx);
+ }
+
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, childIndexes.Count, optimized: true));
+ foreach (var idx in childIndexes)
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, idx, optimized: true));
+ }
+
+ fields.AddRange(scene.ManifestLeftoverFields);
+ return fields;
+ }
+
+ ///
+ /// Builds one element's manifest group. When the element was loaded with a manifest group,
+ /// its channel names and flags are kept and only the keyframe counts are re-derived from
+ /// the element's current channels. New elements get the unnamed (marker=0) form, which
+ /// needs no channel-name knowledge - both forms are common in real files.
+ ///
+ static List BuildManifestGroup(CscElement element)
+ {
+ var counts = element.AllChannelKeyframeCounts();
+
+ if (element.ManifestGroupFields != null &&
+ TryPatchManifestGroup(element.ManifestGroupFields, counts) is { } patched)
+ return patched;
+
+ // Unnamed form: U32(0) + I32(channelCount) + per group: one channel whose
+ // sub-components are the group's channels.
+ var groups = element.ElementGroups().Concat(element.TypeGroups).ToList();
+ var fields = new List
+ {
+ EsfNode.Leaf(EsfNodeKind.U32, 0u, optimized: true),
+ EsfNode.Leaf(EsfNodeKind.I32, groups.Count, optimized: true),
+ };
+
+ foreach (var group in groups)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, group.Channels.Count, optimized: true));
+ foreach (var channel in group.Channels)
+ {
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, false, optimized: true));
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, channel.Keyframes.Count, optimized: true));
+ for (var k = 0; k < channel.Keyframes.Count; k++)
+ fields.Add(EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true));
+ }
+ }
+
+ return fields;
+ }
+
+ static List? TryPatchManifestGroup(List originalFields, List counts)
+ {
+ var parsed = CompositeSceneDetector.DetectGroups(originalFields);
+ if (parsed.Count != 1 || parsed[0].StartIndex != 0 || parsed[0].FieldCount != originalFields.Count)
+ return null;
+
+ var group = parsed[0];
+ var flatSubs = group.Channels.SelectMany(c => c.SubComponents).ToList();
+ if (flatSubs.Count != counts.Count)
+ return null;
+
+ var fields = new List(originalFields.Count);
+ var subIndex = 0;
+ var readPos = 0;
+
+ void CopyThrough(int exclusiveEnd)
+ {
+ while (readPos < exclusiveEnd)
+ fields.Add(originalFields[readPos++]);
+ }
+
+ foreach (var channel in group.Channels)
+ {
+ foreach (var sub in channel.SubComponents)
+ {
+ CopyThrough(sub.StartIndex);
+
+ var newCount = counts[subIndex++];
+ fields.Add(originalFields[readPos]); // Bool flag, preserved
+ fields.Add(EsfNode.Leaf(EsfNodeKind.I32, newCount, optimized: true));
+
+ // Per-keyframe flags: keep the originals that still apply, pad with True.
+ for (var k = 0; k < newCount; k++)
+ fields.Add(k < sub.KeyframeCount
+ ? originalFields[readPos + 2 + k]
+ : EsfNode.Leaf(EsfNodeKind.Bool, true, optimized: true));
+
+ readPos = sub.StartIndex + sub.FieldCount;
+ }
+ }
+
+ CopyThrough(originalFields.Count);
+ return fields;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/DependencyInjectionContainer.cs b/Editors/CscEditor/Editors.CscEditor/DependencyInjectionContainer.cs
new file mode 100644
index 000000000..c26d289ec
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/DependencyInjectionContainer.cs
@@ -0,0 +1,38 @@
+using Editors.CscEditor.Services;
+using Editors.CscEditor.ViewModels;
+using Editors.CscEditor.Views;
+using Microsoft.Extensions.DependencyInjection;
+using Shared.Core.DependencyInjection;
+using Shared.Core.ToolCreation;
+
+namespace Editors.CscEditor
+{
+ public class DependencyInjectionContainer : DependencyContainer
+ {
+ public override void Register(IServiceCollection serviceCollection)
+ {
+ // Views
+ serviceCollection.AddTransient();
+
+ // ViewModels
+ serviceCollection.AddScoped();
+ serviceCollection.AddScoped();
+
+ // Services
+ serviceCollection.AddScoped();
+ serviceCollection.AddScoped();
+
+ // Game components (picked up by IComponentInserter)
+ RegisterGameComponent(serviceCollection);
+ RegisterGameComponent(serviceCollection);
+ }
+
+ public override void RegisterTools(IEditorDatabase editorDatabase)
+ {
+ EditorInfoBuilder
+ .Create(EditorEnums.Csc_Editor)
+ .AddExtention(".csc", EditorPriorites.Default)
+ .Build(editorDatabase);
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Editors.CscEditor.csproj b/Editors/CscEditor/Editors.CscEditor/Editors.CscEditor.csproj
new file mode 100644
index 000000000..3f5d78377
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Editors.CscEditor.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0-windows
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Editors/CscEditor/Editors.CscEditor/Services/CscAnimationComponent.cs b/Editors/CscEditor/Editors.CscEditor/Services/CscAnimationComponent.cs
new file mode 100644
index 000000000..857a81e93
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Services/CscAnimationComponent.cs
@@ -0,0 +1,820 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Editors.CscEditor.Data;
+using GameWorld.Core.Animation;
+using GameWorld.Core.Components;
+using GameWorld.Core.Components.Rendering;
+using GameWorld.Core.SceneNodes;
+using GameWorld.Core.Utility;
+using Microsoft.Xna.Framework;
+
+namespace Editors.CscEditor.Services
+{
+ ///
+ /// Drives scene time and applies each element's evaluated transform/visibility every frame:
+ /// world matrices are composed through the attach tree (including the parent model's animated
+ /// bone when the element is attached to one) and pushed into the element node plus every
+ /// mesh/dummy content node, and each model's skeletal AnimationPlayer is scrubbed to the
+ /// timeline. Runs even while paused so gizmo/detail edits show up immediately. Also applies
+ /// the look-through-camera view when one is active.
+ ///
+ public class CscAnimationComponent : BaseComponent
+ {
+ readonly CscPlaybackContext _context;
+ readonly CscSceneGraphBuilder _sceneBuilder;
+ readonly ArcBallCamera _camera;
+ readonly RenderEngineComponent _renderEngine;
+ CscScene? _scene;
+
+ /// Fixed square render resolution for the look-through/porthole preview -
+ /// independent of the host panel's own (possibly non-square, resizable) pixel dimensions,
+ /// so neither the projection (aspect=1 below) nor CapturePortholeFrame's crop have to guess
+ /// at or react to the panel's current orientation. See RenderEngineComponent.SquareRenderSize.
+ const int PortholeRenderSize = 512;
+
+ double _captureAccumulator;
+ const double CaptureInterval = 0.1; // throttle CPU readback to ~10fps - GetData() stalls the GPU pipeline
+ System.Windows.Media.Imaging.WriteableBitmap? _portholeBitmap;
+
+ /// Cropped-to-square, alpha-preserving snapshot of the current look-through render
+ /// (sourced from RenderEngineComponent's pre-composite alpha target, not the final opaque
+ /// viewport surface - see CapturePortholeFrame). Null when not looking through a camera.
+ public System.Windows.Media.ImageSource? PortholeLiveFrame { get; private set; }
+
+ /// Raised whenever is refreshed.
+ public event Action? PortholeFrameUpdated;
+
+ public CscAnimationComponent(CscPlaybackContext context, CscSceneGraphBuilder sceneBuilder,
+ ArcBallCamera camera, RenderEngineComponent renderEngine)
+ {
+ _context = context;
+ _sceneBuilder = sceneBuilder;
+ _camera = camera;
+ _renderEngine = renderEngine;
+ UpdateOrder = (int)ComponentUpdateOrderEnum.Default;
+ }
+
+ public void SetScene(CscScene? scene)
+ {
+ _scene = scene;
+ ClearLookThrough();
+ }
+
+ /// Switches the viewport to look through the given camera element.
+ public void SetLookThrough(int cameraElementId)
+ {
+ _context.LookThroughElementId = cameraElementId;
+ ApplyFrame();
+ }
+
+ /// Returns the viewport to the normal arc-ball view.
+ public void ClearLookThrough()
+ {
+ _context.LookThroughElementId = -1;
+ _camera.ViewMatrixOverride = null;
+ _camera.ProjectionMatrixOverride = null;
+ _renderEngine.HideGrid = false;
+ _renderEngine.SquareRenderSize = null;
+ PortholeLiveFrame = null;
+ PortholeFrameUpdated?.Invoke();
+ }
+
+ public override void Update(GameTime gameTime)
+ {
+ if (_scene == null)
+ return;
+
+ if (_context.IsPlaying)
+ {
+ var time = _context.CurrentTime + (float)gameTime.ElapsedGameTime.TotalSeconds;
+ if (time >= _context.Duration)
+ {
+ if (_context.Loop)
+ {
+ time = _context.Duration > 0 ? time % _context.Duration : 0;
+ }
+ else
+ {
+ time = _context.Duration;
+ _context.IsPlaying = false;
+ }
+ }
+ _context.CurrentTime = time;
+ }
+
+ ApplyFrame();
+
+ if (_context.LookThroughElementId >= 0)
+ {
+ _captureAccumulator += gameTime.ElapsedGameTime.TotalSeconds;
+ if (_captureAccumulator >= CaptureInterval)
+ {
+ _captureAccumulator = 0;
+ CapturePortholeFrame();
+ }
+ }
+ }
+
+ /// Reads back a centred square crop of RenderEngineComponent's pre-composite
+ /// render target (SurfaceFormat.Color - has real alpha, unlike the final viewport surface
+ /// D3D11Host shares with WPF, which is SurfaceFormat.Bgr32/opaque) into a WPF bitmap, so the
+ /// porthole preview can show real transparency (composited with porthole_back.png in the
+ /// view) instead of the 3D view's own opaque grey background. Throttled - GetData() is a
+ /// GPU/CPU sync point, too costly to do every frame.
+ void CapturePortholeFrame()
+ {
+ var frame = _renderEngine.LastFrame;
+ if (frame == null || frame.Width <= 0 || frame.Height <= 0)
+ return;
+
+ var size = Math.Min(frame.Width, frame.Height);
+ var offsetX = (frame.Width - size) / 2;
+ var offsetY = (frame.Height - size) / 2;
+
+ var colors = new Color[size * size];
+ frame.GetData(0, new Rectangle(offsetX, offsetY, size, size), colors, 0, colors.Length);
+
+ if (_portholeBitmap == null || _portholeBitmap.PixelWidth != size)
+ _portholeBitmap = new System.Windows.Media.Imaging.WriteableBitmap(size, size, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
+
+ var bytes = new byte[size * size * 4];
+ for (var i = 0; i < colors.Length; i++)
+ {
+ var c = colors[i];
+ bytes[i * 4 + 0] = c.B;
+ bytes[i * 4 + 1] = c.G;
+ bytes[i * 4 + 2] = c.R;
+ bytes[i * 4 + 3] = c.A;
+ }
+
+ _portholeBitmap.WritePixels(new System.Windows.Int32Rect(0, 0, size, size), bytes, size * 4, 0);
+ PortholeLiveFrame = _portholeBitmap;
+ PortholeFrameUpdated?.Invoke();
+ }
+
+ public void ApplyFrame()
+ {
+ if (_scene == null)
+ return;
+
+ var localTimes = BuildTimeContext(_context.CurrentTime);
+ DriveSkeletalPlayers(localTimes);
+
+ // GameSkeleton caches the frame it hands out to bone-attach resolvers (attach_point
+ // props, RMV2 rigid-bone pieces) in its own field instead of reading the player live.
+ // Refresh it once DriveSkeletalPlayers has set each player's frame for this tick, or
+ // every such attachment stays frozen at the bind pose while the skinned mesh (which
+ // reads the player directly) animates.
+ foreach (var content in _sceneBuilder.ModelContents.Values)
+ content.Skeleton?.Update();
+
+ var worlds = new Dictionary();
+ foreach (var element in _scene.DfsOrder(includeNested: true))
+ ApplyElementFrame(element, Matrix.Identity, localTimes[element.Id], worlds);
+
+ // Root-ref sub-scenes render under their host element's transform, at their own
+ // (possibly looped) local time - see BuildTimeContext.
+ foreach (var sub in _sceneBuilder.SubScenes)
+ {
+ if (!worlds.TryGetValue(sub.Host.Id, out var hostWorld))
+ continue;
+
+ foreach (var element in sub.Scene.DfsOrder(includeNested: true))
+ ApplyElementFrame(element, hostWorld, localTimes.GetValueOrDefault(element.Id, _context.CurrentTime), worlds);
+ }
+
+ if (_context.LookThroughElementId >= 0 &&
+ worlds.TryGetValue(_context.LookThroughElementId, out var camWorld) &&
+ FindElementIncludingSubScenes(_context.LookThroughElementId) is { Kind: CscElementKind.Camera } camElement)
+ {
+ var camTime = localTimes.GetValueOrDefault(camElement.Id, _context.CurrentTime);
+ ApplyLookThroughCamera(camElement, camWorld, camTime);
+ }
+ }
+
+ ///
+ /// Resolves, for every element (main scene and every root-ref sub-scene, arbitrarily
+ /// nested), the local scene time it should animate at. Aliveness is each element's own
+ /// affair (see ) - a parent outside its own Begin/End
+ /// window does NOT hide its children; a child with its own wider window is meant to keep
+ /// showing regardless of what its parent is doing. Root-ref sub-scenes get their own
+ /// looped local clock: elapsed host time since the ROOT_REF element's Begin (scaled by its
+ /// ELEMENT_PERIOD speed multiplier - see ), wrapped by the
+ /// referenced scene's own duration - so e.g. a ROOT_REF active for 15s referencing a 1s
+ /// scene plays it 15 times instead of once before going inactive. Processes sub-scenes in
+ /// 's load order, which is guaranteed
+ /// outer-before-inner (a sub-scene is appended to the list while its host's own outer
+ /// LoadSubScene call is still running), so a host's local time is always already resolved
+ /// by the time its own sub-scene is processed.
+ ///
+ Dictionary BuildTimeContext(float globalTime)
+ {
+ var localTimes = new Dictionary();
+
+ foreach (var element in _scene!.DfsOrder(includeNested: true))
+ localTimes[element.Id] = globalTime;
+
+ foreach (var sub in _sceneBuilder.SubScenes)
+ {
+ if (!localTimes.TryGetValue(sub.Host.Id, out var hostTime))
+ continue;
+
+ var subTime = ComputeSubSceneTime(sub, hostTime);
+ foreach (var element in sub.Scene.DfsOrder(includeNested: true))
+ localTimes[element.Id] = subTime;
+ }
+
+ return localTimes;
+ }
+
+ /// Referenced scene's own local clock: elapsed time since the host's Begin, scaled
+ /// by the host ROOT_REF_ELEMENT's own ELEMENT_PERIOD speed multiplier - the same (flag,
+ /// speed, offset) record used for SFX/animation bundles elsewhere (see
+ /// ), not ROOT_REF_ELEMENT's own
+ /// game_time_multiplier/scene_time_multiplier type-channels: verified against real files
+ /// (extracted .csc corpus) that those two channels sit at their neutral 1.0 in every
+ /// authored case, while ELEMENT_PERIOD's speed field carries the real value -
+ /// chd_hellshard_drill_01_movedown.csc's RootRef to chd_drill_mainchain_down.csc has
+ /// ELEMENT_PERIOD speed 0.024 (matching the observed 7.2s clip stretched to a 300s host
+ /// window) with both type-channels at 1.0, and chd_hellshard_drill_01_idle.csc's RootRef to
+ /// chd_drill_head_01.csc has ELEMENT_PERIOD speed 0 (freeze) with both type-channels at 1.0
+ /// too - so ELEMENT_PERIOD is the actual mechanism, not the named channels. Wrapped by the
+ /// referenced scene's own Duration so it loops for as long as the host stays alive.
+ static float ComputeSubSceneTime(CscSubScene sub, float hostTime)
+ {
+ var host = sub.Host;
+ var speed = host.PeriodSpeedMultiplier;
+
+ // speed == 0 is a legitimate authored value (freeze the sub-scene on its first frame,
+ // e.g. a static prop reusing a .csc that's normally animated) - it must NOT be treated
+ // as "unset" and defaulted back to 1 (unlike SampleBindingTime's use of this same field
+ // for ANIMATION_ELEMENT clip speed, where 0 does mean "unset").
+ var elapsed = (hostTime - host.NormalizedBegin) * speed;
+ if (elapsed < 0)
+ elapsed = 0;
+
+ var duration = sub.Scene.Duration;
+ return duration > 0 ? elapsed % duration : 0;
+ }
+
+ /// Composes one element's world matrix at its own local time
+ /// (attach parent chain, bone attachment) and pushes it into its scene nodes.
+ /// is the transform parentless elements compose against -
+ /// identity for the main scene, the host element's world for root-ref sub-scenes.
+ /// A model's apparent ground displacement (walk cycles carrying the character away from
+ /// its element origin) never appears here: it lives entirely in the skeletal pose (see
+ /// ), which the skinned mesh and every bone resolver read
+ /// directly - so every node, marker and functional origin stays at its authored
+ /// transform no matter how far an animation carries the character.
+ ///
+ /// Visibility is each element's own , not inherited from
+ /// its attach-tree parent - a parent outside its own window does not hide children that
+ /// are still inside theirs.
+ void ApplyElementFrame(CscElement element, Matrix rootWorld, float t, Dictionary worlds)
+ {
+ var parentWorld = rootWorld;
+ if (element.Parent != null && worlds.TryGetValue(element.Parent.Id, out var pw))
+ parentWorld = GetAttachBoneMatrix(element) * pw;
+
+ // This is the element's functional origin - what children (other animation bindings on
+ // the same model, bone-attached elements, ...) resolve their own position against.
+ var world = element.LocalTransform(t) * parentWorld;
+ worlds[element.Id] = world;
+
+ if (!_sceneBuilder.ElementNodes.TryGetValue(element.Id, out var node))
+ return;
+
+ node.WorldMatrix = world;
+ node.IsVisible = element.IsAliveAt(t) || element.Id == _context.SelectedElementId;
+
+ PushWorldToContent(node, world);
+ }
+
+ CscElement? FindElementIncludingSubScenes(int elementId)
+ {
+ if (_scene?.FindElement(elementId) is { } found)
+ return found;
+ foreach (var sub in _sceneBuilder.SubScenes)
+ if (sub.Scene.FindElement(elementId) is { } subFound)
+ return subFound;
+ return null;
+ }
+
+ /// Points the viewport camera through a scene camera element - position/forward/up
+ /// from its world matrix (local +Z is forward, matching CscCameraFrustumNode) and fov/
+ /// near/far from its live channels. Roll spins the up vector around the camera's own local
+ /// forward axis before it's carried into world space, so it's independent of world orientation.
+ void ApplyLookThroughCamera(CscElement camElement, Matrix world, float t)
+ {
+ var position = world.Translation;
+ var forward = Vector3.Normalize(Vector3.TransformNormal(Vector3.UnitZ, world));
+ var roll = camElement.CameraRoll?.Evaluate(t) ?? 0f;
+ var localUp = Vector3.TransformNormal(Vector3.Up, Matrix.CreateRotationZ(roll));
+ var up = Vector3.Normalize(Vector3.TransformNormal(localUp, world));
+ _camera.ViewMatrixOverride = Matrix.CreateLookAt(position, position + forward, up);
+
+ var fovDegrees = CscCameraFrustumNode.EffectiveFovDegrees(camElement, t);
+ var near = Math.Max(0.01f, camElement.CameraNear?.Evaluate(t) ?? 0.1f);
+ var far = Math.Max(near + 0.1f, camElement.CameraFar?.Evaluate(t) ?? 10000f);
+
+ // Force the whole pipeline to render at a fixed square resolution (see
+ // RenderEngineComponent.SquareRenderSize) instead of the panel's own live, resizable,
+ // generally non-square viewport - so aspect=1 here always exactly matches the render
+ // target's real shape (no stretch), and CapturePortholeFrame's crop always sees an
+ // already-square frame regardless of how the editor panel is sized or oriented.
+ // Faking this with aspect tricks alone (square projection into a non-square target)
+ // stretches the render instead.
+ _renderEngine.SquareRenderSize = PortholeRenderSize;
+ _camera.ProjectionMatrixOverride =
+ Matrix.CreatePerspectiveFieldOfView(MathHelper.ToRadians(fovDegrees), 1f, near, far) * Matrix.CreateScale(-1, 1, 1);
+
+ // Nothing but the porthole itself is visible in-game (see CscEditorView.xaml's overlay) -
+ // hide the normal-editing grid so it doesn't show through the transparent background.
+ _renderEngine.HideGrid = true;
+ }
+
+ /// World transform of the parent's attach bone (in the parent's local space) when
+ /// this element is bone-attached to an animated model; identity otherwise.
+ Matrix GetAttachBoneMatrix(CscElement element)
+ {
+ if (element.AttachBoneIndex < 0 || element.Parent == null)
+ return Matrix.Identity;
+
+ if (!_sceneBuilder.ModelContents.TryGetValue(element.Parent.Id, out var content) || content.Skeleton == null)
+ return Matrix.Identity;
+
+ var boneIndex = element.AttachBoneIndex;
+ if (boneIndex >= content.Skeleton.BoneCount)
+ return Matrix.Identity;
+
+ var frame = content.Player.GetCurrentAnimationFrame();
+ return frame != null
+ ? frame.GetSkeletonAnimatedWorld(content.Skeleton, boneIndex)
+ : content.Skeleton.GetWorldTransform(boneIndex);
+ }
+
+ /// Writes the world matrix into every content node under the element node - the
+ /// engine's picking and selection-highlight code read each node's own ModelMatrix and do
+ /// not compose parent transforms, so world transforms must live on the leaves. Meshes
+ /// resolved to a bone (variantmesh attach_point props, or RMV2's own rigid-bone id for
+ /// "building" pieces) get the element world through
+ /// instead - one step further out than the bone, not in ModelMatrix, which Render composes
+ /// on the near side of the bone transform (see Rmv2MeshNode.Render for why the split
+ /// exists: the bone's own transform is local to this model and must land inside the
+ /// element's world, not have the element's world land inside it).
+ static void PushWorldToContent(CscElementSceneNode node, Matrix world)
+ {
+ node.ForeachNodeRecursive(child =>
+ {
+ if (child is Rmv2MeshNode { AttachmentBoneResolver: not null } attachedMesh)
+ attachedMesh.AttachmentOuterWorld = world;
+ else if (child is Rmv2MeshNode or ICscContentDummy)
+ child.ModelMatrix = world;
+ });
+ }
+
+ ///
+ /// Scrubs each model's skeletal player to its own local scene time, honoring each
+ /// animation binding's own Begin/End window and ELEMENT_PERIOD (speed multiplier, time
+ /// offset). A model can carry several full-body ANIMATION_ELEMENT children: each one's
+ /// pose plays at its own element's authored transform (see )
+ /// and overlapping windows cross-blend through a nested fold in Begin order (see
+ /// ) instead of one clip abruptly replacing another.
+ /// Splice bindings (hand_pose/face_pose etc.) are handled separately afterward as a
+ /// per-bone override on top of whatever this produced - see .
+ ///
+ void DriveSkeletalPlayers(Dictionary localTimes)
+ {
+ foreach (var (elementId, content) in _sceneBuilder.ModelContents)
+ {
+ if (content.Bindings.Count == 0)
+ continue;
+
+ var sceneTime = localTimes.GetValueOrDefault(elementId, 0f);
+ var modelElement = FindElementIncludingSubScenes(elementId);
+
+ var baseBindings = content.Bindings.Where(b => b.Element.SpliceRecord == null).ToList();
+ var spliceBindings = content.Bindings.Where(b => b.Element.SpliceRecord != null).ToList();
+
+ DriveBaseBindings(content, baseBindings, modelElement, sceneTime);
+ ApplySplices(content, spliceBindings, modelElement, sceneTime);
+ }
+ }
+
+ /// Sequences/loops/cross-blends the model's non-splice bindings, driving
+ /// content.Player's pose. Each binding's sampled pose is first PLACED - rigidly moved to
+ /// its own ANIMATION_ELEMENT's authored transform, advanced by its own completed-loop
+ /// root motion (see ) - and the placed poses are then
+ /// folded through a nested crossfade in Begin order, so any depth of authored overlap
+ /// (Boris's walk/step/idle chain runs three deep) stays continuous.
+ void DriveBaseBindings(CscModelContent content, List bindings, CscElement? modelElement, float sceneTime)
+ {
+ var ordered = bindings.OrderBy(b => b.Element.NormalizedBegin).ToList();
+ var started = new List<(CscAnimationBinding Binding, CscElement AnimElement, AnimationFrame Placed)>();
+
+ foreach (var binding in ordered)
+ {
+ var animElement = binding.Element;
+ if (binding.FrameCount <= 0 || binding.ClipLengthSeconds <= 0)
+ continue;
+ if (sceneTime < animElement.NormalizedBegin)
+ continue;
+
+ // The owning model's own (begin, end) window is authoritative over a child
+ // animation's window - an animation that outlives its model gets cut off instead
+ // of continuing to play after the model itself would already be gone. A binding
+ // past its own window stays in the list frozen at its End pose, so late scene
+ // times (and scrubbing back into them) deterministically resolve to the final
+ // clip's last placed pose instead of whatever the player happened to show last.
+ var effectiveEnd = EffectiveEnd(animElement, modelElement);
+ var isPast = animElement.TimingMode != "infinite" && effectiveEnd >= animElement.NormalizedBegin && sceneTime > effectiveEnd;
+ var sampleTime = isPast ? effectiveEnd : sceneTime;
+
+ var placed = ComputePlacedPose(binding, animElement, modelElement, effectiveEnd, sampleTime);
+ if (placed != null)
+ started.Add((binding, animElement, placed));
+ }
+
+ if (started.Count == 0)
+ return;
+
+ // Nested crossfade: each binding fades in over the window between its own Begin and
+ // the previous binding's effective End, on top of the composite of everything before
+ // it - blend(blend(walk, step, w1), idle, w2) for a three-deep overlap. Every weight
+ // is continuous in scene time and saturates exactly when the previous clip's window
+ // closes, so clips joining, blending and expiring never step the pose: a two-clip
+ // overlap reduces to the plain pairwise crossfade, a clip with no overlap (span <= 0:
+ // the previous clip ended before this one began) takes over instantly, and an
+ // already-past prefix stops contributing exactly when the weights above it saturate.
+ var composite = started[0].Placed;
+ var newest = started[0].Binding;
+ for (var i = 1; i < started.Count; i++)
+ {
+ var (binding, animElement, placed) = started[i];
+ if (placed.BoneTransforms.Count != composite.BoneTransforms.Count)
+ continue;
+
+ var span = EffectiveEnd(started[i - 1].AnimElement, modelElement) - animElement.NormalizedBegin;
+ var weight = span > 0 ? Math.Clamp((sceneTime - animElement.NormalizedBegin) / span, 0f, 1f) : 1f;
+ composite = weight >= 1f ? placed : BlendFrames(composite, placed, weight, binding.Skeleton);
+ newest = binding;
+ }
+
+ if (content.Player.AnimationClip != newest.Clip)
+ content.Player.SetAnimation(newest.Clip, newest.Skeleton, allowAnimationsFromDifferentSkeletons: true);
+ content.Player.SetManualFrame(composite);
+ }
+
+ /// Samples a binding's pose and rigidly places it where the scene data says that
+ /// clip plays: at its own ANIMATION_ELEMENT's authored transform (relative to the model),
+ /// advanced by the clip's own completed-loop root motion since its Begin.
+ ///
+ /// Scene authors PLACE each animation element where its clip is meant to play - verified
+ /// against boris_scene's real data: walk's element sits at the model origin, and
+ /// step_forward (z=6.51, slightly rotated) / stand_idle (z=7.01) are authored exactly
+ /// where the walk cycle leaves Boris, so with placement applied, walk's animroot reaches
+ /// z=7.7 as it fades out, step's ends at z=7.06 and idle stands at z=7.01 - continuity
+ /// across a transition chain comes from the FILE, not from anything derived at runtime.
+ /// Synthesizing positions instead (e.g. one shared per-model render-world nudge carrying
+ /// completed loops) cannot place two overlapping clips at once and produces a visible
+ /// jump somewhere in every transition chain.
+ ///
+ /// Loop motion composes INSIDE the placement (pose * loopDelta^k * elementTransform):
+ /// at every loop wrap the raw pose's animroot reset is exactly cancelled by the
+ /// incremented power (loopDelta is Invert(root(first)) * root(last) - see
+ /// 's ComputeLoopRootMotion), keeping each binding's
+ /// placed pose continuous over its whole life with no separate ground-position channel to
+ /// fall out of sync with. The loop count is normalized to the binding's own Begin (a
+ /// nonzero ELEMENT_PERIOD offset makes it nonzero, often negative, the instant the clip
+ /// starts - see ), so each clip starts exactly at its authored
+ /// mark and only advances by what it has genuinely played through since.
+ ///
+ /// A self-bound carrier (a standalone ANIMATION_ELEMENT that is itself the content
+ /// element - building-destruct anims whose pieces bone-attach straight to it) skips the
+ /// element-transform factor: everything consuming its pose already composes against the
+ /// carrier's own world matrix, so folding the same transform into the pose would apply it
+ /// twice. Loop advancement still applies.
+ ///
+ /// The rigid placement is applied to the stored bind-relative WorldTransforms directly -
+ /// see for why that is algebraically exact.
+ static AnimationFrame? ComputePlacedPose(CscAnimationBinding binding, CscElement animElement, CscElement? modelElement, float effectiveEnd, float sampleTime)
+ {
+ var (frame, frameInterpolation, loops) = SampleBindingTime(binding, animElement, effectiveEnd, sampleTime);
+ var pose = AnimationSampler.Sample(frame, frameInterpolation, binding.Skeleton, binding.Clip);
+ if (pose == null)
+ return null;
+
+ var (_, _, loopsAtBegin) = SampleBindingTime(binding, animElement, effectiveEnd, animElement.NormalizedBegin);
+ var placement = MatrixPower(binding.LoopRootMotion, loops - loopsAtBegin);
+ var selfBound = modelElement != null && animElement.Id == modelElement.Id;
+ if (!selfBound)
+ placement *= animElement.LocalTransform(sampleTime);
+ return ApplyRigidOffset(pose, placement);
+ }
+
+ /// Applies each active splice binding (an ANIMATION_ELEMENT with an
+ /// ANIMATION_SPLICE_ELEMENT sibling record - hand_pose/face_pose etc.) as a bone-mask
+ /// override on top of whatever already set as the player's
+ /// current frame: the splice's own SpliceBoneId bone and every descendant of it (by the
+ /// model's own skeleton hierarchy) has its pose entirely REPLACED by the splice clip's own
+ /// sampled pose, not blended - hand/face poses are meant to override specific bones
+ /// outright, unlike the cross-fade used for two overlapping full-body clips. Assumes a
+ /// splice's own clip shares the same skeleton/bone ordering as the model's base skeleton
+ /// (bone index N means the same bone in both) - the only sensible reading of "given by the
+ /// bone id" since there's nothing else to map SpliceBoneId against. Several splices can be
+ /// active at once (a hand pose and a face pose target disjoint bones); when two target the
+ /// same bone the later one (by Begin) wins.
+ void ApplySplices(CscModelContent content, List spliceBindings, CscElement? modelElement, float sceneTime)
+ {
+ if (spliceBindings.Count == 0 || content.Skeleton == null)
+ return;
+
+ var baseFrame = content.Player.GetCurrentAnimationFrame();
+ if (baseFrame == null)
+ return;
+
+ AnimationFrame? merged = null;
+
+ foreach (var binding in spliceBindings.OrderBy(b => b.Element.NormalizedBegin))
+ {
+ var animElement = binding.Element;
+ if (binding.FrameCount <= 0 || binding.ClipLengthSeconds <= 0)
+ continue;
+ if (sceneTime < animElement.NormalizedBegin)
+ continue;
+
+ var effectiveEnd = EffectiveEnd(animElement, modelElement);
+ if (animElement.TimingMode != "infinite" && effectiveEnd >= animElement.NormalizedBegin && sceneTime > effectiveEnd)
+ continue;
+
+ var (frame, frameInterpolation, _) = SampleBindingTime(binding, animElement, effectiveEnd, sceneTime);
+ var splicePose = AnimationSampler.Sample(frame, frameInterpolation, binding.Skeleton, binding.Clip);
+ if (splicePose == null || splicePose.BoneTransforms.Count != baseFrame.BoneTransforms.Count)
+ continue;
+
+ merged ??= CloneFrame(baseFrame);
+ ApplySpliceOverride(merged, splicePose, content.Skeleton, animElement.SpliceBoneId);
+ }
+
+ if (merged != null)
+ content.Player.SetManualFrame(merged);
+ }
+
+ /// Overrides SpliceBoneId and its descendants in with the
+ /// splice clip's own pose WITHOUT discarding the base animation's actual current arm/head
+ /// chain. Each bone's (as produced
+ /// by ) is already bind-relative but chain-composed against
+ /// whichever clip it was sampled from - so naively copying the splice's own WorldTransform
+ /// for the target bone would replace it with the splice clip's own idea of where its parent
+ /// chain is (typically close to bind pose, since a hand/face-only clip doesn't meaningfully
+ /// animate the shoulder/neck above it), leaving the hand/face behind while the real arm/head
+ /// keeps moving under the base animation. Instead, each spliced bone's LOCAL
+ /// rotation/translation/scale (still available on the sampled -
+ /// never clears them, only adds the composed WorldTransform)
+ /// is recomposed against the BASE animation's own current chain - top-down, so descendants
+ /// chain onto their own already-overridden parent - and the hand/face rigidly follows
+ /// wherever the real arm/head actually is right now, with only its own internal pose
+ /// replaced.
+ static void ApplySpliceOverride(AnimationFrame merged, AnimationFrame splicePose, GameSkeleton skeleton, int spliceBoneId)
+ {
+ if (spliceBoneId < 0 || spliceBoneId >= skeleton.BoneCount)
+ return;
+
+ var overriddenWorlds = new Dictionary();
+ foreach (var boneIndex in BoneAndDescendantsTopDown(skeleton, spliceBoneId))
+ {
+ if (boneIndex >= merged.BoneTransforms.Count || boneIndex >= splicePose.BoneTransforms.Count)
+ continue;
+
+ var parentIndex = skeleton.GetParentBoneIndex(boneIndex);
+ var parentWorld = parentIndex < 0
+ ? Matrix.Identity
+ : overriddenWorlds.TryGetValue(parentIndex, out var overriddenParent)
+ ? overriddenParent
+ : merged.GetSkeletonAnimatedWorld(skeleton, parentIndex);
+
+ var spliceKey = splicePose.BoneTransforms[boneIndex];
+ var localSplice = Matrix.CreateScale(spliceKey.Scale) * Matrix.CreateFromQuaternion(spliceKey.Rotation) * Matrix.CreateTranslation(spliceKey.Translation);
+ var animatedWorld = localSplice * parentWorld;
+ overriddenWorlds[boneIndex] = animatedWorld;
+
+ var bindInverse = Matrix.Invert(skeleton.GetWorldTransform(boneIndex));
+ merged.BoneTransforms[boneIndex] = new AnimationFrame.BoneKeyFrame
+ {
+ BoneIndex = spliceKey.BoneIndex,
+ ParentBoneIndex = spliceKey.ParentBoneIndex,
+ Rotation = spliceKey.Rotation,
+ Translation = spliceKey.Translation,
+ Scale = spliceKey.Scale,
+ WorldTransform = bindInverse * animatedWorld,
+ };
+ }
+ }
+
+ static AnimationFrame CloneFrame(AnimationFrame source)
+ {
+ var clone = new AnimationFrame();
+ clone.BoneTransforms.AddRange(source.BoneTransforms);
+ return clone;
+ }
+
+ /// A bone and every bone beneath it in the skeleton hierarchy, parent before child
+ /// so each descendant's newly-overridden parent world is already available when it's
+ /// processed - "certain bones and their children" for splice overrides.
+ static List BoneAndDescendantsTopDown(GameSkeleton skeleton, int boneId)
+ {
+ var result = new List();
+ var queue = new Queue();
+ queue.Enqueue(boneId);
+ while (queue.Count > 0)
+ {
+ var bone = queue.Dequeue();
+ result.Add(bone);
+ foreach (var child in skeleton.GetDirectChildBones(bone))
+ queue.Enqueue(child);
+ }
+ return result;
+ }
+
+ static float EffectiveEnd(CscElement animElement, CscElement? modelElement)
+ {
+ var effectiveEnd = animElement.NormalizedEnd;
+ if (modelElement != null && modelElement.TimingMode != "infinite" && modelElement.NormalizedEnd >= modelElement.NormalizedBegin)
+ effectiveEnd = MathF.Min(effectiveEnd, modelElement.NormalizedEnd);
+ return effectiveEnd;
+ }
+
+ /// Frame index + fractional inter-frame position + completed-loop count for a
+ /// binding at a given sample time (already clamped by the caller to whichever window
+ /// applies), honoring the animation's own (normalized) Begin and ELEMENT_PERIOD speed/
+ /// offset. Floored modulo (not truncation) so a negative time offset wraps into "plays the
+ /// tail end of the clip first" instead of freezing on frame 0 until elapsed time catches up
+ /// to |offset|.
+ ///
+ /// / are deliberately computed the same
+ /// way
+ /// does (round to the nearest frame, keep the leftover as the blend factor toward its
+ /// neighbour) rather than truncating straight to an integer
+ /// frame index with no fractional remainder - the previous version discarded that remainder
+ /// entirely (passed a hardcoded interpolation of 0 into ),
+ /// so playback only ever showed whichever whole frame the current time landed on with no
+ /// blend toward the next - correct, but visibly stepped ("slideshow-y"), worst at a heavily
+ /// slowed ELEMENT_PERIOD (e.g. 0.025x) where each frame then holds for tens of real seconds
+ /// before jumping to the next.
+ static (int Frame, float FrameInterpolation, int CompletedLoops) SampleBindingTime(CscAnimationBinding binding, CscElement animElement, float effectiveEnd, float sampleTime)
+ {
+ var begin = animElement.NormalizedBegin;
+ var clampedTime = animElement.TimingMode == "infinite" || effectiveEnd < begin
+ ? MathF.Max(sampleTime, begin)
+ : Math.Clamp(sampleTime, begin, effectiveEnd);
+
+ var speed = animElement.PeriodSpeedMultiplier != 0 ? animElement.PeriodSpeedMultiplier : 1;
+ var animTime = (clampedTime - begin) * speed + animElement.PeriodTimeOffset;
+
+ var passes = animTime / binding.ClipLengthSeconds;
+ var completedLoops = (int)MathF.Floor(passes);
+ var normalized = passes - completedLoops; // always in [0,1), even for negative animTime
+
+ var maxFrameIndex = MathF.Max(binding.FrameCount - 1, 0);
+ var frameWithLeftover = normalized * maxFrameIndex;
+ var frame = (int)MathF.Round(frameWithLeftover);
+ var frameInterpolation = frameWithLeftover - frame;
+ frame = Math.Clamp(frame, 0, binding.FrameCount - 1);
+
+ return (frame, frameInterpolation, completedLoops);
+ }
+
+ /// Per-bone crossfade of two already-sampled frames, blended in LOCAL bone space
+ /// (each bone's parent-relative rotation/translation/scale, Slerp/Lerp-ed) and then
+ /// recomposed down the hierarchy exactly the way builds a
+ /// frame (animatedWorld = local * parentAnimatedWorld, parent-before-child, then
+ /// bind-normalized for the shader).
+ ///
+ /// Blending each bone's MODEL-SPACE transform independently instead keeps limb chains
+ /// intact only while the two poses roughly agree. Where they differ (e.g. a dual-swords
+ /// walk against an idle holding the arms completely differently), every bone takes its own
+ /// straight-line path between its two model-space positions, detaching hands and forearms
+ /// from the arm chain that should be carrying them. Blending locals and
+ /// recomposing makes every bone ride its own blended parent by construction, so limbs
+ /// stay connected at every weight; parentless bones' locals ARE their model-space
+ /// placement (see ), so the verified positional continuity
+ /// of the placed animroot is unchanged by blending this way.
+ ///
+ /// Callers are expected to pass frames already placed by ,
+ /// so both sides express their pose in the same (model-space) reference frame - blending
+ /// raw clip-local poses would average two unrelated authoring origins through the
+ /// animroot bone.
+ static AnimationFrame BlendFrames(AnimationFrame a, AnimationFrame b, float weightB, GameSkeleton skeleton)
+ {
+ var blended = new AnimationFrame();
+ var count = Math.Min(Math.Min(a.BoneTransforms.Count, b.BoneTransforms.Count), skeleton.BoneCount);
+ var animatedWorlds = new Matrix[count];
+ for (var i = 0; i < count; i++)
+ {
+ var boneA = a.BoneTransforms[i];
+ var boneB = b.BoneTransforms[i];
+
+ var scale = Vector3.Lerp(boneA.Scale, boneB.Scale, weightB);
+ var rotation = Quaternion.Slerp(boneA.Rotation, boneB.Rotation, weightB);
+ var translation = Vector3.Lerp(boneA.Translation, boneB.Translation, weightB);
+
+ var local = Matrix.CreateScale(scale) * Matrix.CreateFromQuaternion(rotation) * Matrix.CreateTranslation(translation);
+ var parent = boneA.ParentBoneIndex;
+ animatedWorlds[i] = parent >= 0 && parent < i ? local * animatedWorlds[parent] : local;
+
+ blended.BoneTransforms.Add(new AnimationFrame.BoneKeyFrame
+ {
+ BoneIndex = boneA.BoneIndex,
+ ParentBoneIndex = boneA.ParentBoneIndex,
+ Rotation = rotation,
+ Translation = translation,
+ Scale = scale,
+ WorldTransform = Matrix.Invert(skeleton.GetWorldTransform(i)) * animatedWorlds[i],
+ });
+ }
+ return blended;
+ }
+
+ /// Rigidly repositions/reorients every bone of an already-sampled frame by a
+ /// constant matrix (see ) - body-pose shape is
+ /// untouched, only where it sits and faces changes. Each is bind-relative
+ /// (bindInverse * animatedWorld, per 's
+ /// bindWorld * WorldTransform = animatedWorld), so post-multiplying the offset onto
+ /// the STORED WorldTransform is exactly equivalent to post-multiplying the true model-space
+ /// animatedWorld and re-deriving - the per-bone bindWorld factor cancels out algebraically,
+ /// no unbake/rebake needed.
+ ///
+ /// The per-bone LOCAL components are preserved (they're 's
+ /// inputs, not derived data): a rigid whole-pose move leaves every parent-relative
+ /// transform unchanged except for parentless bones (animroot), whose local IS their
+ /// animated world - the offset folds into those directly, keeping locals and worlds
+ /// mutually consistent so a chain recompose from locals reproduces exactly these worlds.
+ /// Identity offset is a no-op fast path (skips reallocating a whole frame every tick for
+ /// the common case of a binding placed at its model's origin with no completed
+ /// loops).
+ static AnimationFrame ApplyRigidOffset(AnimationFrame frame, Matrix offset)
+ {
+ if (offset == Matrix.Identity)
+ return frame;
+
+ var result = new AnimationFrame();
+ foreach (var bone in frame.BoneTransforms)
+ {
+ var rotation = bone.Rotation;
+ var translation = bone.Translation;
+ var scale = bone.Scale;
+ if (bone.ParentBoneIndex < 0)
+ {
+ var local = Matrix.CreateScale(scale) * Matrix.CreateFromQuaternion(rotation) * Matrix.CreateTranslation(translation);
+ (local * offset).Decompose(out scale, out rotation, out translation);
+ }
+
+ result.BoneTransforms.Add(new AnimationFrame.BoneKeyFrame
+ {
+ BoneIndex = bone.BoneIndex,
+ ParentBoneIndex = bone.ParentBoneIndex,
+ Rotation = rotation,
+ Translation = translation,
+ Scale = scale,
+ WorldTransform = bone.WorldTransform * offset,
+ });
+ }
+ return result;
+ }
+
+ /// Supports negative exponents (inverse) as well as positive ones: a binding
+ /// sampled with a negative ELEMENT_PERIOD time offset starts already partway through the
+ /// clip counted BACKWARD from frame 0 (e.g. offset -0.7 loops means "0.7 loops before the
+ /// clip's own start"), which floored-modulo division in
+ /// correctly reports as a negative completed-loop count. Previously any exponent <= 0 was
+ /// treated as identity, which was correct for exactly 0 but wrong for negative counts -
+ /// LoopRootMotion^-1 should undo one loop's worth of motion, not contribute nothing. That
+ /// bug meant every binding's first loop-wrap after a negative-offset start (once the loop
+ /// counter crossed from negative to zero, both of which the old code rendered identically)
+ /// had no compensating jump to cancel the clip's own reset-to-frame-0 pose, producing a
+ /// visible backward teleport right at the start before playback evened out (e.g. several
+ /// characters each carrying a distinct negative ELEMENT_PERIOD offset so they don't all
+ /// step in lockstep).
+ static Matrix MatrixPower(Matrix m, int exponent)
+ {
+ if (exponent == 0 || m == Matrix.Identity)
+ return Matrix.Identity;
+
+ if (exponent < 0)
+ return Matrix.Invert(MatrixPower(m, -exponent));
+
+ var result = Matrix.Identity;
+ var basePower = m;
+ while (exponent > 0)
+ {
+ if ((exponent & 1) != 0)
+ result *= basePower;
+ basePower *= basePower;
+ exponent >>= 1;
+ }
+ return result;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Services/CscGizmoComponent.cs b/Editors/CscEditor/Editors.CscEditor/Services/CscGizmoComponent.cs
new file mode 100644
index 000000000..1e9714cbc
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Services/CscGizmoComponent.cs
@@ -0,0 +1,245 @@
+using System;
+using Editors.CscEditor.Data;
+using GameWorld.Core.Components;
+using GameWorld.Core.Components.Gizmo;
+using GameWorld.Core.Components.Input;
+using GameWorld.Core.Components.Rendering;
+using GameWorld.Core.Services;
+using GameWorld.Core.Utility;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+
+namespace Editors.CscEditor.Services
+{
+ ///
+ /// A CSC-specific transform gizmo. The stock bakes transforms
+ /// into mesh vertices (the Kitbasher model), which is wrong here - moving a CSC element means
+ /// editing its position/rotation/scale channels. This component reuses the low-level
+ /// widget and routes its deltas into the selected element's channel data.
+ ///
+ public class CscGizmoComponent : BaseComponent, IDisposable
+ {
+ readonly ArcBallCamera _camera;
+ readonly IMouseComponent _mouse;
+ readonly IKeyboardComponent _keyboard;
+ readonly RenderEngineComponent _renderEngine;
+ readonly IDeviceResolver _deviceResolver;
+ readonly IGraphicsResourceCreator _graphicsResourceCreator;
+ readonly CscPlaybackContext _context;
+ readonly CscSceneGraphBuilder _sceneBuilder;
+
+ Gizmo? _gizmo;
+ readonly TargetAdapter _adapter = new();
+ CscElement? _element;
+ bool _enabled;
+
+ /// Raised after a drag changed element data (mark dirty, refresh details).
+ public event Action? ElementModified;
+
+ public CscGizmoComponent(
+ ArcBallCamera camera,
+ IMouseComponent mouseComponent,
+ IKeyboardComponent keyboardComponent,
+ RenderEngineComponent renderEngine,
+ IDeviceResolver deviceResolver,
+ IGraphicsResourceCreator graphicsResourceCreator,
+ CscPlaybackContext context,
+ CscSceneGraphBuilder sceneBuilder)
+ {
+ _camera = camera;
+ _mouse = mouseComponent;
+ _keyboard = keyboardComponent;
+ _renderEngine = renderEngine;
+ _deviceResolver = deviceResolver;
+ _graphicsResourceCreator = graphicsResourceCreator;
+ _context = context;
+ _sceneBuilder = sceneBuilder;
+
+ UpdateOrder = (int)ComponentUpdateOrderEnum.Gizmo;
+ DrawOrder = (int)ComponentDrawOrderEnum.Gizmo;
+ }
+
+ public override void Initialize()
+ {
+ _gizmo = new Gizmo(_camera, _mouse, _deviceResolver.Device, _renderEngine, _graphicsResourceCreator);
+ _gizmo.ActivePivot = PivotType.ObjectCenter;
+ _gizmo.TranslateEvent += OnTranslate;
+ _gizmo.RotateEvent += OnRotate;
+ _gizmo.ScaleEvent += OnScale;
+ _gizmo.StartEvent += OnDragStart;
+ _gizmo.StopEvent += OnDragEnd;
+ _gizmo.Selection.Add(_adapter);
+ }
+
+ public void SetMode(GizmoMode mode)
+ {
+ if (_gizmo == null)
+ return;
+ _gizmo.ActiveMode = mode;
+ _enabled = true;
+ }
+
+ public void Disable() => _enabled = false;
+
+ public void SetTarget(CscElement? element)
+ {
+ _element = element != null && !element.IsLegacyRaw ? element : null;
+ _gizmo?.ResetDeltas();
+ }
+
+ bool IsActive => _enabled && _element != null && _gizmo != null;
+
+ public override void Update(GameTime gameTime)
+ {
+ if (!IsActive)
+ return;
+
+ SyncAdapter();
+ var isCameraMoving = _keyboard.IsKeyDown(Keys.LeftAlt);
+ _gizmo!.Update(gameTime, !isCameraMoving);
+ }
+
+ public override void Draw(GameTime gameTime)
+ {
+ if (IsActive)
+ _gizmo!.Draw();
+ }
+
+ Matrix ElementWorld(CscElement element)
+ {
+ // The animation component's composed matrix (includes attach-bone transforms);
+ // domain-only fallback when the node isn't built yet.
+ if (_sceneBuilder.ElementNodes.TryGetValue(element.Id, out var node))
+ return node.WorldMatrix;
+ return element.WorldTransform(_context.CurrentTime);
+ }
+
+ void SyncAdapter()
+ {
+ var world = ElementWorld(_element!);
+ _adapter.Position = world.Translation;
+ world.Decompose(out _, out var rotation, out _);
+ _adapter.Orientation = rotation;
+ }
+
+ void OnDragStart()
+ {
+ _mouse.MouseOwner = this;
+ }
+
+ void OnDragEnd()
+ {
+ if (_mouse.MouseOwner == this)
+ {
+ _mouse.MouseOwner = null;
+ _mouse.ClearStates();
+ }
+ }
+
+ ///
+ /// Inverse of the frame the element's local transform lives in (parent world, including
+ /// any attach-bone matrix). Recovered from the composed world: W = local * Frame, so
+ /// Frame^-1 = W^-1 * local (row-vector convention).
+ ///
+ Matrix ParentFrameInverse()
+ {
+ if (_element!.Parent == null)
+ return Matrix.Identity;
+
+ var local = _element.LocalTransform(_context.CurrentTime);
+ return Matrix.Invert(ElementWorld(_element)) * local;
+ }
+
+ void OnTranslate(ITransformable transformable, TransformationEventArgs e)
+ {
+ if (!IsActive)
+ return;
+
+ var worldDelta = (Vector3)e.Value!;
+ var localDelta = Vector3.TransformNormal(worldDelta, ParentFrameInverse());
+
+ var channels = _element!.Position?.Channels;
+ if (channels == null || channels.Count < 3)
+ return;
+
+ channels[0].OffsetAll(localDelta.X);
+ channels[1].OffsetAll(localDelta.Y);
+ channels[2].OffsetAll(localDelta.Z);
+
+ _adapter.Position += worldDelta;
+ ElementModified?.Invoke();
+ }
+
+ void OnRotate(ITransformable transformable, TransformationEventArgs e)
+ {
+ if (!IsActive)
+ return;
+
+ var deltaMatrix = (Matrix)e.Value!;
+ var euler = ToEulerXyz(deltaMatrix);
+
+ var channels = _element!.Rotation?.Channels;
+ if (channels == null || channels.Count < 3)
+ return;
+
+ channels[0].OffsetAll(euler.X);
+ channels[1].OffsetAll(euler.Y);
+ channels[2].OffsetAll(euler.Z);
+ ElementModified?.Invoke();
+ }
+
+ void OnScale(ITransformable transformable, TransformationEventArgs e)
+ {
+ if (!IsActive)
+ return;
+
+ var value = (Vector3)e.Value!;
+ var component = value.X != 0 ? value.X : value.Y != 0 ? value.Y : value.Z;
+ var factor = 1 + component;
+ if (Math.Abs(factor) < 0.001f)
+ return;
+
+ var channels = _element!.Scale?.Channels;
+ if (channels == null || channels.Count < 1)
+ return;
+
+ channels[0].ScaleAll(factor);
+ ElementModified?.Invoke();
+ }
+
+ ///
+ /// Extracts (x, y, z) Euler angles from a rotation matrix under the Rx*Ry*Rz row-vector
+ /// composition this editor uses for CSC rotations. Exact for that convention; drag deltas
+ /// are tiny per-event, so accumulation stays stable.
+ ///
+ internal static Vector3 ToEulerXyz(Matrix m)
+ {
+ var y = MathF.Asin(Math.Clamp(-m.M13, -1f, 1f));
+ float x, z;
+ if (MathF.Abs(m.M13) < 0.9999f)
+ {
+ x = MathF.Atan2(m.M23, m.M33);
+ z = MathF.Atan2(m.M12, m.M11);
+ }
+ else
+ {
+ x = MathF.Atan2(-m.M32, m.M22);
+ z = 0;
+ }
+ return new Vector3(x, y, z);
+ }
+
+ public void Dispose()
+ {
+ _gizmo?.Dispose();
+ }
+
+ class TargetAdapter : ITransformable
+ {
+ public Vector3 Position { get; set; }
+ public Vector3 Scale { get; set; } = Vector3.One;
+ public Quaternion Orientation { get; set; } = Quaternion.Identity;
+ public Vector3 GetObjectCentre() => Position;
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Services/CscPlaybackContext.cs b/Editors/CscEditor/Editors.CscEditor/Services/CscPlaybackContext.cs
new file mode 100644
index 000000000..18eebd79c
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Services/CscPlaybackContext.cs
@@ -0,0 +1,32 @@
+using Shared.Core.Misc;
+
+namespace Editors.CscEditor.Services
+{
+ ///
+ /// Scene-time and selection state shared by the animation component, the drawable nodes
+ /// (which evaluate their channels live at the current time) and the view models.
+ ///
+ public class CscPlaybackContext : NotifyPropertyChangedImpl
+ {
+ float _currentTime;
+ public float CurrentTime { get => _currentTime; set => SetAndNotify(ref _currentTime, value); }
+
+ float _duration = 20;
+ public float Duration { get => _duration; set => SetAndNotify(ref _duration, value); }
+
+ bool _isPlaying;
+ public bool IsPlaying { get => _isPlaying; set => SetAndNotify(ref _isPlaying, value); }
+
+ bool _loop = true;
+ public bool Loop { get => _loop; set => SetAndNotify(ref _loop, value); }
+
+ /// Element id of the current selection, or -1. Selected elements stay visible
+ /// (and highlighted) even outside their active time window.
+ public int SelectedElementId { get; set; } = -1;
+
+ int _lookThroughElementId = -1;
+ /// Camera element id the viewport is currently looking through, or -1 for the
+ /// normal arc-ball view. Applied every frame by CscAnimationComponent.
+ public int LookThroughElementId { get => _lookThroughElementId; set => SetAndNotify(ref _lookThroughElementId, value); }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Services/CscSceneGraphBuilder.cs b/Editors/CscEditor/Editors.CscEditor/Services/CscSceneGraphBuilder.cs
new file mode 100644
index 000000000..1d7251e09
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Services/CscSceneGraphBuilder.cs
@@ -0,0 +1,584 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Editors.CscEditor.Data;
+using GameWorld.Core.Animation;
+using GameWorld.Core.Components;
+using GameWorld.Core.SceneNodes;
+using GameWorld.Core.Services;
+using GameWorld.Core.Utility;
+using Microsoft.Xna.Framework;
+using Serilog;
+using Shared.Core.ErrorHandling;
+using Shared.Core.PackFiles;
+using Shared.CoreLog;
+using Shared.GameFormats.Animation;
+
+namespace Editors.CscEditor.Services
+{
+ /// One ANIMATION_ELEMENT child bound to a loaded clip: a model can have several
+ /// (sequenced by their own Begin/End windows, blended across an overlap), not just one.
+ public class CscAnimationBinding
+ {
+ public required CscElement Element { get; init; }
+ public required AnimationClip Clip { get; init; }
+ public required GameSkeleton Skeleton { get; init; }
+ public float ClipLengthSeconds { get; init; }
+ public int FrameCount { get; init; }
+
+ /// Model-space transform the animroot bone accumulates over one full clip pass
+ /// (identity when the clip ends where it started). Walk cycles and travelling idles put
+ /// their ground movement on animroot, so this is composed once per completed loop into
+ /// the binding's placed pose (see 's ComputePlacedPose)
+ /// to make the clip self-propel instead of teleporting back on every wrap.
+ public Matrix LoopRootMotion { get; set; } = Matrix.Identity;
+ }
+
+ /// Per-model-element animation state: the player its meshes render with, plus the
+ /// skeleton resolved from the model itself (bind pose) or from the currently active binding's
+ /// .anim (which takes over when one exists). Implements so
+ /// attach_point meshes can resolve their bone through the standard
+ /// mechanism.
+ public class CscModelContent : ISkeletonProvider
+ {
+ public required AnimationPlayer Player { get; init; }
+ public GameSkeleton? Skeleton { get; set; }
+
+ /// Every ANIMATION_ELEMENT child bound so far (nested or attached). Sequenced by
+ /// their own Begin/End at playback time; when two overlap they're cross-blended - see
+ /// .
+ public List Bindings { get; } = [];
+ }
+
+ /// A .csc referenced by a ROOT_REF_ELEMENT, loaded for display: its elements are
+ /// remapped to private ids, marked and rendered under the
+ /// host element's transform. They are never part of the host scene's save data.
+ public class CscSubScene
+ {
+ public required CscElement Host { get; init; }
+ public required CscScene Scene { get; init; }
+ public List ElementIds { get; } = [];
+ }
+
+ ///
+ /// Builds the 3D scene for a : one flat transform node per element
+ /// (world matrices are composed by and pushed onto the
+ /// mesh/dummy nodes directly, because both the engine's picking and its selection highlight
+ /// ignore parent-chain transforms), with the element's visual (model mesh, dummy box, light
+ /// shape, camera frustum) as children of that node.
+ ///
+ public class CscSceneGraphBuilder
+ {
+ readonly ILogger _logger = Logging.Create();
+ readonly IPackFileService _packFileService;
+ readonly SceneManager _sceneManager;
+ readonly ComplexMeshLoader _complexMeshLoader;
+ readonly AnimationsContainerComponent _animationsContainer;
+ readonly ISkeletonAnimationLookUpHelper _skeletonLookUp;
+ readonly CscPlaybackContext _context;
+
+ GroupNode? _sceneRoot;
+ public Dictionary ElementNodes { get; } = [];
+ public Dictionary ModelContents { get; } = [];
+ public List SubScenes { get; } = [];
+
+ /// Ids handed to elements of referenced sub-scenes; far above anything a real
+ /// scene uses so they can share the node/content dictionaries without colliding.
+ int _nextExternalId = 1_000_000;
+ readonly HashSet _subSceneLoadStack = new(StringComparer.OrdinalIgnoreCase);
+
+ public CscSceneGraphBuilder(
+ IPackFileService packFileService,
+ SceneManager sceneManager,
+ ComplexMeshLoader complexMeshLoader,
+ AnimationsContainerComponent animationsContainer,
+ ISkeletonAnimationLookUpHelper skeletonLookUp,
+ CscPlaybackContext context)
+ {
+ _packFileService = packFileService;
+ _sceneManager = sceneManager;
+ _complexMeshLoader = complexMeshLoader;
+ _animationsContainer = animationsContainer;
+ _skeletonLookUp = skeletonLookUp;
+ _context = context;
+ }
+
+ public void Build(CscScene scene)
+ {
+ Clear();
+ _sceneRoot = _sceneManager.RootNode.AddObject(new GroupNode("CSC_Scene") { IsEditable = false });
+ foreach (var element in scene.AllElementsIncludingNested())
+ AddElement(element);
+ RefreshAnimationBindings(scene);
+ }
+
+ public void Clear()
+ {
+ var existing = _sceneManager.RootNode.Children.Where(x => x.Name == "CSC_Scene").ToList();
+ foreach (var node in existing)
+ _sceneManager.RootNode.RemoveObject(node);
+
+ foreach (var content in ModelContents.Values)
+ _animationsContainer.Remove(content.Player);
+
+ _sceneRoot = null;
+ ElementNodes.Clear();
+ ModelContents.Clear();
+ SubScenes.Clear();
+ _nextExternalId = 1_000_000;
+ }
+
+ public void AddElement(CscElement element)
+ {
+ if (_sceneRoot == null)
+ return;
+
+ var node = _sceneRoot.AddObject(new CscElementSceneNode(element, _context));
+ ElementNodes[element.Id] = node;
+ AddContent(node, element);
+ }
+
+ public void RemoveElement(int elementId)
+ {
+ if (_sceneRoot != null && ElementNodes.TryGetValue(elementId, out var node))
+ _sceneRoot.RemoveObject(node);
+ ElementNodes.Remove(elementId);
+
+ if (ModelContents.Remove(elementId, out var content))
+ _animationsContainer.Remove(content.Player);
+
+ RemoveSubScenesOf(elementId);
+ }
+
+ /// Rebuilds an element's visual children (e.g. after its model path changed).
+ public void RefreshContent(CscElement element)
+ {
+ if (!ElementNodes.TryGetValue(element.Id, out var node))
+ return;
+
+ foreach (var child in node.Children.ToList())
+ node.RemoveObject(child);
+ if (ModelContents.Remove(element.Id, out var content))
+ _animationsContainer.Remove(content.Player);
+ RemoveSubScenesOf(element.Id);
+
+ AddContent(node, element);
+ }
+
+ /// Tears down the sub-scene(s) a ROOT_REF element loaded (recursively - a
+ /// sub-scene's own root refs are hosted by ids inside its ElementIds list).
+ void RemoveSubScenesOf(int hostId)
+ {
+ foreach (var sub in SubScenes.Where(s => s.Host.Id == hostId).ToList())
+ {
+ SubScenes.Remove(sub);
+ foreach (var id in sub.ElementIds)
+ RemoveElement(id);
+ }
+ }
+
+ /// Maps a scene node (e.g. a clicked mesh) back to its owning element.
+ public CscElement? FindOwningElement(ISceneNode node)
+ {
+ ISceneNode? current = node;
+ while (current != null)
+ {
+ if (current is CscElementSceneNode elementNode)
+ return elementNode.Element;
+ current = current.Parent;
+ }
+ return null;
+ }
+
+ // ---------------------------------------------------------------------
+ // Skeletal animation (.anim on props)
+ // ---------------------------------------------------------------------
+
+ ///
+ /// Binds each model element's player to the .anim of every ANIMATION_ELEMENT child (nested
+ /// or attached) - a model can carry several, sequenced/blended at playback time by their
+ /// own Begin/End windows (see ).
+ /// A standalone ANIMATION_ELEMENT (one that's itself an attach-tree parent, not merely
+ /// attached under a Model - real "building" scenes attach destruct pieces straight to the
+ /// .anim's own element) binds to its OWN asset path instead, via
+ /// . The skeleton of each is resolved from its own anim
+ /// file's header. Call after any structural change.
+ ///
+ public void RefreshAnimationBindings(CscScene scene)
+ {
+ foreach (var element in scene.AllElementsIncludingNested())
+ {
+ if (!ModelContents.TryGetValue(element.Id, out var content))
+ continue;
+
+ var animationElements = GetAnimationSources(element);
+
+ // Rebuild only when the set of animation children actually changed - loading .anim
+ // files is not free and this runs on every structural edit, not just once.
+ var unchanged = animationElements.Count == content.Bindings.Count &&
+ animationElements.All(a => content.Bindings.Any(b => b.Element == a));
+ if (unchanged)
+ continue;
+
+ if (animationElements.Count == 0)
+ {
+ content.Bindings.Clear();
+ content.Player.SetAnimation(null, content.Skeleton);
+ continue;
+ }
+
+ content.Bindings.Clear();
+ foreach (var animationElement in animationElements)
+ {
+ try
+ {
+ var animPackFile = _packFileService.FindFile(animationElement.AssetPath);
+ if (animPackFile == null)
+ {
+ _logger.Warning("CSC editor: animation file not found: {Path}", animationElement.AssetPath);
+ continue;
+ }
+
+ var animFile = AnimationFile.Create(animPackFile);
+ var skeletonFile = _skeletonLookUp.GetSkeletonFileFromName(animFile.Header.SkeletonName);
+
+ // No skeleton file means an ad-hoc skeleton ("building" and other rigidmodel
+ // anims): the .anim's own bone table is the skeleton definition.
+ var skeleton = skeletonFile != null
+ ? new GameSkeleton(skeletonFile, content.Player)
+ : GameSkeleton.CreateFromAnimationFile(animFile, content.Player);
+
+ var clip = new AnimationClip(animFile, skeleton);
+ content.Bindings.Add(new CscAnimationBinding
+ {
+ Element = animationElement,
+ Clip = clip,
+ Skeleton = skeleton,
+ ClipLengthSeconds = clip.PlayTimeInSec,
+ FrameCount = clip.DynamicFrames.Count,
+ LoopRootMotion = ComputeLoopRootMotion(skeleton, clip),
+ });
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e, "CSC editor: failed to bind animation '{Path}'", animationElement.AssetPath);
+ }
+ }
+
+ if (content.Bindings.Count > 0)
+ {
+ var first = content.Bindings[0];
+ content.Skeleton = first.Skeleton;
+ content.Player.SetAnimation(first.Clip, first.Skeleton, allowAnimationsFromDifferentSkeletons: true);
+ content.Player.IsEnabled = true;
+ content.Player.Pause(); // time is driven by the editor timeline, not wall clock
+ }
+
+ if (ElementNodes.TryGetValue(element.Id, out var node))
+ WireAttachmentResolvers(node, content);
+ }
+
+ foreach (var sub in SubScenes.Where(s => scene.FindElement(s.Host.Id) == s.Host).ToList())
+ RefreshAnimationBindings(sub.Scene);
+ }
+
+ /// An element's animation source(s): a standalone ANIMATION_ELEMENT (one that's
+ /// itself an attach-tree parent - real "building" scenes attach destruct pieces straight
+ /// to the .anim's own element rather than to a wrapping Model) binds to its own asset
+ /// path; any other element binds to its attached ANIMATION_ELEMENT children, as before.
+ /// The two cases are mutually exclusive since an element's Kind can't be both.
+ static List GetAnimationSources(CscElement element)
+ {
+ if (element.Kind == CscElementKind.Animation && element.AssetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
+ return [element];
+
+ return element.Children
+ .Where(c => c.Kind == CscElementKind.Animation && c.AssetPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ ///
+ /// The animroot bone's model-space displacement over one full clip pass: X such that
+ /// animrootWorld(lastFrame) = animrootWorld(frame0) * X. Composed once per completed loop
+ /// so travelling animations (walks, and idles that drift) continue from where the previous
+ /// loop ended instead of snapping back. Identity for clips whose animroot returns to its
+ /// start and for skeletons without an animroot bone.
+ ///
+ static Matrix ComputeLoopRootMotion(GameSkeleton skeleton, AnimationClip clip)
+ {
+ if (clip.DynamicFrames.Count < 2)
+ return Matrix.Identity;
+
+ var animRootIndex = skeleton.GetBoneIndexByName("animroot");
+ if (animRootIndex < 0)
+ return Matrix.Identity;
+
+ var firstFrame = AnimationSampler.Sample(0, 0, skeleton, clip);
+ var lastFrame = AnimationSampler.Sample(clip.DynamicFrames.Count - 1, 0, skeleton, clip);
+ if (firstFrame == null || lastFrame == null)
+ return Matrix.Identity;
+
+ var start = firstFrame.GetSkeletonAnimatedWorld(skeleton, animRootIndex);
+ var end = lastFrame.GetSkeletonAnimatedWorld(skeleton, animRootIndex);
+ return Matrix.Invert(start) * end;
+ }
+
+ /// Points every attach_point mesh (variantmesh SLOT weapons/shields, loaded with
+ /// set) at its bone on this model's current
+ /// skeleton, and every rigid-bone mesh (RMV2's own
+ /// - "building" destruction pieces) at the same bone on this model's OWN skeleton, so its
+ /// origin becomes the bone regardless of where its vertices were authored. Re-run whenever
+ /// the skeleton changes.
+ void WireAttachmentResolvers(CscElementSceneNode node, CscModelContent content)
+ {
+ node.ForeachNodeRecursive(child =>
+ {
+ if (child is not Rmv2MeshNode mesh)
+ return;
+
+ if (!string.IsNullOrWhiteSpace(mesh.AttachmentPointName))
+ {
+ var boneIndex = content.Skeleton?.GetBoneIndexByName(mesh.AttachmentPointName) ?? -1;
+ mesh.AttachmentBoneResolver = boneIndex >= 0
+ ? new SkeletonBoneAnimationResolver(content, boneIndex)
+ : null;
+ if (mesh.AttachmentBoneResolver != null)
+ mesh.ModelMatrix = Matrix.Identity; // stop stale pushed world from also landing here - see AttachmentOuterWorld
+ }
+ else if (mesh.AnimationMatrixOverride >= 0)
+ {
+ mesh.AttachmentBoneResolver = content.Skeleton != null && mesh.AnimationMatrixOverride < content.Skeleton.BoneCount
+ ? new SkeletonBoneAnimationResolver(content, mesh.AnimationMatrixOverride)
+ : null;
+ if (mesh.AttachmentBoneResolver != null)
+ mesh.ModelMatrix = Matrix.Identity;
+ }
+ });
+ }
+
+ // ---------------------------------------------------------------------
+ // Content
+ // ---------------------------------------------------------------------
+
+ void AddContent(CscElementSceneNode node, CscElement element)
+ {
+ switch (element.Kind)
+ {
+ case CscElementKind.Model:
+ case CscElementKind.VariantModel:
+ AddModelContent(node, element);
+ break;
+
+ case CscElementKind.Vfx:
+ node.AddObject(new CscBoxDummyNode("VFX_Dummy", Color.MediumPurple, 0.5f));
+ break;
+
+ case CscElementKind.Sfx:
+ node.AddObject(new CscBoxDummyNode("SFX_Dummy", Color.Lime, 0.4f));
+ break;
+
+ case CscElementKind.SoundSphere:
+ node.AddObject(new CscSoundSphereNode
+ {
+ InnerRadius = ReadTypeRecordFloat(element, 1, 1),
+ OuterRadius = ReadTypeRecordFloat(element, 2, 2),
+ });
+ break;
+
+ case CscElementKind.PointLight:
+ node.AddObject(new CscPointLightNode(element, _context));
+ break;
+
+ case CscElementKind.SpotLight:
+ node.AddObject(new CscSpotLightNode(element, _context));
+ break;
+
+ case CscElementKind.Camera:
+ node.AddObject(new CscCameraFrustumNode(element, _context));
+ break;
+
+ case CscElementKind.Animation:
+ node.AddObject(new CscBoxDummyNode("Animation_Dummy", Color.Cyan, 0.25f));
+ AddAnimationCarrierContent(element);
+ break;
+
+ case CscElementKind.AnimationNodeTransform:
+ node.AddObject(new CscBoxDummyNode("NodeTransform_Dummy", Color.Turquoise, 0.2f));
+ break;
+
+ case CscElementKind.Prefab:
+ node.AddObject(new CscBoxDummyNode("Prefab_Dummy", Color.SaddleBrown, 0.7f));
+ break;
+
+ case CscElementKind.RootRef:
+ node.AddObject(new CscBoxDummyNode("RootRef_Dummy", Color.Orange, 0.7f));
+ LoadSubScene(element);
+ break;
+
+ case CscElementKind.CameraShake:
+ node.AddObject(new CscBoxDummyNode("CameraShake_Dummy", Color.HotPink, 0.3f));
+ break;
+
+ case CscElementKind.Locator:
+ // The element node itself already draws a locator cross.
+ break;
+
+ default:
+ node.AddObject(new CscBoxDummyNode("Unknown_Dummy", Color.Gray, 0.5f));
+ break;
+ }
+ }
+
+ float ReadTypeRecordFloat(CscElement element, int fieldIndex, float fallback)
+ {
+ var fields = element.TypeRecord?.Children.ToList();
+ if (fields != null && fieldIndex < fields.Count && fields[fieldIndex].Value is float f)
+ return f;
+ return fallback;
+ }
+
+ /// Registers a bare entry (player only, no mesh) for
+ /// a standalone ANIMATION_ELEMENT so other elements can bone-attach directly to IT via
+ /// ATTACH_TO_PARAMETERS - some real "building" scenes (destruct pieces) attach straight to
+ /// the .anim's own element instead of to a Model element that merely carries the .anim as
+ /// a child. then binds this same element to its own asset
+ /// path when runs, and
+ /// resolves against it exactly like
+ /// it would a Model's skeleton.
+ void AddAnimationCarrierContent(CscElement element)
+ {
+ var player = _animationsContainer.RegisterAnimationPlayer(new AnimationPlayer(), $"CscElement_{element.Id}");
+ ModelContents[element.Id] = new CscModelContent { Player = player };
+ }
+
+ ///
+ /// A Model/VariantModel element always gets a entry, even when
+ /// it has no asset path or fails to load - some "building" scenes carry a MODEL_ELEMENT with
+ /// a blank rigid_model_v2 path whose sole purpose is hosting an ANIMATION_ELEMENT child (the
+ /// .anim supplies an ad-hoc bone table, no named skeleton file) that OTHER elements attach
+ /// to via ATTACH_TO_PARAMETERS bone ids. Bailing out early without registering content (the
+ /// previous behaviour) meant that .anim never got bound and every bone-attached child on it
+ /// silently fell back to identity - i.e. unpositioned - since
+ /// and
+ /// both key off this same dictionary.
+ ///
+ void AddModelContent(CscElementSceneNode node, CscElement element)
+ {
+ var player = _animationsContainer.RegisterAnimationPlayer(new AnimationPlayer(), $"CscElement_{element.Id}");
+ var content = new CscModelContent { Player = player };
+ ModelContents[element.Id] = content;
+
+ var path = element.AssetPath;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ node.AddObject(new CscBoxDummyNode("Model_Missing", Color.Red, 0.5f));
+ return;
+ }
+
+ try
+ {
+ var file = _packFileService.FindFile(path);
+ if (file == null)
+ {
+ _logger.Warning("CSC editor: model file not found: {Path}", path);
+ node.AddObject(new CscBoxDummyNode("Model_NotFound", Color.Red, 0.5f));
+ return;
+ }
+
+ var loaded = _complexMeshLoader.Load(file, player, onlyLoadRootNode: true, onlyLoadFirstMesh: false);
+ if (loaded == null)
+ {
+ node.AddObject(new CscBoxDummyNode("Model_LoadFailed", Color.Red, 0.5f));
+ return;
+ }
+
+ node.AddObject(loaded);
+ ResolveBindSkeleton(content, loaded);
+ WireAttachmentResolvers(node, content);
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e, "CSC editor: failed to load model '{Path}'", path);
+ node.AddObject(new CscBoxDummyNode("Model_LoadFailed", Color.Red, 0.5f));
+ }
+ }
+
+ /// Resolves the model's own skeleton (bind pose) from its RMV2 header, so
+ /// bone-attached children and attach_point meshes are positioned correctly even before -
+ /// or without - an animation being bound. Ad-hoc skeletons ("building") have no skeleton
+ /// file; they stay null here and get their skeleton from the .anim when one is bound.
+ void ResolveBindSkeleton(CscModelContent content, SceneNode loaded)
+ {
+ try
+ {
+ var skeletonName = SceneNodeHelper.GetSkeletonName(loaded);
+ if (string.IsNullOrWhiteSpace(skeletonName))
+ return;
+
+ var skeletonFile = _skeletonLookUp.GetSkeletonFileFromName(skeletonName);
+ if (skeletonFile == null)
+ return;
+
+ content.Skeleton = new GameSkeleton(skeletonFile, content.Player);
+ }
+ catch (Exception e)
+ {
+ _logger.Warning(e, "CSC editor: failed to resolve bind skeleton");
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // ROOT_REF sub-scenes
+ // ---------------------------------------------------------------------
+
+ /// Loads the .csc referenced by a ROOT_REF element for display: elements get
+ /// private ids and the IsExternal mark, then join the normal node/content dictionaries so
+ /// animation and picking work; they are never added to the host scene's save data.
+ void LoadSubScene(CscElement host)
+ {
+ var path = host.AssetPath;
+ if (string.IsNullOrWhiteSpace(path))
+ return;
+
+ var pathKey = path.Replace('/', '\\').ToLowerInvariant();
+ if (!_subSceneLoadStack.Add(pathKey))
+ {
+ _logger.Warning("CSC editor: root ref cycle detected at '{Path}' - not loading again", path);
+ return;
+ }
+
+ try
+ {
+ var file = _packFileService.FindFile(path);
+ if (file == null)
+ {
+ _logger.Warning("CSC editor: root ref scene not found: {Path}", path);
+ return;
+ }
+
+ var scene = CscScene.Load(file.DataSource.ReadData());
+ var sub = new CscSubScene { Host = host, Scene = scene };
+ foreach (var element in scene.AllElementsIncludingNested())
+ {
+ element.Id = _nextExternalId++;
+ element.IsExternal = true;
+ sub.ElementIds.Add(element.Id);
+ }
+
+ SubScenes.Add(sub);
+ foreach (var element in scene.AllElementsIncludingNested())
+ AddElement(element);
+
+ RefreshAnimationBindings(scene);
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e, "CSC editor: failed to load root ref scene '{Path}'", path);
+ }
+ finally
+ {
+ _subSceneLoadStack.Remove(pathKey);
+ }
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Services/CscSceneNodes.cs b/Editors/CscEditor/Editors.CscEditor/Services/CscSceneNodes.cs
new file mode 100644
index 000000000..8568e2591
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Services/CscSceneNodes.cs
@@ -0,0 +1,311 @@
+using System;
+using System.Collections.Generic;
+using Editors.CscEditor.Data;
+using GameWorld.Core.Components.Rendering;
+using GameWorld.Core.Rendering;
+using GameWorld.Core.SceneNodes;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+namespace Editors.CscEditor.Services
+{
+ ///
+ /// The per-element transform carrier. Element nodes sit flat under the scene root with an
+ /// identity ModelMatrix - composes world matrices in the
+ /// domain and pushes them into here and into the ModelMatrix of
+ /// every content node (meshes, dummies). That keeps the engine's picking and highlight code
+ /// working, since both read a node's own ModelMatrix and ignore parent-chain transforms.
+ /// Draws a locator cross, and a highlight box when its element is selected.
+ ///
+ public class CscElementSceneNode : GroupNode, IDrawableItem
+ {
+ public CscElement Element { get; }
+ readonly CscPlaybackContext _context;
+
+ /// The element's fully-composed world transform for the current frame.
+ public Matrix WorldMatrix { get; set; } = Matrix.Identity;
+
+ public CscElementSceneNode(CscElement element, CscPlaybackContext context)
+ : base($"CscElement_{element.Id}")
+ {
+ Element = element;
+ _context = context;
+ IsEditable = false;
+ }
+
+ public bool IsSelected => _context.SelectedElementId == Element.Id;
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ renderEngine.AddRenderLines(LineHelper.AddRgbLocator(WorldMatrix.Translation, 0.4f));
+
+ if (IsSelected)
+ renderEngine.AddRenderLines(LineHelper.CreateCube(Matrix.CreateScale(0.6f) * WorldMatrix, Color.White));
+ }
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ /// Marker for leaf content nodes that receive the element's world transform in
+ /// their own ModelMatrix each frame (see CscAnimationComponent.PushWorldToContent). Only
+ /// leaves may carry it - a world matrix on an intermediate node would double-transform its
+ /// descendants through SceneManager's matrix accumulation.
+ public interface ICscContentDummy : ISceneNode
+ {
+ }
+
+ /// Wireframe box dummy used for VFX/SFX/prefab/etc. markers.
+ public class CscBoxDummyNode : GroupNode, IDrawableItem, ICscContentDummy
+ {
+ public Color Colour { get; set; } = Color.Purple;
+ public float Scale { get; set; } = 0.5f;
+
+ public CscBoxDummyNode(string name, Color colour, float scale) : base(name)
+ {
+ Colour = colour;
+ Scale = scale;
+ IsEditable = false;
+ }
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ var world = Matrix.CreateScale(Scale) * ModelMatrix * parentWorld;
+ renderEngine.AddRenderLines(LineHelper.CreateCube(world, Colour));
+ }
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ /// Point light: wireframe sphere with the live (possibly animated) range and colour.
+ public class CscPointLightNode : GroupNode, IDrawableItem, ICscContentDummy
+ {
+ readonly CscElement _element;
+ readonly CscPlaybackContext _context;
+
+ public CscPointLightNode(CscElement element, CscPlaybackContext context) : base("PointLight")
+ {
+ _element = element;
+ _context = context;
+ IsEditable = false;
+ }
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ var t = _context.CurrentTime;
+ var colour = ToColour(_element.LightColour(t));
+ var range = Math.Max(0.25f, _element.PointLightRange?.Evaluate(t) ?? 1f);
+
+ var world = ModelMatrix * parentWorld;
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeSphere(Matrix.CreateScale(range) * world, colour, 12));
+ renderEngine.AddRenderLines(LineHelper.CreateCube(Matrix.CreateScale(0.25f) * world, colour));
+ }
+
+ internal static Color ToColour(Vector3 rgb) =>
+ new(Math.Clamp(rgb.X, 0, 1), Math.Clamp(rgb.Y, 0, 1), Math.Clamp(rgb.Z, 0, 1));
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ /// Spot light: inner/outer wireframe cones with the live length/angles/colour.
+ /// Cone extends along local +X - confirmed in-game against a real file, NOT the same
+ /// forward-axis convention as cameras (+Z) - see WireframeCone.
+ public class CscSpotLightNode : GroupNode, IDrawableItem, ICscContentDummy
+ {
+ readonly CscElement _element;
+ readonly CscPlaybackContext _context;
+
+ public CscSpotLightNode(CscElement element, CscPlaybackContext context) : base("SpotLight")
+ {
+ _element = element;
+ _context = context;
+ IsEditable = false;
+ }
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ var t = _context.CurrentTime;
+ var colour = CscPointLightNode.ToColour(_element.LightColour(t));
+ var length = Math.Clamp(_element.SpotLightLength?.Evaluate(t) ?? 5f, 0.5f, 200f);
+ var inner = _element.SpotLightInnerAngle?.Evaluate(t) ?? 0f;
+ var outer = _element.SpotLightOuterAngle?.Evaluate(t) ?? 0.5f;
+
+ var world = ModelMatrix * parentWorld;
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeCone(world, colour, length, outer, 16));
+ if (inner > 0.001f)
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeCone(world, Color.Lerp(colour, Color.White, 0.5f), length, inner, 16));
+ }
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ /// Camera: wireframe view frustum from the live fov/near/far channels. The far plane
+ /// is clamped for display so a 100k-unit far distance doesn't dwarf the scene.
+ public class CscCameraFrustumNode : GroupNode, IDrawableItem, ICscContentDummy
+ {
+ readonly CscElement _element;
+ readonly CscPlaybackContext _context;
+
+ public CscCameraFrustumNode(CscElement element, CscPlaybackContext context) : base("CameraFrustum")
+ {
+ _element = element;
+ _context = context;
+ IsEditable = false;
+ }
+
+ /// fov <= 0 (usually -1) means "auto/default" in vanilla files.
+ internal static float EffectiveFovDegrees(CscElement element, float t)
+ {
+ var fov = element.CameraFov?.Evaluate(t) ?? 45f;
+ return fov <= 0.5f ? 45f : Math.Clamp(fov, 1f, 175f);
+ }
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ // While the viewport is looking through this camera, its own frustum lines would
+ // just clutter the view from inside.
+ if (_context.LookThroughElementId == _element.Id)
+ return;
+
+ var t = _context.CurrentTime;
+ var fovDegrees = EffectiveFovDegrees(_element, t);
+ // The real near/far clip planes, clamped for this wireframe's own display scale only -
+ // a real far plane can be scene-scale (e.g. 13000 units) and would dwarf the viewport.
+ var near = Math.Max(0.01f, _element.CameraNear?.Evaluate(t) ?? 0.1f);
+ var far = Math.Clamp(_element.CameraFar?.Evaluate(t) ?? 100f, near + 0.1f, 50f);
+
+ var roll = _element.CameraRoll?.Evaluate(t) ?? 0f;
+ var world = Matrix.CreateRotationZ(roll) * ModelMatrix * parentWorld;
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeFrustum(world, Color.DeepSkyBlue, fovDegrees, 16f / 9f, near, far));
+ renderEngine.AddRenderLines(LineHelper.CreateCube(Matrix.CreateScale(0.3f) * world, Color.DeepSkyBlue));
+ }
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ /// Sound sphere: inner/outer falloff radii around the element.
+ public class CscSoundSphereNode : GroupNode, IDrawableItem, ICscContentDummy
+ {
+ public float InnerRadius { get; set; } = 1;
+ public float OuterRadius { get; set; } = 2;
+
+ public CscSoundSphereNode() : base("SoundSphere")
+ {
+ IsEditable = false;
+ }
+
+ public void Render(RenderEngineComponent renderEngine, Matrix parentWorld)
+ {
+ var world = ModelMatrix * parentWorld;
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeSphere(Matrix.CreateScale(Math.Max(0.1f, InnerRadius)) * world, Color.Lime, 12));
+ renderEngine.AddRenderLines(CscShapeHelper.WireframeSphere(Matrix.CreateScale(Math.Max(0.1f, OuterRadius)) * world, Color.Green, 12));
+ }
+
+ public override ISceneNode CreateCopyInstance() => throw new NotSupportedException();
+ }
+
+ public static class CscShapeHelper
+ {
+ public static VertexPositionColor[] WireframeSphere(Matrix transform, Color colour, int segments)
+ {
+ var lines = new List();
+
+ void Circle(Func pointAt)
+ {
+ Vector3? prev = null;
+ for (var i = 0; i <= segments; i++)
+ {
+ var angle = MathF.Tau * i / segments;
+ var point = Vector3.Transform(pointAt(angle), transform);
+ if (prev.HasValue)
+ {
+ lines.Add(new VertexPositionColor(prev.Value, colour));
+ lines.Add(new VertexPositionColor(point, colour));
+ }
+ prev = point;
+ }
+ }
+
+ Circle(a => new Vector3(MathF.Cos(a), MathF.Sin(a), 0));
+ Circle(a => new Vector3(MathF.Cos(a), 0, MathF.Sin(a)));
+ Circle(a => new Vector3(0, MathF.Cos(a), MathF.Sin(a)));
+ return [.. lines];
+ }
+
+ /// Cone with apex at the origin extending along +X (with lateral spread on Y/Z) -
+ /// spot lights use a DIFFERENT local-forward axis than cameras (which use +Z, see
+ /// WireframeFrustum): verified against the real `campaign_teleport_portal_kho_idle.csc`
+ /// spotlight, whose local +X transforms to world (0, -1, 0) - exactly straight down, which
+ /// is what the user confirmed seeing in-game, while the editor (still assuming +Z at the
+ /// time) rendered it along world +X instead.
+ /// is the full cone angle in radians.
+ public static VertexPositionColor[] WireframeCone(Matrix transform, Color colour, float length, float angle, int segments)
+ {
+ var radius = length * MathF.Tan(Math.Clamp(angle, 0.001f, MathF.PI - 0.01f) * 0.5f);
+ var apex = Vector3.Transform(Vector3.Zero, transform);
+
+ var lines = new List();
+ var basePoints = new Vector3[segments];
+ for (var i = 0; i < segments; i++)
+ {
+ var a = MathF.Tau * i / segments;
+ basePoints[i] = Vector3.Transform(new Vector3(length, radius * MathF.Cos(a), radius * MathF.Sin(a)), transform);
+ }
+
+ for (var i = 0; i < segments; i++)
+ {
+ lines.Add(new VertexPositionColor(apex, colour));
+ lines.Add(new VertexPositionColor(basePoints[i], colour));
+ lines.Add(new VertexPositionColor(basePoints[i], colour));
+ lines.Add(new VertexPositionColor(basePoints[(i + 1) % segments], colour));
+ }
+
+ return [.. lines];
+ }
+
+ /// View frustum looking along +Z (CSC's camera convention, verified against the
+ /// nor_sayl porthole scene where the camera's local +Z points at the character): near/far
+ /// rectangles joined at the corners.
+ public static VertexPositionColor[] WireframeFrustum(Matrix transform, Color colour, float fovDegrees, float aspect, float near, float far)
+ {
+ var halfTan = MathF.Tan(MathHelper.ToRadians(fovDegrees) * 0.5f);
+
+ Vector3[] Plane(float distance)
+ {
+ var halfHeight = halfTan * distance;
+ var halfWidth = halfHeight * aspect;
+ return
+ [
+ Vector3.Transform(new Vector3(-halfWidth, -halfHeight, distance), transform),
+ Vector3.Transform(new Vector3(halfWidth, -halfHeight, distance), transform),
+ Vector3.Transform(new Vector3(halfWidth, halfHeight, distance), transform),
+ Vector3.Transform(new Vector3(-halfWidth, halfHeight, distance), transform),
+ ];
+ }
+
+ var n = Plane(near);
+ var f = Plane(far);
+
+ var lines = new List();
+ void Line(Vector3 a, Vector3 b)
+ {
+ lines.Add(new VertexPositionColor(a, colour));
+ lines.Add(new VertexPositionColor(b, colour));
+ }
+
+ for (var i = 0; i < 4; i++)
+ {
+ Line(n[i], n[(i + 1) % 4]);
+ Line(f[i], f[(i + 1) % 4]);
+ Line(n[i], f[i]);
+ }
+
+ // A small "up" tick on the far plane so the camera's roll is readable.
+ var farTop = (f[2] + f[3]) * 0.5f;
+ var tip = farTop + (farTop - Vector3.Transform(new Vector3(0, 0, far), transform)) * 0.3f;
+ Line(f[2], tip);
+ Line(f[3], tip);
+
+ return [.. lines];
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/ViewModels/CscEditorViewModel.cs b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscEditorViewModel.cs
new file mode 100644
index 000000000..a5e06d074
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscEditorViewModel.cs
@@ -0,0 +1,779 @@
+using System;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using CommunityToolkit.Mvvm.Input;
+using Editors.CscEditor.Data;
+using Editors.CscEditor.Services;
+using Editors.CscEditor.Views;
+using GameWorld.Core.Components;
+using GameWorld.Core.Components.Gizmo;
+using GameWorld.Core.Components.Rendering;
+using GameWorld.Core.Components.Selection;
+using GameWorld.Core.WpfWindow;
+using Serilog;
+using Shared.Core.ErrorHandling;
+using Shared.Core.Events;
+using Shared.Core.Misc;
+using Shared.Core.PackFiles;
+using Shared.Core.PackFiles.Models;
+using Shared.Core.PackFiles.Utility;
+using Shared.Core.Services;
+using Shared.Core.ToolCreation;
+using Shared.CoreLog;
+
+namespace Editors.CscEditor.ViewModels
+{
+ public class CscEditorViewModel : NotifyPropertyChangedImpl, IEditorInterface, IFileEditor, ISaveableEditor, IDisposable
+ {
+ readonly ILogger _logger = Logging.Create();
+ readonly IPackFileService _packFileService;
+ readonly IFileSaveService _fileSaveService;
+ readonly IStandardDialogs _dialogs;
+ readonly IEventHub _eventHub;
+ readonly CscSceneGraphBuilder _sceneBuilder;
+ readonly CscAnimationComponent _animation;
+ readonly CscGizmoComponent _gizmo;
+ readonly CscPlaybackContext _context;
+ readonly ArcBallCamera _camera;
+ readonly SelectionManager _selectionManager;
+
+ public IWpfGame Scene { get; }
+ public CscScene? SceneData { get; private set; }
+
+ public string DisplayName { get; set; } = "CSC Editor";
+ public PackFile CurrentFile { get; private set; } = null!;
+
+ bool _hasUnsavedChanges;
+ public bool HasUnsavedChanges
+ {
+ get => _hasUnsavedChanges;
+ set => SetAndNotify(ref _hasUnsavedChanges, value);
+ }
+
+ public ObservableCollection RootElements { get; } = [];
+
+ /// Single-item wrapper so the tree shows a "Root" node above the top-level
+ /// elements (bound to directly, so it stays live).
+ public ObservableCollection SceneRootItems { get; }
+
+ CscElementViewModel? _selectedElement;
+ public CscElementViewModel? SelectedElement
+ {
+ get => _selectedElement;
+ set
+ {
+ if (value != null && _selectedSceneRoot != null)
+ {
+ _selectedSceneRoot = null;
+ NotifyPropertyChanged(nameof(SelectedSceneRoot));
+ NotifyPropertyChanged(nameof(IsSceneRootSelected));
+ }
+ SetAndNotify(ref _selectedElement, value);
+ _context.SelectedElementId = value?.Element.Id ?? -1;
+ _gizmo.SetTarget(value?.Element is { IsExternal: true } ? null : value?.Element);
+ RebuildCurveSeries();
+ NotifyPropertyChanged(nameof(HasSelection));
+ NotifyPropertyChanged(nameof(IsElementSelected));
+ }
+ }
+
+ public bool HasSelection => SelectedElement != null;
+ public bool IsElementSelected => SelectedElement != null;
+
+ CscSceneRootViewModel? _selectedSceneRoot;
+ /// The synthetic tree root, selected exclusively of any
+ /// - lets the Details panel show/edit the .csc's own ROOT header fields (scene duration,
+ /// focus point, radius, weather path) instead of an element's.
+ public CscSceneRootViewModel? SelectedSceneRoot
+ {
+ get => _selectedSceneRoot;
+ set
+ {
+ if (value != null)
+ SelectedElement = null; // exclusive with element selection
+ SetAndNotify(ref _selectedSceneRoot, value);
+ NotifyPropertyChanged(nameof(IsSceneRootSelected));
+ }
+ }
+
+ public bool IsSceneRootSelected => SelectedSceneRoot != null;
+
+ public bool IsLookingThroughCamera => _context.LookThroughElementId >= 0;
+
+ // ---- Porthole overlay: emulates the in-game 2D framing (ui/skins/default/porthole*.png)
+ // shown while looking through a camera, so it's easier to compose a shot that matches how
+ // it'll actually be cropped/masked in-game. Back/frame images are loaded once from the
+ // loaded pack files; missing files (mod without the UI skin loaded) just leave the overlay
+ // empty. Sized/positioned in the view against porthole.png's own 242x258 pixel footprint,
+ // with its circular cutout (180x180, centred at 115,120 - i.e. offset 25,30) - user-measured,
+ // not derived from any header/manifest data. PortholeLiveFrame (the cropped, alpha-preserving
+ // 3D render) is produced by CscAnimationComponent - see its CapturePortholeFrame for why
+ // that's a CPU-readback from RenderEngineComponent's pre-composite target rather than a
+ // mirror of the live viewport surface, which has no alpha. ----
+ public ImageSource? PortholeBackImage { get; }
+ public ImageSource? PortholeFrameImage { get; }
+ public ImageSource? PortholeLiveFrame => _animation.PortholeLiveFrame;
+
+ string _statusText = "";
+ public string StatusText { get => _statusText; set => SetAndNotify(ref _statusText, value); }
+
+ // ---- Timeline ----
+ public CscPlaybackContext Playback => _context;
+
+ public double CurrentTime
+ {
+ get => _context.CurrentTime;
+ set
+ {
+ _context.CurrentTime = (float)value;
+ _animation.ApplyFrame();
+ }
+ }
+
+ public double Duration => _context.Duration;
+ public bool IsPlaying => _context.IsPlaying;
+ public bool ManifestEditable => SceneData?.ManifestParsed ?? false;
+
+ /// True when keyframes must not be added/removed (the file's COMPOSITE_SCENE
+ /// manifest could not be parsed, so keyframe counts cannot be restated on save).
+ public bool CurveStructureLocked => !ManifestEditable;
+
+ // ---- Curve editor ----
+ public ObservableCollection CurveSeriesList { get; } = [];
+
+ CurveSeries? _selectedCurveSeries;
+ public CurveSeries? SelectedCurveSeries
+ {
+ get => _selectedCurveSeries;
+ set => SetAndNotify(ref _selectedCurveSeries, value);
+ }
+
+ // ---- Commands ----
+ public ICommand SaveCommand { get; }
+ public ICommand PlayPauseCommand { get; }
+ public ICommand StopCommand { get; }
+ public ICommand GizmoTranslateCommand { get; }
+ public ICommand GizmoRotateCommand { get; }
+ public ICommand GizmoScaleCommand { get; }
+ public ICommand GizmoOffCommand { get; }
+ public ICommand FocusSelectedCommand { get; }
+ public ICommand LookThroughCameraCommand { get; }
+ public ICommand ResetViewCommand { get; }
+ public ICommand DeleteSelectedCommand { get; }
+ public ICommand DetachSelectedCommand { get; }
+ public ICommand AddModelCommand { get; }
+ public ICommand AddVariantModelCommand { get; }
+ public ICommand AddVfxCommand { get; }
+ public ICommand AddSfxCommand { get; }
+ public ICommand AddPointLightCommand { get; }
+ public ICommand AddSpotLightCommand { get; }
+ public ICommand AddCameraCommand { get; }
+ public ICommand AddLocatorCommand { get; }
+ public ICommand AddCompositeSceneCommand { get; }
+ public ICommand AddPrefabCommand { get; }
+ public ICommand AddAnimationCommand { get; }
+
+ public CscEditorViewModel(
+ IPackFileService packFileService,
+ IFileSaveService fileSaveService,
+ IStandardDialogs dialogs,
+ IEventHub eventHub,
+ IWpfGame gameWorld,
+ IComponentInserter componentInserter,
+ CscSceneGraphBuilder sceneBuilder,
+ CscAnimationComponent animationComponent,
+ CscGizmoComponent gizmoComponent,
+ CscPlaybackContext playbackContext,
+ ArcBallCamera camera,
+ SelectionManager selectionManager)
+ {
+ _packFileService = packFileService;
+ _fileSaveService = fileSaveService;
+ _dialogs = dialogs;
+ _eventHub = eventHub;
+ _sceneBuilder = sceneBuilder;
+ _animation = animationComponent;
+ _gizmo = gizmoComponent;
+ _context = playbackContext;
+ _camera = camera;
+ _selectionManager = selectionManager;
+ Scene = gameWorld;
+ SceneRootItems = [new CscSceneRootViewModel(RootElements, "Root", OnSceneRootModified)];
+
+ PortholeBackImage = LoadPackImage("ui/skins/default/porthole_back.png");
+ PortholeFrameImage = LoadPackImage("ui/skins/default/porthole.png");
+
+ componentInserter.Execute();
+
+ _gizmo.ElementModified += OnGizmoModifiedElement;
+ _animation.PortholeFrameUpdated += OnPortholeFrameUpdated;
+ _eventHub.Register(this, OnScene3dSelectionChanged);
+ _context.PropertyChanged += OnPlaybackContextChanged;
+
+ SaveCommand = new RelayCommand(() => Save());
+ PlayPauseCommand = new RelayCommand(() => _context.IsPlaying = !_context.IsPlaying);
+ StopCommand = new RelayCommand(() =>
+ {
+ _context.IsPlaying = false;
+ CurrentTime = 0;
+ });
+ GizmoTranslateCommand = new RelayCommand(() => _gizmo.SetMode(GizmoMode.Translate));
+ GizmoRotateCommand = new RelayCommand(() => _gizmo.SetMode(GizmoMode.Rotate));
+ GizmoScaleCommand = new RelayCommand(() => _gizmo.SetMode(GizmoMode.NonUniformScale));
+ GizmoOffCommand = new RelayCommand(_gizmo.Disable);
+ FocusSelectedCommand = new RelayCommand(FocusSelected);
+ LookThroughCameraCommand = new RelayCommand(LookThroughSelectedCamera);
+ ResetViewCommand = new RelayCommand(() => _animation.ClearLookThrough());
+ DeleteSelectedCommand = new RelayCommand(DeleteSelected);
+ DetachSelectedCommand = new RelayCommand(() => ReparentElement(SelectedElement, null));
+ AddModelCommand = new RelayCommand(AddModel);
+ AddVariantModelCommand = new RelayCommand(AddVariantModel);
+ AddVfxCommand = new RelayCommand(() => AddNamedElement(CscElementKind.Vfx, "VFX name (e.g. campaign_actions_dust)"));
+ AddSfxCommand = new RelayCommand(() => AddNamedElement(CscElementKind.Sfx, "Wwise start event (e.g. Play_My_Sound)"));
+ AddPointLightCommand = new RelayCommand(() => AddElement(CscElementKind.PointLight));
+ AddSpotLightCommand = new RelayCommand(() => AddElement(CscElementKind.SpotLight));
+ AddCameraCommand = new RelayCommand(() => AddElement(CscElementKind.Camera));
+ AddLocatorCommand = new RelayCommand(() => AddElement(CscElementKind.Locator));
+ AddCompositeSceneCommand = new RelayCommand(AddCompositeScene);
+ AddPrefabCommand = new RelayCommand(AddPrefab);
+ AddAnimationCommand = new RelayCommand(AddAnimation);
+ }
+
+ void OnPortholeFrameUpdated() => NotifyPropertyChanged(nameof(PortholeLiveFrame));
+
+ void OnPlaybackContextChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(CscPlaybackContext.CurrentTime))
+ NotifyPropertyChanged(nameof(CurrentTime));
+ else if (e.PropertyName == nameof(CscPlaybackContext.Duration))
+ {
+ NotifyPropertyChanged(nameof(Duration));
+ SceneRootItems[0].Duration = Duration;
+ }
+ else if (e.PropertyName == nameof(CscPlaybackContext.IsPlaying))
+ NotifyPropertyChanged(nameof(IsPlaying));
+ else if (e.PropertyName == nameof(CscPlaybackContext.LookThroughElementId))
+ NotifyPropertyChanged(nameof(IsLookingThroughCamera));
+ }
+
+ BitmapImage? LoadPackImage(string path)
+ {
+ try
+ {
+ var file = _packFileService.FindFile(path);
+ if (file == null)
+ {
+ _logger.Warning("Porthole overlay image not found in loaded packs: {Path}", path);
+ return null;
+ }
+
+ using var stream = new MemoryStream(file.DataSource.ReadData());
+ var image = new BitmapImage();
+ image.BeginInit();
+ image.CacheOption = BitmapCacheOption.OnLoad;
+ image.StreamSource = stream;
+ image.EndInit();
+ image.Freeze();
+ return image;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "Failed to load porthole overlay image {Path}", path);
+ return null;
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Load / save
+ // ---------------------------------------------------------------------
+
+ public void LoadFile(PackFile file)
+ {
+ CurrentFile = file;
+ DisplayName = file.Name;
+
+ try
+ {
+ SceneData = CscScene.Load(file.DataSource.ReadData());
+ _context.Duration = SceneData.Duration;
+ _context.CurrentTime = 0;
+ _context.IsPlaying = false;
+ SceneRootItems[0].SetScene(SceneData);
+
+ // Build the 3D scene first - root-ref sub-scenes are loaded during the build and
+ // the tree wants to show their contents too.
+ _sceneBuilder.Build(SceneData);
+ RebuildElementTree();
+ _animation.SetScene(SceneData);
+ _animation.ApplyFrame();
+
+ var warning = SceneData.ManifestParsed
+ ? ""
+ : " (manifest not editable - keyframe add/remove disabled)";
+ StatusText = $"{SceneData.Elements.Count} elements, ROOT v{SceneData.RootVersion}, " +
+ $"duration {SceneData.Duration:0.##}s{warning}";
+ NotifyPropertyChanged(nameof(Duration));
+ SceneRootItems[0].Duration = Duration;
+ NotifyPropertyChanged(nameof(ManifestEditable));
+ NotifyPropertyChanged(nameof(CurveStructureLocked));
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e, "Failed to load CSC file {Name}", file.Name);
+ _dialogs.ShowExceptionWindow(e, $"Failed to load '{file.Name}' as a CSC scene.");
+ }
+ }
+
+ public bool Save()
+ {
+ if (SceneData == null)
+ return false;
+
+ try
+ {
+ var bytes = CscSceneWriter.Write(SceneData);
+ var path = _packFileService.GetFullPath(CurrentFile);
+ var result = _fileSaveService.Save(path, bytes, prompOnConflict: false);
+ if (result != null)
+ {
+ CurrentFile = result;
+ HasUnsavedChanges = false;
+ StatusText = $"Saved {result.Name}";
+ return true;
+ }
+ return false;
+ }
+ catch (Exception e)
+ {
+ _logger.Error(e, "Failed to save CSC file");
+ _dialogs.ShowExceptionWindow(e, "Failed to save the CSC scene. The file on disk is unchanged.");
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Tree building / selection
+ // ---------------------------------------------------------------------
+
+ void RebuildElementTree()
+ {
+ RootElements.Clear();
+ if (SceneData == null)
+ return;
+
+ foreach (var root in SceneData.RootElements)
+ RootElements.Add(BuildTreeNode(root));
+ }
+
+ /// Builds a tree node for and its children, resolving a
+ /// root-ref's referenced sub-scene contents (display-only) if one is already loaded. Shared
+ /// by (full reload) and (a single
+ /// newly created element).
+ CscElementViewModel BuildTreeNode(CscElement element)
+ {
+ var vm = new CscElementViewModel(element, OnElementModified);
+ foreach (var child in element.Children)
+ vm.Children.Add(BuildTreeNode(child));
+
+ // Root-ref elements show the referenced scene's contents (display-only), and its
+ // own ROOT header info (duration, focus point, radius, weather path) read-only.
+ if (element.Kind == CscElementKind.RootRef)
+ vm.SetSubScene(_sceneBuilder.SubScenes.FirstOrDefault(s => s.Host == element)?.Scene);
+
+ foreach (var sub in _sceneBuilder.SubScenes.Where(s => s.Host == element))
+ foreach (var subRoot in sub.Scene.RootElements)
+ vm.Children.Add(BuildTreeNode(subRoot));
+
+ return vm;
+ }
+
+ public CscElementViewModel? FindViewModel(CscElement element)
+ {
+ CscElementViewModel? Search(ObservableCollection items)
+ {
+ foreach (var item in items)
+ {
+ if (item.Element == element)
+ return item;
+ if (Search(item.Children) is { } found)
+ return found;
+ }
+ return null;
+ }
+ return Search(RootElements);
+ }
+
+ void OnElementModified(CscElementViewModel vm, ElementChange change)
+ {
+ HasUnsavedChanges = true;
+ if (change == ElementChange.Asset && SceneData != null)
+ {
+ _sceneBuilder.RefreshContent(vm.Element);
+ _sceneBuilder.RefreshAnimationBindings(SceneData);
+
+ // A changed root-ref path swaps the whole displayed sub-scene - re-list it.
+ if (vm.Element.Kind == CscElementKind.RootRef)
+ RebuildElementTree();
+ }
+ _animation.ApplyFrame();
+ }
+
+ /// Called when the Root node's own ROOT header fields (duration, focus point,
+ /// radius, weather path) are edited - re-syncs playback's own Duration to a changed header
+ /// value, same as LoadFile.
+ void OnSceneRootModified()
+ {
+ HasUnsavedChanges = true;
+ if (SceneData == null)
+ return;
+
+ _context.Duration = SceneData.Duration;
+ var warning = SceneData.ManifestParsed ? "" : " (manifest not editable - keyframe add/remove disabled)";
+ StatusText = $"{SceneData.Elements.Count} elements, ROOT v{SceneData.RootVersion}, " +
+ $"duration {SceneData.Duration:0.##}s{warning}";
+ }
+
+ void OnGizmoModifiedElement()
+ {
+ HasUnsavedChanges = true;
+ _animation.ApplyFrame();
+ SelectedElement?.RefreshFromDomain();
+ RedrawCurves?.Invoke();
+ }
+
+ void OnScene3dSelectionChanged(SelectionChangedEvent selectionEvent)
+ {
+ if (selectionEvent.NewState is not ObjectSelectionState objectSelection)
+ return;
+
+ var selected = objectSelection.GetSingleSelectedObject();
+ if (selected == null)
+ return;
+
+ var element = _sceneBuilder.FindOwningElement(selected);
+
+ // Hand the click over to the element-level selection and drop the engine's own mesh
+ // selection: its highlight draws the geometry bounding box without any world
+ // transform, which would render an orphaned box at the scene origin.
+ objectSelection.Clear();
+
+ if (element == null || element == SelectedElement?.Element)
+ return;
+
+ var vm = FindViewModel(element);
+ if (vm != null)
+ {
+ vm.IsSelected = true; // two-way TreeViewItem binding routes back to SelectedElement
+ ExpandTo(vm);
+ }
+ }
+
+ void ExpandTo(CscElementViewModel target)
+ {
+ bool Expand(ObservableCollection items)
+ {
+ foreach (var item in items)
+ {
+ if (item == target || Expand(item.Children))
+ {
+ item.IsExpanded = true;
+ return true;
+ }
+ }
+ return false;
+ }
+ Expand(RootElements);
+ }
+
+ /// Called by the curve editor when keyframes changed through it.
+ public void OnCurvesModified()
+ {
+ HasUnsavedChanges = true;
+ _animation.ApplyFrame();
+ SelectedElement?.RefreshFromDomain();
+ }
+
+ /// Called by the view when a splice/node-transform bone id field is edited.
+ public void OnAuxFieldModified() => HasUnsavedChanges = true;
+
+ /// Hook the view uses to repaint the curve control after out-of-band data changes.
+ public Action? RedrawCurves { get; set; }
+
+ void RebuildCurveSeries()
+ {
+ CurveSeriesList.Clear();
+ SelectedCurveSeries = null;
+
+ var element = SelectedElement?.Element;
+ if (element == null)
+ return;
+
+ void Add(string name, System.Windows.Media.Color colour, CscChannel channel) =>
+ CurveSeriesList.Add(new CurveSeries { Name = name, Colour = colour, Channel = channel });
+
+ var mediaColors = new[]
+ {
+ System.Windows.Media.Color.FromRgb(235, 90, 90),
+ System.Windows.Media.Color.FromRgb(120, 220, 120),
+ System.Windows.Media.Color.FromRgb(110, 160, 250),
+ };
+
+ if (element.Position != null)
+ for (var i = 0; i < element.Position.Channels.Count; i++)
+ Add($"Position {"XYZ"[i]}", mediaColors[i % 3], element.Position.Channels[i]);
+ if (element.Rotation != null)
+ for (var i = 0; i < element.Rotation.Channels.Count; i++)
+ Add($"Rotation {"XYZ"[i]}", mediaColors[i % 3], element.Rotation.Channels[i]);
+ if (element.Scale is { Channels.Count: > 0 })
+ Add("Scale", System.Windows.Media.Color.FromRgb(230, 200, 90), element.Scale.Channels[0]);
+ if (element.Weight is { Channels.Count: > 0 })
+ Add("Weight", System.Windows.Media.Color.FromRgb(200, 130, 230), element.Weight.Channels[0]);
+
+ for (var g = 0; g < element.TypeGroups.Count; g++)
+ {
+ var group = element.TypeGroups[g];
+ for (var c = 0; c < group.Channels.Count; c++)
+ {
+ var name = group.Channels.Count == 1
+ ? element.TypeGroupName(g)
+ : $"{element.TypeGroupName(g)} {"RGB"[Math.Min(c, 2)]}";
+ Add(name, mediaColors[(g + c) % 3], group.Channels[c]);
+ }
+ }
+
+ // Show animated channels by default; if nothing is animated, show position.
+ var anyAnimated = CurveSeriesList.Any(s => s.Channel.Keyframes.Count > 0);
+ foreach (var series in CurveSeriesList)
+ series.IsVisible = anyAnimated ? series.Channel.Keyframes.Count > 0 : series.Name.StartsWith("Position");
+
+ SelectedCurveSeries = CurveSeriesList.FirstOrDefault(s => s.IsVisible);
+ RedrawCurves?.Invoke();
+ }
+
+ // ---------------------------------------------------------------------
+ // Structural edits
+ // ---------------------------------------------------------------------
+
+ void AddModel()
+ {
+ var result = _dialogs.DisplayBrowseDialog([".rigid_model_v2", ".wsmodel"]);
+ if (result.Result == false || result.File == null)
+ return;
+
+ AddElement(CscElementKind.Model, _packFileService.GetFullPath(result.File));
+ }
+
+ void AddVariantModel()
+ {
+ var result = _dialogs.DisplayBrowseDialog([".variantmeshdefinition"]);
+ if (result.Result == false || result.File == null)
+ return;
+
+ AddElement(CscElementKind.VariantModel, _packFileService.GetFullPath(result.File));
+ }
+
+ void AddCompositeScene()
+ {
+ var result = _dialogs.DisplayBrowseDialog([".csc"]);
+ if (result.Result == false || result.File == null)
+ return;
+
+ AddElement(CscElementKind.RootRef, _packFileService.GetFullPath(result.File));
+ }
+
+ void AddPrefab()
+ {
+ var result = _dialogs.DisplayBrowseDialog([".bmd"]);
+ if (result.Result == false || result.File == null)
+ return;
+
+ AddElement(CscElementKind.Prefab, _packFileService.GetFullPath(result.File));
+ }
+
+ void AddAnimation()
+ {
+ var result = _dialogs.DisplayBrowseDialog([".anim"]);
+ if (result.Result == false || result.File == null)
+ return;
+
+ AddElement(CscElementKind.Animation, _packFileService.GetFullPath(result.File));
+ }
+
+ void AddNamedElement(CscElementKind kind, string prompt)
+ {
+ var input = _dialogs.ShowTextInputDialog(prompt);
+ if (input.Result == false || string.IsNullOrWhiteSpace(input.Text))
+ return;
+
+ AddElement(kind, input.Text.Trim());
+ }
+
+ void AddElement(CscElementKind kind, string assetPath = "")
+ {
+ if (SceneData == null)
+ return;
+
+ // New elements go into ROOT's attach tree, so a nested selection falls back to its
+ // nearest non-nested ancestor and an external (root-ref sub-scene) selection falls
+ // back to the hosting root-ref element.
+ var parent = SelectedElement?.Element;
+ while (parent is { IsExternal: true })
+ {
+ var current = parent;
+ parent = _sceneBuilder.SubScenes.FirstOrDefault(s => s.ElementIds.Contains(current.Id))?.Host;
+ }
+ while (parent is { IsNested: true })
+ parent = parent.Parent;
+
+ var element = CscElementFactory.Create(SceneData, kind, assetPath);
+ SceneData.AddElement(element, parent);
+
+ // Added before building the tree node so a new root-ref's sub-scene (loaded inside
+ // AddElement) is already in SubScenes for BuildTreeNode to pick up.
+ _sceneBuilder.AddElement(element);
+
+ var vm = BuildTreeNode(element);
+ var parentVm = parent != null ? FindViewModel(parent) : null;
+ if (parentVm != null)
+ {
+ parentVm.Children.Add(vm);
+ parentVm.IsExpanded = true;
+ }
+ else
+ {
+ RootElements.Add(vm);
+ }
+
+ _sceneBuilder.RefreshAnimationBindings(SceneData);
+ _animation.ApplyFrame();
+ HasUnsavedChanges = true;
+ vm.IsSelected = true;
+ StatusText = $"Added {kind} element [{element.Id}]" + (parent != null ? $" under [{parent.Id}]" : "");
+ }
+
+ void DeleteSelected()
+ {
+ var vm = SelectedElement;
+ if (vm == null || SceneData == null)
+ return;
+
+ if (vm.Element.IsNested)
+ {
+ StatusText = "Nested elements live inside their carrier's record and cannot be deleted individually";
+ return;
+ }
+
+ if (vm.Element.IsExternal)
+ {
+ StatusText = "Elements of a referenced scene are display-only - edit the referenced .csc instead";
+ return;
+ }
+
+ var subtreeSize = 1 + CountDescendants(vm.Element);
+ var confirm = _dialogs.ShowYesNoBox(
+ subtreeSize > 1
+ ? $"Delete element [{vm.Element.Id}] and its {subtreeSize - 1} descendant(s)?"
+ : $"Delete element [{vm.Element.Id}]?",
+ "Delete element");
+ if (confirm != ShowMessageBoxResult.OK)
+ return;
+
+ void RemoveNodes(CscElement element)
+ {
+ foreach (var child in element.Children)
+ RemoveNodes(child);
+ _sceneBuilder.RemoveElement(element.Id);
+ }
+ RemoveNodes(vm.Element);
+
+ SceneData.RemoveElementSubtree(vm.Element);
+ RemoveVm(RootElements, vm);
+ SelectedElement = null;
+ HasUnsavedChanges = true;
+ }
+
+ static int CountDescendants(CscElement element) =>
+ element.Children.Sum(child => 1 + CountDescendants(child));
+
+ static bool RemoveVm(ObservableCollection items, CscElementViewModel target)
+ {
+ if (items.Remove(target))
+ return true;
+ foreach (var item in items)
+ if (RemoveVm(item.Children, target))
+ return true;
+ return false;
+ }
+
+ /// Reparents via tree drag-drop or the Detach command. Null parent = top level.
+ public void ReparentElement(CscElementViewModel? childVm, CscElementViewModel? newParentVm)
+ {
+ if (childVm == null || SceneData == null || childVm == newParentVm)
+ return;
+
+ if (!SceneData.Reparent(childVm.Element, newParentVm?.Element))
+ {
+ StatusText = childVm.Element.IsExternal || (newParentVm?.Element.IsExternal ?? false)
+ ? "Elements of a referenced scene are display-only and cannot be re-parented"
+ : childVm.Element.IsNested || (newParentVm?.Element.IsNested ?? false)
+ ? "Nested elements are bound to their carrier record and cannot be re-parented"
+ : "Cannot reparent: would create a cycle";
+ return;
+ }
+
+ RemoveVm(RootElements, childVm);
+ if (newParentVm != null)
+ {
+ newParentVm.Children.Add(childVm);
+ newParentVm.IsExpanded = true;
+ }
+ else
+ {
+ RootElements.Add(childVm);
+ }
+
+ childVm.RefreshFromDomain();
+ _sceneBuilder.RefreshAnimationBindings(SceneData);
+ _animation.ApplyFrame();
+ HasUnsavedChanges = true;
+ StatusText = newParentVm != null
+ ? $"[{childVm.Element.Id}] attached to [{newParentVm.Element.Id}]"
+ : $"[{childVm.Element.Id}] detached (top level)";
+ }
+
+ void FocusSelected()
+ {
+ if (SelectedElement == null)
+ return;
+ var world = _sceneBuilder.ElementNodes.TryGetValue(SelectedElement.Element.Id, out var node)
+ ? node.WorldMatrix
+ : SelectedElement.Element.WorldTransform(_context.CurrentTime);
+ _camera.LookAt = world.Translation;
+ }
+
+ void LookThroughSelectedCamera()
+ {
+ if (SelectedElement?.Element is { Kind: CscElementKind.Camera } camera)
+ _animation.SetLookThrough(camera.Id);
+ }
+
+ public void Close()
+ {
+ }
+
+ public void Dispose()
+ {
+ _eventHub?.UnRegister(this);
+ _gizmo.ElementModified -= OnGizmoModifiedElement;
+ _animation.PortholeFrameUpdated -= OnPortholeFrameUpdated;
+ _context.PropertyChanged -= OnPlaybackContextChanged;
+ _animation.ClearLookThrough();
+ _sceneBuilder.Clear();
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/ViewModels/CscElementViewModel.cs b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscElementViewModel.cs
new file mode 100644
index 000000000..a579c85d1
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscElementViewModel.cs
@@ -0,0 +1,307 @@
+using System;
+using System.Collections.ObjectModel;
+using Editors.CscEditor.Data;
+using Microsoft.Xna.Framework;
+using Shared.Core.Misc;
+
+namespace Editors.CscEditor.ViewModels
+{
+ public enum ElementChange
+ {
+ /// Values changed - transforms/curves need re-applying.
+ Data,
+ /// The referenced asset changed - the 3D content must be rebuilt.
+ Asset,
+ }
+
+ /// Tree item + details-panel adapter over a .
+ public class CscElementViewModel : NotifyPropertyChangedImpl
+ {
+ public CscElement Element { get; }
+ readonly Action _onModified;
+
+ public ObservableCollection Children { get; } = [];
+
+ public CscElementViewModel(CscElement element, Action onModified)
+ {
+ Element = element;
+ _onModified = onModified;
+ }
+
+ bool _isSelected;
+ public bool IsSelected { get => _isSelected; set => SetAndNotify(ref _isSelected, value); }
+
+ bool _isExpanded = true;
+ public bool IsExpanded { get => _isExpanded; set => SetAndNotify(ref _isExpanded, value); }
+
+ public string DisplayName => Element.DisplayName;
+
+ /// User-facing kind label. Differs from 's own member
+ /// names for two kinds where the internal name (matching the ESF record grammar) reads as
+ /// confusing/jargon-y in the tree: "RootRef" -> "Composite Scene" (it's a nested reference
+ /// to another .csc, not a "root" of anything from the user's point of view) and "Locator"
+ /// -> "Group" (matching the .csc format's own "element group" terminology - it's just an
+ /// ELEMENT with no type record, i.e. an attach-tree grouping node).
+ public string KindName => Element.Kind switch
+ {
+ CscElementKind.RootRef => "Composite Scene",
+ CscElementKind.Locator => "Group",
+ _ => Element.Kind.ToString(),
+ };
+
+ public int Id => Element.Id;
+ public bool IsEditable => !Element.IsLegacyRaw && !Element.IsExternal;
+
+ public string? ReadOnlyReason => Element.IsExternal
+ ? "Part of a referenced scene (via Composite Scene reference) - display-only, edit the referenced .csc instead"
+ : Element.IsLegacyRaw
+ ? "Legacy record layout - preserved verbatim, read-only"
+ : null;
+
+ // ---- Kind flags for panel visibility ----
+ public bool ShowAssetPath => Element.Kind is CscElementKind.Model or CscElementKind.VariantModel
+ or CscElementKind.Vfx or CscElementKind.Sfx or CscElementKind.Animation
+ or CscElementKind.Prefab or CscElementKind.RootRef or CscElementKind.SoundSphere;
+ public bool IsSfx => Element.Kind == CscElementKind.Sfx;
+ public bool SfxHasSecondEventPair => Element.SfxHasSecondEventPair;
+ public string AssetPathLabel => Element.Kind switch
+ {
+ CscElementKind.Sfx => "Event 1 (start)",
+ CscElementKind.Vfx => "Effect name",
+ _ => "Asset",
+ };
+ public bool IsLight => Element.Kind is CscElementKind.PointLight or CscElementKind.SpotLight;
+ public bool IsPointLight => Element.Kind == CscElementKind.PointLight;
+ public bool IsSpotLight => Element.Kind == CscElementKind.SpotLight;
+ public bool IsCamera => Element.Kind == CscElementKind.Camera;
+ public bool HasAttachParent => Element.Parent != null;
+
+ // ---- Composite Scene (RootRef): the referenced .csc's own ROOT header info, shown
+ // read-only - edit the referenced file directly instead (see CscScene's FocusPoint/
+ // Radius/WeatherPath docs for what these fields mean). No setters at all (not merely
+ // disabled bindings) so there is no path through which this VM could ever write back into
+ // the referenced CscScene.
+ CscScene? _subScene;
+
+ /// Set by the owning view model once the referenced scene has (or hasn't) loaded
+ /// - null while unset, on load failure, or for any non-RootRef element.
+ public void SetSubScene(CscScene? subScene)
+ {
+ _subScene = subScene;
+ NotifyPropertyChanged(nameof(HasSubScene));
+ NotifyPropertyChanged(nameof(SubSceneDuration));
+ NotifyPropertyChanged(nameof(SubSceneFocusPointX));
+ NotifyPropertyChanged(nameof(SubSceneFocusPointY));
+ NotifyPropertyChanged(nameof(SubSceneFocusPointZ));
+ NotifyPropertyChanged(nameof(SubSceneRadius));
+ NotifyPropertyChanged(nameof(SubSceneHasWeatherPath));
+ NotifyPropertyChanged(nameof(SubSceneWeatherPath));
+ }
+
+ public bool HasSubScene => Element.Kind == CscElementKind.RootRef && _subScene != null;
+ public float SubSceneDuration => _subScene?.Duration ?? 0f;
+ public float SubSceneFocusPointX => _subScene?.FocusPoint.X ?? 0f;
+ public float SubSceneFocusPointY => _subScene?.FocusPoint.Y ?? 0f;
+ public float SubSceneFocusPointZ => _subScene?.FocusPoint.Z ?? 0f;
+ public float SubSceneRadius => _subScene?.Radius ?? 0f;
+ public bool SubSceneHasWeatherPath => _subScene?.HasWeatherPath ?? false;
+ public string SubSceneWeatherPath => _subScene?.WeatherPath ?? "";
+
+ // ---- ANIMATION_SPLICE_ELEMENT: a sibling record alongside ANIMATION_ELEMENT ----
+ public bool HasSplice => Element.SpliceRecord != null;
+
+ public int SpliceBoneId
+ {
+ get => Element.SpliceBoneId;
+ set { Element.SpliceBoneId = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public int SpliceDepthA
+ {
+ get => Element.SpliceDepthA;
+ set { Element.SpliceDepthA = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public int SpliceDepthB
+ {
+ get => Element.SpliceDepthB;
+ set { Element.SpliceDepthB = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public string SpliceRemainingFieldsDisplay => Element.SpliceRemainingFieldsDisplay;
+
+ // ---- ANIMATION_NODE_TRANSFORM_ELEMENT: this element's own kind ----
+ public bool IsAnimationNodeTransform => Element.Kind == CscElementKind.AnimationNodeTransform;
+
+ public int NodeTransformBoneId
+ {
+ get => Element.NodeTransformBoneId;
+ set { Element.NodeTransformBoneId = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public int NodeTransformSecondValue
+ {
+ get => Element.NodeTransformSecondValue;
+ set { Element.NodeTransformSecondValue = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ void Modified(ElementChange change = ElementChange.Data) => _onModified(this, change);
+
+ // ---- Asset ----
+ public string AssetPath
+ {
+ get => Element.AssetPath;
+ set { Element.AssetPath = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(DisplayName)); Modified(ElementChange.Asset); }
+ }
+
+ public string SfxStopEvent
+ {
+ get => Element.SfxStopEvent;
+ set { Element.SfxStopEvent = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public string SfxEvent2Start
+ {
+ get => Element.SfxEvent2Start;
+ set { Element.SfxEvent2Start = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public string SfxEvent2Stop
+ {
+ get => Element.SfxEvent2Stop;
+ set { Element.SfxEvent2Stop = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ // ---- Timing ----
+ public float Begin
+ {
+ get => Element.Begin;
+ set { Element.Begin = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public float End
+ {
+ get => Element.End;
+ set { Element.End = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public string TimingMode
+ {
+ get => Element.TimingMode;
+ set { Element.TimingMode = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public string AnchorMode
+ {
+ get => Element.AnchorMode;
+ set { Element.AnchorMode = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public int AttachBoneIndex
+ {
+ get => Element.AttachBoneIndex;
+ set { Element.AttachBoneIndex = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ // ---- ELEMENT's trailing Bool (v7/v100 only) - meaning unconfirmed, shown for every kind ----
+ public bool HasElementTrailingBool => Element.HasElementTrailingBool;
+
+ public bool ElementTrailingBool
+ {
+ get => Element.ElementTrailingBool;
+ set { Element.ElementTrailingBool = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ // ---- ELEMENT_PERIOD: (flag, speed multiplier, time offset) - in-game confirmed ----
+ public bool HasPeriod => Element.PeriodRecord != null;
+
+ public bool PeriodFlag
+ {
+ get => Element.PeriodFlag;
+ set { Element.PeriodFlag = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public float PeriodSpeedMultiplier
+ {
+ get => Element.PeriodSpeedMultiplier;
+ set { Element.PeriodSpeedMultiplier = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ public float PeriodTimeOffset
+ {
+ get => Element.PeriodTimeOffset;
+ set { Element.PeriodTimeOffset = value; NotifyPropertyChanged(); Modified(); }
+ }
+
+ // ---- Static channel transform (header when un-animated; shifts the whole curve when animated) ----
+ float ChannelStatic(CscChannelGroup? group, int index) =>
+ group != null && index < group.Channels.Count ? group.Channels[index].StaticValue : 0;
+
+ void SetChannelStatic(CscChannelGroup? group, int index, float value)
+ {
+ if (group == null || index >= group.Channels.Count)
+ return;
+ group.Channels[index].SetStatic(value);
+ Modified();
+ }
+
+ public float PositionX { get => ChannelStatic(Element.Position, 0); set { SetChannelStatic(Element.Position, 0, value); NotifyPropertyChanged(); } }
+ public float PositionY { get => ChannelStatic(Element.Position, 1); set { SetChannelStatic(Element.Position, 1, value); NotifyPropertyChanged(); } }
+ public float PositionZ { get => ChannelStatic(Element.Position, 2); set { SetChannelStatic(Element.Position, 2, value); NotifyPropertyChanged(); } }
+
+ public float RotationXDegrees { get => MathHelper.ToDegrees(ChannelStatic(Element.Rotation, 0)); set { SetChannelStatic(Element.Rotation, 0, MathHelper.ToRadians(value)); NotifyPropertyChanged(); } }
+ public float RotationYDegrees { get => MathHelper.ToDegrees(ChannelStatic(Element.Rotation, 1)); set { SetChannelStatic(Element.Rotation, 1, MathHelper.ToRadians(value)); NotifyPropertyChanged(); } }
+ public float RotationZDegrees { get => MathHelper.ToDegrees(ChannelStatic(Element.Rotation, 2)); set { SetChannelStatic(Element.Rotation, 2, MathHelper.ToRadians(value)); NotifyPropertyChanged(); } }
+
+ public float ScaleValue { get => ChannelStatic(Element.Scale, 0); set { SetChannelStatic(Element.Scale, 0, value); NotifyPropertyChanged(); } }
+ public float WeightValue { get => ChannelStatic(Element.Weight, 0); set { SetChannelStatic(Element.Weight, 0, value); NotifyPropertyChanged(); } }
+
+ // ---- Base placement (ELEMENT's trailing Coord3d pair) ----
+ public float BasePositionX { get => Element.BasePosition.X; set { Element.BasePosition = new Vector3(value, Element.BasePosition.Y, Element.BasePosition.Z); NotifyPropertyChanged(); Modified(); } }
+ public float BasePositionY { get => Element.BasePosition.Y; set { Element.BasePosition = new Vector3(Element.BasePosition.X, value, Element.BasePosition.Z); NotifyPropertyChanged(); Modified(); } }
+ public float BasePositionZ { get => Element.BasePosition.Z; set { Element.BasePosition = new Vector3(Element.BasePosition.X, Element.BasePosition.Y, value); NotifyPropertyChanged(); Modified(); } }
+
+ public float BaseRotationXDegrees { get => MathHelper.ToDegrees(Element.BaseRotation.X); set { Element.BaseRotation = new Vector3(MathHelper.ToRadians(value), Element.BaseRotation.Y, Element.BaseRotation.Z); NotifyPropertyChanged(); Modified(); } }
+ public float BaseRotationYDegrees { get => MathHelper.ToDegrees(Element.BaseRotation.Y); set { Element.BaseRotation = new Vector3(Element.BaseRotation.X, MathHelper.ToRadians(value), Element.BaseRotation.Z); NotifyPropertyChanged(); Modified(); } }
+ public float BaseRotationZDegrees { get => MathHelper.ToDegrees(Element.BaseRotation.Z); set { Element.BaseRotation = new Vector3(Element.BaseRotation.X, Element.BaseRotation.Y, MathHelper.ToRadians(value)); NotifyPropertyChanged(); Modified(); } }
+
+ // ---- Lights ----
+ float TypeChannelStatic(int group, int channel = 0) => Element.TypeChannel(group, channel)?.StaticValue ?? 0;
+
+ void SetTypeChannelStatic(int group, float value, int channel = 0)
+ {
+ var ch = Element.TypeChannel(group, channel);
+ if (ch == null)
+ return;
+ ch.SetStatic(value);
+ Modified();
+ }
+
+ public float LightColourR { get => TypeChannelStatic(0, 0); set { SetTypeChannelStatic(0, value, 0); NotifyPropertyChanged(); } }
+ public float LightColourG { get => TypeChannelStatic(0, 1); set { SetTypeChannelStatic(0, value, 1); NotifyPropertyChanged(); } }
+ public float LightColourB { get => TypeChannelStatic(0, 2); set { SetTypeChannelStatic(0, value, 2); NotifyPropertyChanged(); } }
+ public float LightIntensity { get => TypeChannelStatic(1); set { SetTypeChannelStatic(1, value); NotifyPropertyChanged(); } }
+
+ /// Range for point lights, length for spot lights - both live in type group 2.
+ public float LightRange { get => TypeChannelStatic(2); set { SetTypeChannelStatic(2, value); NotifyPropertyChanged(); } }
+
+ public float SpotInnerAngleDegrees { get => MathHelper.ToDegrees(TypeChannelStatic(3)); set { SetTypeChannelStatic(3, MathHelper.ToRadians(value)); NotifyPropertyChanged(); } }
+ public float SpotOuterAngleDegrees { get => MathHelper.ToDegrees(TypeChannelStatic(4)); set { SetTypeChannelStatic(4, MathHelper.ToRadians(value)); NotifyPropertyChanged(); } }
+
+ // ---- Camera (fov already in degrees on the wire - in-game confirmed). Near/far are the
+ // real render clip planes. Routed through Element's own group-index lookup
+ // (CscElement.CameraGroupIndex), which is the same for every CAMERA_ELEMENT version. ----
+ public float CameraFov { get => Element.CameraFov?.StaticValue ?? 0; set { Element.CameraFov?.SetStatic(value); Modified(); NotifyPropertyChanged(); } }
+ public float CameraRollDegrees { get => MathHelper.ToDegrees(Element.CameraRoll?.StaticValue ?? 0); set { Element.CameraRoll?.SetStatic(MathHelper.ToRadians(value)); Modified(); NotifyPropertyChanged(); } }
+ public float CameraNear { get => Element.CameraNear?.StaticValue ?? 0; set { Element.CameraNear?.SetStatic(value); Modified(); NotifyPropertyChanged(); } }
+ public float CameraFar { get => Element.CameraFar?.StaticValue ?? 0; set { Element.CameraFar?.SetStatic(value); Modified(); NotifyPropertyChanged(); } }
+ public bool HasCameraUnknownFlag => Element.HasCameraUnknownFlag;
+ public bool CameraUnknownFlag { get => Element.CameraUnknownFlag; set { Element.CameraUnknownFlag = value; Modified(); NotifyPropertyChanged(); } }
+
+ /// Re-raises every binding after the domain changed underneath (gizmo drags, curve edits).
+ public void RefreshFromDomain()
+ {
+ NotifyPropertyChanged(string.Empty);
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/ViewModels/CscSceneRootViewModel.cs b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscSceneRootViewModel.cs
new file mode 100644
index 000000000..8315b157b
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/ViewModels/CscSceneRootViewModel.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.ObjectModel;
+using Editors.CscEditor.Data;
+using Microsoft.Xna.Framework;
+using Shared.Core.Misc;
+
+namespace Editors.CscEditor.ViewModels
+{
+ /// Synthetic tree root above the top-level elements - lets the component tree show a
+ /// single "Root" node, gives drag-drop a visible target for detaching an element back to top
+ /// level (dropping on it behaves the same as dropping on empty space), and doubles as the
+ /// selectable/editable adapter over the .csc's own ROOT header fields (scene duration, focus
+ /// point, radius, weather/environment path).
+ public class CscSceneRootViewModel : NotifyPropertyChangedImpl
+ {
+ public ObservableCollection Children { get; }
+ public string DisplayName { get; }
+
+ readonly Action _onModified;
+
+ /// Set by the owning view model on load/reload. Null (and every header-field
+ /// property a harmless default) before a file is loaded.
+ public CscScene? Scene { get; private set; }
+
+ public void SetScene(CscScene? scene)
+ {
+ Scene = scene;
+ NotifyPropertyChanged(nameof(SceneDuration));
+ NotifyPropertyChanged(nameof(FocusPointX));
+ NotifyPropertyChanged(nameof(FocusPointY));
+ NotifyPropertyChanged(nameof(FocusPointZ));
+ NotifyPropertyChanged(nameof(Radius));
+ NotifyPropertyChanged(nameof(HasWeatherPath));
+ NotifyPropertyChanged(nameof(WeatherPath));
+ }
+
+ double _duration;
+ /// The scene's overall *computed* playback duration (seconds) - the longest of the
+ /// ROOT header's own Duration field and every element's own end time, kept in sync by the
+ /// owning view model whenever it changes, so the root tree item doubles as a duration
+ /// readout. Distinct from , which is the raw, directly-authored
+ /// ROOT header field shown/edited in the details panel.
+ public double Duration
+ {
+ get => _duration;
+ set => SetAndNotify(ref _duration, value, _ => NotifyPropertyChanged(nameof(Subtitle)));
+ }
+
+ public string Subtitle => $"duration {Duration:0.##}s";
+
+ bool _isExpanded = true;
+ public bool IsExpanded { get => _isExpanded; set => SetAndNotify(ref _isExpanded, value); }
+
+ bool _isSelected;
+ public bool IsSelected { get => _isSelected; set => SetAndNotify(ref _isSelected, value); }
+
+ public CscSceneRootViewModel(ObservableCollection children, string displayName, Action onModified)
+ {
+ Children = children;
+ DisplayName = displayName;
+ _onModified = onModified;
+ }
+
+ void Modified() => _onModified();
+
+ // ---------------------------------------------------------------------
+ // ROOT header fields (see RootStructureDetector / CscScene for field-position docs)
+ // ---------------------------------------------------------------------
+
+ public float SceneDuration
+ {
+ get => Scene?.Duration ?? 0f;
+ set { if (Scene != null) { Scene.Duration = value; NotifyPropertyChanged(); Modified(); } }
+ }
+
+ public float FocusPointX
+ {
+ get => Scene?.FocusPoint.X ?? 0f;
+ set { if (Scene != null) { var p = Scene.FocusPoint; Scene.FocusPoint = new Vector3(value, p.Y, p.Z); NotifyPropertyChanged(); Modified(); } }
+ }
+
+ public float FocusPointY
+ {
+ get => Scene?.FocusPoint.Y ?? 0f;
+ set { if (Scene != null) { var p = Scene.FocusPoint; Scene.FocusPoint = new Vector3(p.X, value, p.Z); NotifyPropertyChanged(); Modified(); } }
+ }
+
+ public float FocusPointZ
+ {
+ get => Scene?.FocusPoint.Z ?? 0f;
+ set { if (Scene != null) { var p = Scene.FocusPoint; Scene.FocusPoint = new Vector3(p.X, p.Y, value); NotifyPropertyChanged(); Modified(); } }
+ }
+
+ public float Radius
+ {
+ get => Scene?.Radius ?? 0f;
+ set { if (Scene != null) { Scene.Radius = value; NotifyPropertyChanged(); Modified(); } }
+ }
+
+ public bool HasWeatherPath => Scene?.HasWeatherPath ?? false;
+
+ public string WeatherPath
+ {
+ get => Scene?.WeatherPath ?? "";
+ set { if (Scene != null) { Scene.WeatherPath = value; NotifyPropertyChanged(); Modified(); } }
+ }
+ }
+}
diff --git a/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml b/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml
new file mode 100644
index 000000000..98e1b27cb
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml
@@ -0,0 +1,585 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml.cs b/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml.cs
new file mode 100644
index 000000000..64490e263
--- /dev/null
+++ b/Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml.cs
@@ -0,0 +1,85 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using Editors.CscEditor.ViewModels;
+
+namespace Editors.CscEditor.Views
+{
+ public partial class CscEditorView : UserControl
+ {
+ Point _dragStart;
+ CscElementViewModel? _dragCandidate;
+
+ CscEditorViewModel? ViewModel => DataContext as CscEditorViewModel;
+
+ public CscEditorView()
+ {
+ InitializeComponent();
+ CurveEditor.CurvesModified += () => ViewModel?.OnCurvesModified();
+ DataContextChanged += (_, _) =>
+ {
+ if (ViewModel != null)
+ ViewModel.RedrawCurves = CurveEditor.Redraw;
+ };
+ }
+
+ void ComponentTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs