From c2c33238842097f93a1fb33aeb28f715f0522b55 Mon Sep 17 00:00:00 2001 From: robert-d-schultz Date: Wed, 22 Jul 2026 07:55:51 -0400 Subject: [PATCH 1/2] Fix mouse capture, attach-point loading, and bone-attach resolver bugs - WpfMouse: a missed hit-test mid-drag no longer stops position tracking before the button is released, so fast mouse movement during a capture drag doesn't freeze the cursor. - ComplexMeshLoader: nested children under a were losing their attach_point name (the recursive call passed the outer attachmentPointName through instead of slot.AttachmentPoint). - SkeletonBoneAnimationResolver: added a bind-pose fallback when no animation frame is available and a bounds check against BoneCount, so attach_point/rigid-bone meshes don't collapse to the model origin. - Pin SQLitePCLRaw.bundle_e_sqlite3 2.1.12 in the three TreatWarningsAsErrors projects that pull in a vulnerable transitive version (NU1903 audit advisory). Co-Authored-By: Claude Sonnet 5 --- .../Editor.CampaignAnimationCreator.csproj | 4 ++++ .../Services/ComplexMeshLoader.cs | 2 +- .../Utility/SkeletonBoneAnimationResolver.cs | 20 +++++++++++++------ .../WpfWindow/Input/WpfMouse.cs | 20 +++++++++++-------- .../Shared.EmbeddedResources.csproj | 4 ++++ .../SharedCore/Shared.Core/Shared.Core.csproj | 1 + 6 files changed, 36 insertions(+), 15 deletions(-) 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/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs b/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs index d99a145e0..48fe720a0 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Services/ComplexMeshLoader.cs @@ -122,7 +122,7 @@ void LoadVariantMesh(VariantMesh mesh, SceneNode root, AnimationPlayer player, s break; } - LoadVariantMesh(childMesh, slotNode, player, attachmentPointName, onlyLoadRootNode, onlyLoadFirstMesh); + LoadVariantMesh(childMesh, slotNode, player, slot.AttachmentPoint, onlyLoadRootNode, onlyLoadFirstMesh); } foreach (var meshReference in slot.ChildReferences) diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Utility/SkeletonBoneAnimationResolver.cs b/GameWorld/GameWorldCore/GameWorld.Core/Utility/SkeletonBoneAnimationResolver.cs index 8e4e0d7f2..fe5ae2a3e 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Utility/SkeletonBoneAnimationResolver.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Utility/SkeletonBoneAnimationResolver.cs @@ -19,18 +19,26 @@ public Matrix GetWorldTransform() return _animationProvider.Skeleton.GetAnimatedWorldTranform(_boneIndex); } + /// + /// World transform of the attach bone. Falls back to the skeleton's bind pose when no + /// animation frame is available (player paused, scrubbed, or no clip loaded) so attached + /// meshes (variantmesh attach_point weapons/shields) still sit on their bone instead of + /// collapsing to the model origin. + /// public Matrix GetWorldTransformIfAnimating() { - if (_animationProvider.Skeleton != null && _animationProvider.Skeleton.AnimationPlayer.IsEnabled && _animationProvider.Skeleton.AnimationPlayer.IsPlaying && _boneIndex != -1) - return _animationProvider.Skeleton.GetAnimatedWorldTranform(_boneIndex); - return Matrix.Identity; + var skeleton = _animationProvider.Skeleton; + if (skeleton == null || _boneIndex < 0 || _boneIndex >= skeleton.BoneCount) + return Matrix.Identity; + return skeleton.GetAnimatedWorldTranform(_boneIndex); } public Matrix GetTransformIfAnimating() { - if (_animationProvider.Skeleton != null && _animationProvider.Skeleton.AnimationPlayer.IsEnabled && _animationProvider.Skeleton.AnimationPlayer.IsPlaying && _boneIndex != -1) - return _animationProvider.Skeleton.GetAnimatedTranform(_boneIndex); - return Matrix.Identity; + var skeleton = _animationProvider.Skeleton; + if (skeleton == null || _boneIndex < 0 || _boneIndex >= skeleton.BoneCount) + return Matrix.Identity; + return skeleton.GetAnimatedTranform(_boneIndex); } } } diff --git a/GameWorld/GameWorldCore/GameWorld.Core/WpfWindow/Input/WpfMouse.cs b/GameWorld/GameWorldCore/GameWorld.Core/WpfWindow/Input/WpfMouse.cs index ddc74745a..a2e9a3465 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/WpfWindow/Input/WpfMouse.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/WpfWindow/Input/WpfMouse.cs @@ -138,19 +138,23 @@ private void HandleMouse(object sender, MouseEventArgs e) { if (_focusElement.IsMouseCaptured) { - _mouseState = new MouseState(_mouseState.X, _mouseState.Y, _mouseState.ScrollWheelValue, - (ButtonState)e.LeftButton, (ButtonState)e.MiddleButton, (ButtonState)e.RightButton, (ButtonState)e.XButton1, - (ButtonState)e.XButton2); - // only release if LeftMouse is up + // Once capture is held (left-button drags only - see CaptureMouse() below), + // a single missed hit-test here must not freeze position tracking for the + // rest of the drag: fast mouse movement routinely lands this per-move + // hit-test outside _focusElement even though the drag is still legitimately + // in progress (capture already guarantees events keep routing here + // regardless of position). Only release capture on button-up; otherwise fall + // through to the normal position/button update below instead of returning. if (e.LeftButton == MouseButtonState.Released) { _focusElement.ReleaseMouseCapture(); } - e.Handled = true; } - - // mouse is outside the control and not captured, so don't update the mouse state - return; + else + { + // mouse is outside the control and not captured, so don't update the mouse state + return; + } } } diff --git a/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj b/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj index 2eb415abf..b392a97f9 100644 --- a/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj +++ b/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj @@ -7,6 +7,10 @@ true + + + + diff --git a/Shared/SharedCore/Shared.Core/Shared.Core.csproj b/Shared/SharedCore/Shared.Core/Shared.Core.csproj index 122fad211..b63905dde 100644 --- a/Shared/SharedCore/Shared.Core/Shared.Core.csproj +++ b/Shared/SharedCore/Shared.Core/Shared.Core.csproj @@ -12,6 +12,7 @@ + From 888bfc5775b2d3cb0568508fcbddabbb158f3243 Mon Sep 17 00:00:00 2001 From: robert-d-schultz Date: Wed, 22 Jul 2026 07:56:21 -0400 Subject: [PATCH 2/2] Add CSC (Composite Scene) editor Adds a full editor for Total War .csc files: an ESF envelope reader/writer (Shared/GameFiles/Esf, ported from RPFM's ESF format handling), CSC-specific grammar detectors for ROOT/ELEMENT/ COMPOSITE_SCENE (Shared/GameFiles/CSC), and the editor itself (Editors/CscEditor) - scene graph building, skeletal animation playback/blending/splicing across overlapping bindings, a custom gizmo, a keyframe curve editor, and a look-through-camera porthole preview. Supporting engine changes needed by the editor: - Camera: ViewMatrixOverride/ProjectionMatrixOverride for looking through a scene-defined camera. - RenderEngineComponent/RenderTargetHelper: HideGrid, SquareRenderSize and an alpha-preserving LastFrame for the porthole preview. - GameSkeleton.CreateFromAnimationFile: builds a skeleton directly from an animation's own bone table for "ad-hoc" skeletons that have no dedicated skeleton file (e.g. building-destruction rigs) - also wired into SceneObjectEditor.SetAnimation for every editor. - AnimationPlayer.SetManualFrame: lets external code inject an already-sampled frame, used for cross-fading overlapping clips. - Rmv2MeshNode: corrected bone-attach and pivot composition order, and a new AttachmentOuterWorld slot so CSC's flat scene graph (which must push a leaf's full world into ModelMatrix for picking) and Kitbasher's nested scenegraph can share the same attach-bone resolver without one clobbering the other. 22 new unit tests (ESF round-trip, channel evaluation, scene round-trip with hierarchy/keyframes, nested-element handling). Co-Authored-By: Claude Sonnet 5 --- AssetEditor.sln | 33 + AssetEditor/AssetEditor.csproj | 1 + .../Services/DependencyInjectionConfig.cs | 1 + .../Editors.CscEditor/Data/CscChannel.cs | 182 ++++ .../Editors.CscEditor/Data/CscElement.cs | 425 +++++++++ .../Data/CscElementFactory.cs | 317 +++++++ .../Editors.CscEditor/Data/CscScene.cs | 691 +++++++++++++++ .../Editors.CscEditor/Data/CscSceneWriter.cs | 458 ++++++++++ .../DependencyInjectionContainer.cs | 38 + .../Editors.CscEditor.csproj | 19 + .../Services/CscAnimationComponent.cs | 820 ++++++++++++++++++ .../Services/CscGizmoComponent.cs | 245 ++++++ .../Services/CscPlaybackContext.cs | 32 + .../Services/CscSceneGraphBuilder.cs | 584 +++++++++++++ .../Services/CscSceneNodes.cs | 311 +++++++ .../ViewModels/CscEditorViewModel.cs | 779 +++++++++++++++++ .../ViewModels/CscElementViewModel.cs | 307 +++++++ .../ViewModels/CscSceneRootViewModel.cs | 108 +++ .../Views/CscEditorView.xaml | 585 +++++++++++++ .../Views/CscEditorView.xaml.cs | 85 ++ .../Views/CurveEditorControl.cs | 717 +++++++++++++++ .../Test.CscEditor/CscChannelTests.cs | 100 +++ .../Test.CscEditor/CscNestedElementTests.cs | 178 ++++ .../Test.CscEditor/CscSceneRoundTripTests.cs | 248 ++++++ .../Test.CscEditor/EsfRoundTripTests.cs | 122 +++ .../Test.CscEditor/Test.CscEditor.csproj | 33 + .../Common/SceneObjectEditor.cs | 56 +- .../Animation/AnimationPlayer.cs | 5 + .../GameWorld.Core/Animation/GameSkeleton.cs | 60 ++ .../Components/Rendering/Camera.cs | 14 + .../Rendering/RenderEngineComponent.cs | 34 +- .../Rendering/RenderTargetHelper.cs | 6 +- .../GameWorld.Core/SceneNodes/Rmv2MeshNode.cs | 54 +- .../GameFiles/CSC/CompositeSceneDetector.cs | 294 +++++++ Shared/GameFiles/CSC/CurveDetector.cs | 178 ++++ Shared/GameFiles/CSC/RootStructureDetector.cs | 180 ++++ Shared/GameFiles/Esf/Coordinates.cs | 11 + Shared/GameFiles/Esf/EsfBinaryReader.cs | 88 ++ Shared/GameFiles/Esf/EsfBinaryWriter.cs | 61 ++ Shared/GameFiles/Esf/EsfDocument.cs | 15 + Shared/GameFiles/Esf/EsfNode.cs | 57 ++ Shared/GameFiles/Esf/EsfNodeKind.cs | 57 ++ Shared/GameFiles/Esf/EsfNodeTypeCodes.cs | 87 ++ Shared/GameFiles/Esf/EsfReader.cs | 335 +++++++ Shared/GameFiles/Esf/EsfRecordFlags.cs | 20 + Shared/GameFiles/Esf/EsfSignature.cs | 21 + Shared/GameFiles/Esf/EsfWriter.cs | 366 ++++++++ .../Shared.Core/ToolCreation/EditorEnums.cs | 1 + 48 files changed, 9387 insertions(+), 32 deletions(-) create mode 100644 Editors/CscEditor/Editors.CscEditor/Data/CscChannel.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Data/CscElement.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Data/CscElementFactory.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Data/CscScene.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Data/CscSceneWriter.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/DependencyInjectionContainer.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Editors.CscEditor.csproj create mode 100644 Editors/CscEditor/Editors.CscEditor/Services/CscAnimationComponent.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Services/CscGizmoComponent.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Services/CscPlaybackContext.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Services/CscSceneGraphBuilder.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Services/CscSceneNodes.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/ViewModels/CscEditorViewModel.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/ViewModels/CscElementViewModel.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/ViewModels/CscSceneRootViewModel.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml create mode 100644 Editors/CscEditor/Editors.CscEditor/Views/CscEditorView.xaml.cs create mode 100644 Editors/CscEditor/Editors.CscEditor/Views/CurveEditorControl.cs create mode 100644 Editors/CscEditor/Test.CscEditor/CscChannelTests.cs create mode 100644 Editors/CscEditor/Test.CscEditor/CscNestedElementTests.cs create mode 100644 Editors/CscEditor/Test.CscEditor/CscSceneRoundTripTests.cs create mode 100644 Editors/CscEditor/Test.CscEditor/EsfRoundTripTests.cs create mode 100644 Editors/CscEditor/Test.CscEditor/Test.CscEditor.csproj create mode 100644 Shared/GameFiles/CSC/CompositeSceneDetector.cs create mode 100644 Shared/GameFiles/CSC/CurveDetector.cs create mode 100644 Shared/GameFiles/CSC/RootStructureDetector.cs create mode 100644 Shared/GameFiles/Esf/Coordinates.cs create mode 100644 Shared/GameFiles/Esf/EsfBinaryReader.cs create mode 100644 Shared/GameFiles/Esf/EsfBinaryWriter.cs create mode 100644 Shared/GameFiles/Esf/EsfDocument.cs create mode 100644 Shared/GameFiles/Esf/EsfNode.cs create mode 100644 Shared/GameFiles/Esf/EsfNodeKind.cs create mode 100644 Shared/GameFiles/Esf/EsfNodeTypeCodes.cs create mode 100644 Shared/GameFiles/Esf/EsfReader.cs create mode 100644 Shared/GameFiles/Esf/EsfRecordFlags.cs create mode 100644 Shared/GameFiles/Esf/EsfSignature.cs create mode 100644 Shared/GameFiles/Esf/EsfWriter.cs 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/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 @@ + + + + + + + + + + + + + + + + + +