diff --git a/Anamnesis/Actor/Pages/ActionPage.xaml b/Anamnesis/Actor/Pages/ActionPage.xaml
index 8f9462461..1d00ebd1b 100644
--- a/Anamnesis/Actor/Pages/ActionPage.xaml
+++ b/Anamnesis/Actor/Pages/ActionPage.xaml
@@ -8,9 +8,9 @@
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
xmlns:anaMem="clr-namespace:Anamnesis.Memory"
xmlns:ana="clr-namespace:Anamnesis.Views"
- xmlns:converters="clr-namespace:Anamnesis.Actor.Converters"
+ xmlns:converters="clr-namespace:Anamnesis.Actor.Converters"
xmlns:controls="clr-namespace:Anamnesis.Actor.Controls"
- d:DesignHeight="600"
+ d:DesignHeight="600"
d:DesignWidth="1024"
DataContextChanged="OnDataContextChanged"
Loaded="OnLoaded"
@@ -460,15 +460,25 @@
Icon="User" />
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Anamnesis/Actor/Pages/ActionPage.xaml.cs b/Anamnesis/Actor/Pages/ActionPage.xaml.cs
index 565911400..8a4d7ef26 100644
--- a/Anamnesis/Actor/Pages/ActionPage.xaml.cs
+++ b/Anamnesis/Actor/Pages/ActionPage.xaml.cs
@@ -180,7 +180,7 @@ private void OnResumeAll()
AnimationService.Instance.SpeedControlEnabled = true;
- foreach (var target in TargetService.Instance.PinnedActors)
+ foreach (var target in TargetService.Instance.PinnedActors.ToList())
{
if (target.IsValid && target.Memory != null && target.Memory.IsValid)
{
@@ -199,7 +199,7 @@ private void OnPauseAll()
AnimationService.Instance.SpeedControlEnabled = true;
- foreach (var target in TargetService.Instance.PinnedActors)
+ foreach (var target in TargetService.Instance.PinnedActors.ToList())
{
if (target.IsValid && target.Memory != null && target.Memory.IsValid)
{
diff --git a/Anamnesis/Actor/Pages/PosePage.xaml b/Anamnesis/Actor/Pages/PosePage.xaml
index 093d30b81..16deeba8f 100644
--- a/Anamnesis/Actor/Pages/PosePage.xaml
+++ b/Anamnesis/Actor/Pages/PosePage.xaml
@@ -2,21 +2,17 @@
x:Class="Anamnesis.Actor.Pages.PosePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- xmlns:ana="clr-namespace:Anamnesis.Styles.Controls"
xmlns:anaUtils="clr-namespace:Anamnesis"
xmlns:controls="clr-namespace:Anamnesis.Actor.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
- xmlns:fa="http://schemas.awesome.incremented/wpf/xaml/fontawesome.sharp"
- xmlns:local="clr-namespace:Anamnesis.Actor"
- xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:Anamnesis.Actor.Views"
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
- xmlns:views1="clr-namespace:Anamnesis.Actor.Views"
d:DesignHeight="512"
d:DesignWidth="1024"
DataContextChanged="OnDataContextChanged"
Loaded="OnLoaded"
+ Unloaded="OnUnloaded"
mc:Ignorable="d">
@@ -57,26 +53,32 @@
+ Visibility="{Binding Skeleton.SelectedBones, Converter={StaticResource NotEmptyToVisibilityConverter}}"
+ IsReadOnly="{Binding IsMultipleBonesSelected}"
+ Style="{StaticResource MaterialDesignTextBox}">
+
+
+
+
+ VerticalAlignment="Top"
+ Visibility="{Binding IsSingleBoneSelected, Converter={StaticResource B2V}}"/>
+ Visibility="{Binding Skeleton.SelectedBones, Converter={StaticResource IsEmptyToVisibilityConverter}}" />
@@ -89,24 +91,24 @@
Height="22"
MinWidth="32"
Style="{StaticResource TransparentIconToggleButton}"
- IsEnabled="{Binding Skeleton.CurrentBone.LinkedBonesCount, Converter={StaticResource NotZeroToBool}, FallbackValue=False}"
- IsChecked="{Binding Skeleton.CurrentBone.EnableLinkedBones}">
+ IsEnabled="{Binding Skeleton.SelectedLinkedCount, Mode=OneWay, Converter={StaticResource NotZeroToBool}, FallbackValue=False}"
+ IsChecked="{Binding Skeleton.SelectedEnableLinkedBones, Mode=OneWay}">
-
+
-
-
-
@@ -124,7 +126,7 @@
Padding="6,3"
Click="OnParentClicked"
Style="{StaticResource TransparentIconButton}"
- IsEnabled="{Binding Skeleton.CurrentBone.Parent, Converter={StaticResource NotNullToBoolConverter}}"
+ IsEnabled="{Binding Skeleton.SelectedBonesParents, Mode=OneWay, Converter={StaticResource NotEmptyToVisibilityConverter}}"
anaUtils:Behaviours.Tooltip="Pose_SelectParent">
@@ -151,59 +153,29 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/Anamnesis/Actor/Posing/Views/Pose3DView.xaml.cs b/Anamnesis/Actor/Posing/Views/Pose3DView.xaml.cs
index 03641860c..ef28af18c 100644
--- a/Anamnesis/Actor/Posing/Views/Pose3DView.xaml.cs
+++ b/Anamnesis/Actor/Posing/Views/Pose3DView.xaml.cs
@@ -3,34 +3,50 @@
namespace Anamnesis.Actor.Views;
+using Anamnesis.Actor.Posing;
+using Anamnesis.Actor.Posing.Visuals;
using Anamnesis.Services;
using PropertyChanged;
using Serilog;
using System;
-using System.ComponentModel;
+using System.Collections.Generic;
+using System.IO.Enumeration;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
+using System.Windows.Media;
using System.Windows.Media.Media3D;
using XivToolsWpf;
using XivToolsWpf.Math3D.Extensions;
-using Colors = System.Windows.Media.Colors;
+
+public enum CameraInteractionMode
+{
+ None,
+ Panning,
+ Rotating,
+ Selection,
+}
///
-/// Interaction logic for CharacterPoseView.xaml.
+/// Interaction logic for Pose3DView.xaml.
///
[AddINotifyPropertyChangedInterface]
public partial class Pose3DView : UserControl
{
public readonly PerspectiveCamera Camera;
- public readonly RotateTransform3D CameraRotaion;
+ public readonly RotateTransform3D CameraRotation;
public readonly TranslateTransform3D CameraPosition;
private CancellationTokenSource? camUpdateCancelTokenSrc;
private bool cameraIsTicking = false;
+ private Point lastMousePosition;
+
+ private CameraInteractionMode interactionMode = CameraInteractionMode.None;
+
public Pose3DView()
{
this.InitializeComponent();
@@ -38,32 +54,62 @@ public Pose3DView()
this.Camera = new PerspectiveCamera(new Point3D(0, 0.75, -4), new Vector3D(0, 0, 1), new Vector3D(0, 1, 0), 45);
this.Viewport.Camera = this.Camera;
- this.CameraRotaion = new RotateTransform3D();
- QuaternionRotation3D camRot = new QuaternionRotation3D();
- camRot.Quaternion = CameraService.Instance.Camera?.Rotation3d.ToMedia3DQuaternion() ?? Quaternion.Identity;
- this.CameraRotaion.Rotation = camRot;
+ this.CameraRotation = new RotateTransform3D();
+ QuaternionRotation3D camRot = new()
+ {
+ Quaternion = CameraService.Instance.Camera?.Rotation3d.ToMedia3DQuaternion() ?? Quaternion.Identity,
+ };
+ this.CameraRotation.Rotation = camRot;
this.CameraPosition = new TranslateTransform3D();
- Transform3DGroup transformGroup = new Transform3DGroup();
- transformGroup.Children.Add(this.CameraRotaion);
+
+ Transform3DGroup transformGroup = new();
+ transformGroup.Children.Add(this.CameraRotation);
transformGroup.Children.Add(this.CameraPosition);
this.Camera.Transform = transformGroup;
this.ContentArea.DataContext = this;
+ }
+
+ public SkeletonEntity? Skeleton { get; set; }
+ public SkeletonVisual3D? Visual { get; set; }
+
+ public double CameraDistance { get; set; }
- if (CameraService.Instance.Camera != null)
+ public string BoneSearch { get; set; } = string.Empty;
+ public IEnumerable BoneSearchResult
+ {
+ get
{
- CameraService.Instance.Camera.PropertyChanged += this.OnCameraChanged;
+ if (this.Skeleton == null)
+ return Enumerable.Empty();
+
+ var bones = SkeletonEntity.TraverseSkeleton(this.Skeleton);
+
+ if (string.IsNullOrWhiteSpace(this.BoneSearch))
+ return bones;
+
+ string searchPattern = $"*{this.BoneSearch}*";
+ return bones.Where(b => FileSystemName.MatchesSimpleExpression(searchPattern, b.Name) || FileSystemName.MatchesSimpleExpression(searchPattern, b.Tooltip));
}
}
- public SkeletonVisual3d? Skeleton { get; set; }
+ public bool SyncWithGameCamera { get; set; } = true;
- public double CameraDistance { get; set; }
- public Quaternion CameraRotation { get; set; }
+ private static BoneVisual3D? FindBoneVisual(DependencyObject visual)
+ {
+ while (visual != null)
+ {
+ if (visual is BoneVisual3D boneVisual)
+ return boneVisual;
+
+ visual = VisualTreeHelper.GetParent(visual);
+ }
+
+ return null;
+ }
private void OnLoaded(object sender, RoutedEventArgs e)
{
- this.OnDataContextChanged(null, default);
Task.Run(this.UpdateCamera);
}
@@ -75,91 +121,161 @@ private void OnUnloaded(object sender, RoutedEventArgs e)
private void OnDataContextChanged(object? sender, DependencyPropertyChangedEventArgs e)
{
- this.Skeleton = this.DataContext as SkeletonVisual3d;
-
- if (this.Skeleton == null)
+ if (this.DataContext is not SkeletonEntity skeleton)
return;
+ // Clear the existing children and dispose of the current visual
this.SkeletonRoot.Children.Clear();
+ if (this.Visual != null)
+ {
+ this.Visual.Dispose();
+ this.Visual = null;
+ }
- if (!this.SkeletonRoot.Children.Contains(this.Skeleton))
- this.SkeletonRoot.Children.Add(this.Skeleton);
+ // Set the new skeleton and create a new visual
+ this.Skeleton = skeleton;
+ this.Visual = new SkeletonVisual3D(this.Skeleton);
+ // Add the new visual to the SkeletonRoot
+ this.SkeletonRoot.Children.Add(this.Visual);
this.SkeletonRoot.Children.Add(new ModelVisual3D() { Content = new AmbientLight(Colors.White) });
+ // Frame the skeleton into view
this.FrameSkeleton();
}
- private void ListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- SkeletonVisual3d? vm = this.DataContext as SkeletonVisual3d;
-
- if (vm == null)
- return;
-
- if (e.AddedItems == null || e.AddedItems.Count <= 0)
- return;
-
- BoneVisual3d? selected = e.AddedItems[0] as BoneVisual3d;
-
- if (selected == null)
- return;
-
- vm.Hover(selected, true);
- vm.Select(selected);
- }
-
- private void OnCameraChanged(object? sender, PropertyChangedEventArgs? e)
- {
- if (CameraService.Instance == null || CameraService.Instance.Camera == null)
- return;
- }
-
- private void OnFrameClicked(object sender, RoutedEventArgs e)
+ private void OnResetCameraButtonClicked(object sender, RoutedEventArgs e)
{
this.FrameSkeleton();
}
private void FrameSkeleton()
{
- // position camera at average center position of skeleton
- if (this.Skeleton == null || this.Skeleton.Bones == null || this.Skeleton.Bones.Count <= 0)
+ // Position camera at average center position of skeleton
+ if (this.Skeleton == null || this.Skeleton.Bones == null || this.Skeleton.Bones.IsEmpty)
return;
Rect3D bounds = default;
+ foreach (var bone in this.Skeleton.Bones.Values.OfType())
+ {
+ bounds.Union(new Point3D(bone.Position.X, bone.Position.Y, bone.Position.Z));
+ }
+
+ this.CameraDistance = Math.Max(Math.Max(bounds.SizeX, bounds.SizeY), bounds.SizeZ);
- Vector3D? pos = null;
- foreach (BoneVisual3d visual in this.Skeleton.Bones.Values)
+ if (this.Visual != null)
+ {
+ foreach (BoneVisual3D bone in this.Visual.Children.OfType())
+ {
+ bone.OnCameraUpdated(this);
+ }
+ }
+ }
+
+ private void OnViewportMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton == MouseButton.Left)
+ {
+ Point mousePosition = e.GetPosition(this.Viewport);
+ HitTestResult hitResult = VisualTreeHelper.HitTest(this.Viewport, mousePosition);
+
+ if (hitResult is RayHitTestResult rayHitResult)
+ {
+ BoneVisual3D? boneVisual = FindBoneVisual(rayHitResult.VisualHit);
+ if (boneVisual != null)
+ {
+ this.Skeleton?.Select(boneVisual.Bone);
+ e.Handled = true;
+ }
+ }
+ }
+ else if (e.ChangedButton == MouseButton.Middle)
{
- if (pos == null)
+ if (Keyboard.IsKeyDown(Key.LeftShift))
{
- pos = visual.Position.ToMedia3DVector();
+ this.interactionMode = CameraInteractionMode.Panning;
}
else
{
- pos = pos + visual.Position.ToMedia3DVector();
+ this.interactionMode = CameraInteractionMode.Rotating;
}
- Point3D point = visual.Position.ToMedia3DPoint();
- bounds.Union(point);
+ this.lastMousePosition = e.GetPosition(this.Viewport);
+ this.Viewport.CaptureMouse();
+ e.Handled = true;
}
+ }
- if (pos == null)
- return;
+ private void OnViewportMouseMove(object sender, MouseEventArgs e)
+ {
+ if (this.interactionMode == CameraInteractionMode.Panning)
+ {
+ Point currentMousePosition = e.GetPosition(this.Viewport);
+ Vector delta = Point.Subtract(currentMousePosition, this.lastMousePosition);
- pos = pos / this.Skeleton.Bones.Count;
+ double panSpeedFactor = 0.005;
+ double effectivePanSpeed = SettingsService.Current.ViewportPanSpeed * panSpeedFactor;
- this.CameraDistance = Math.Max(Math.Max(bounds.SizeX, bounds.SizeY), bounds.SizeZ);
+ // Transform the delta vector by the camera's rotation
+ Vector3D panVector = new(delta.X * effectivePanSpeed, delta.Y * effectivePanSpeed, 0);
+ Matrix3D rotationMatrix = this.CameraRotation.Value;
+ Vector3D transformedPanVector = rotationMatrix.Transform(panVector);
+
+ this.CameraPosition.OffsetX += transformedPanVector.X;
+ this.CameraPosition.OffsetY += transformedPanVector.Y;
+ this.CameraPosition.OffsetZ += transformedPanVector.Z;
+
+ this.lastMousePosition = currentMousePosition;
+ e.Handled = true;
+ }
+ else if (this.interactionMode == CameraInteractionMode.Rotating && !this.SyncWithGameCamera)
+ {
+ Point currentMousePosition = e.GetPosition(this.Viewport);
+ Vector delta = Point.Subtract(currentMousePosition, this.lastMousePosition);
+
+ double rotationSpeedFactor = 0.5;
+ double effectiveRotationSpeed = SettingsService.Current.ViewportRotationSpeed * rotationSpeedFactor;
+ QuaternionRotation3D rot = (QuaternionRotation3D)this.CameraRotation.Rotation;
+ Quaternion q = rot.Quaternion;
+
+ q *= new Quaternion(new Vector3D(0, 1, 0), -delta.X * effectiveRotationSpeed);
+ q *= new Quaternion(new Vector3D(1, 0, 0), delta.Y * effectiveRotationSpeed);
+
+ rot.Quaternion = q;
+ this.CameraRotation.Rotation = rot;
+
+ this.lastMousePosition = currentMousePosition;
+ e.Handled = true;
+ }
}
- private void Viewport_MouseMove(object sender, MouseEventArgs e)
+ private void OnViewportMouseUp(object sender, MouseButtonEventArgs e)
{
+ if (e.ChangedButton == MouseButton.Middle)
+ {
+ this.interactionMode = CameraInteractionMode.None;
+ this.Viewport.ReleaseMouseCapture();
+ e.Handled = true;
+ }
}
- private void Viewport_MouseWheel(object sender, MouseWheelEventArgs e)
+ private void OnViewportMouseWheel(object sender, MouseWheelEventArgs e)
{
- this.CameraDistance -= e.Delta / 120;
+ double zoomSpeedFactor = 0.2;
+ double effectiveZoomSpeed = SettingsService.Current.ViewportZoomSpeed * zoomSpeedFactor;
+
+ this.CameraDistance -= e.Delta / 120 * effectiveZoomSpeed;
this.CameraDistance = Math.Clamp(this.CameraDistance, 0, 300);
+
+ if (this.Visual != null)
+ {
+ foreach (BoneVisual3D bone in this.Visual.Children.OfType())
+ {
+ bone.OnCameraUpdated(this);
+ }
+ }
+
+ e.Handled = true;
}
private async Task UpdateCamera()
@@ -175,7 +291,8 @@ private async Task UpdateCamera()
while (this.IsLoaded)
{
- if (!this.IsVisible)
+ // If we're not in GPose or the view is not visible, skip the update
+ if (!this.IsVisible || !GposeService.Instance.IsGpose)
{
await Task.Delay(100, token);
continue;
@@ -186,27 +303,21 @@ private async Task UpdateCamera()
try
{
- // If we're not in GPose, skip the update
- if (!GposeService.GetIsGPose())
+ // Validate that all objects are valid and we're in GPose
+ if (!GposeService.Instance.IsGpose || this.Skeleton == null || this.Skeleton.Actor == null || CameraService.Instance.Camera == null)
continue;
- if (this.Skeleton == null || this.Skeleton.Actor == null || CameraService.Instance.Camera == null)
- continue;
+ // Update visual skeleton
+ this.Visual?.Update();
- // Update skeleton transforms until pose service is enabled
- if (!PoseService.Instance.IsEnabled)
+ // Apply camera rotation
+ if (this.SyncWithGameCamera)
{
- this.Skeleton.ReadTransforms();
+ QuaternionRotation3D rot = (QuaternionRotation3D)this.CameraRotation.Rotation;
+ rot.Quaternion = CameraService.Instance.Camera.Rotation3d.ToMedia3DQuaternion();
+ this.CameraRotation.Rotation = rot;
}
- // TODO: allow the user to rotate camera with the mouse instead
- this.CameraRotation = CameraService.Instance.Camera.Rotation3d.ToMedia3DQuaternion();
-
- // Apply camera rotation
- QuaternionRotation3D rot = (QuaternionRotation3D)this.CameraRotaion.Rotation;
- rot.Quaternion = this.CameraRotation;
- this.CameraRotaion.Rotation = rot;
-
// Apply camera position
Point3D pos = this.Camera.Position;
pos.Z = -this.CameraDistance;
diff --git a/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml b/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml
index 2b211da09..f16266f3f 100644
--- a/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml
+++ b/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml
@@ -7,7 +7,9 @@
xmlns:XivToolsWpf.Converters="clr-namespace:XivToolsWpf.Converters;assembly=XivToolsWpf"
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
mc:Ignorable="d"
- d:DesignHeight="512" d:DesignWidth="1024">
+ DataContextChanged="OnDataContextChanged"
+ d:DesignHeight="512"
+ d:DesignWidth="1024">
diff --git a/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml.cs b/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml.cs
index a1cbc8902..4277991e9 100644
--- a/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml.cs
+++ b/Anamnesis/Actor/Posing/Views/PoseBodyGUIView.xaml.cs
@@ -3,7 +3,10 @@
namespace Anamnesis.Actor.Views;
+using Anamnesis.Actor.Posing;
+using System.Windows;
using System.Windows.Controls;
+using System.Windows.Threading;
///
/// Interaction logic for PoseBodyGuiView.xaml.
@@ -14,4 +17,9 @@ public PoseBodyGuiView()
{
this.InitializeComponent();
}
+
+ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => BoneViewManager.Instance.Refresh());
+ }
}
diff --git a/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml b/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml
index b4b7ee944..5800f3bee 100644
--- a/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml
+++ b/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml
@@ -7,7 +7,9 @@
xmlns:XivToolsWpf.Converters="clr-namespace:XivToolsWpf.Converters;assembly=XivToolsWpf"
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
mc:Ignorable="d"
- d:DesignHeight="512" d:DesignWidth="1024">
+ Unloaded="OnUnloaded"
+ d:DesignHeight="512"
+ d:DesignWidth="1024">
diff --git a/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml.cs b/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml.cs
index d0c9d7925..ea014259f 100644
--- a/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml.cs
+++ b/Anamnesis/Actor/Posing/Views/PoseFaceGUIView.xaml.cs
@@ -3,6 +3,7 @@
namespace Anamnesis.Actor.Views;
+using Anamnesis.Actor.Posing;
using Serilog;
using System;
using System.Collections.Generic;
@@ -12,6 +13,7 @@ namespace Anamnesis.Actor.Views;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
+using System.Windows.Threading;
///
/// Interaction logic for PoseFaceGuiView.xaml.
@@ -30,14 +32,16 @@ public partial class PoseFaceGuiView : UserControl
private readonly Border? mouthSelectorBorder;
private readonly Line? mouthGuideLine;
private readonly Ellipse? mouthGuideEllipse;
+ private readonly FaceTemplateSelector? templateSelector;
public PoseFaceGuiView()
{
this.InitializeComponent();
- if (this.Resources["FaceTemplateSelector"] is FaceTemplateSelector templateSelector)
+ if (this.Resources["FaceTemplateSelector"] is FaceTemplateSelector selector)
{
- templateSelector.TemplateChanged += this.OnTemplateChanged;
+ this.templateSelector = selector;
+ this.templateSelector.TemplateChanged += this.OnTemplateChanged;
}
// Load components.
@@ -51,6 +55,14 @@ public PoseFaceGuiView()
Debug.Assert(this.mouthSelectorBorder != null, "Failed to find MouthSelectorBorder.");
}
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (this.templateSelector != null)
+ {
+ this.templateSelector.TemplateChanged -= this.OnTemplateChanged;
+ }
+ }
+
private void OnTemplateChanged(object? sender, string templateName)
{
if (this.mouthGuideLine == null || this.mouthGuideEllipse == null)
@@ -116,13 +128,9 @@ public class FaceTemplateSelector : DataTemplateSelector
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
{
- var skeleton = item as SkeletonVisual3d;
-
- if (skeleton is null)
+ if (item is not SkeletonEntity skeleton)
return null;
- this.OnContentTemplateChanged(skeleton);
-
string templateName = "StandardFaceTemplate";
if (skeleton.IsHrothgar)
{
@@ -142,6 +150,8 @@ public class FaceTemplateSelector : DataTemplateSelector
this.TemplateChanged?.Invoke(this, templateName);
+ Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => BoneViewManager.Instance.Refresh());
+
return templateName switch
{
"HrothgarFaceTemplate" => this.HrothgarFaceTemplate,
@@ -151,9 +161,4 @@ public class FaceTemplateSelector : DataTemplateSelector
_ => this.StandardFaceTemplate,
};
}
-
- private void OnContentTemplateChanged(SkeletonVisual3d skeleton)
- {
- skeleton.NotifySkeletonChanged();
- }
}
diff --git a/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml b/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml
index 02a21a349..886a61f69 100644
--- a/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml
+++ b/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml
@@ -8,8 +8,10 @@
xmlns:local="clr-namespace:Anamnesis.Actor.Views"
xmlns:XivToolsWpf.Converters="clr-namespace:XivToolsWpf.Converters;assembly=XivToolsWpf"
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
- mc:Ignorable="d"
- d:DesignHeight="450" d:DesignWidth="800">
+ mc:Ignorable="d"
+ Unloaded="OnUnloaded"
+ d:DesignHeight="450"
+ d:DesignWidth="800">
@@ -477,7 +479,7 @@
+ ItemsSource="{Binding HairBones, RelativeSource={RelativeSource AncestorType={x:Type local:PoseMatrixView}}}">
@@ -1053,7 +1055,7 @@
-
+
@@ -1066,7 +1068,7 @@
+ ItemsSource="{Binding MetBones, RelativeSource={RelativeSource AncestorType={x:Type local:PoseMatrixView}}}">
@@ -1081,14 +1083,14 @@
-
+
-
+
@@ -1118,7 +1120,7 @@
-
+
@@ -1131,7 +1133,7 @@
+ ItemsSource="{Binding MainHandBones, RelativeSource={RelativeSource AncestorType={x:Type local:PoseMatrixView}}}">
@@ -1146,14 +1148,14 @@
-
+
-
+
diff --git a/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml.cs b/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml.cs
index 87809f0e7..4847a05b0 100644
--- a/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml.cs
+++ b/Anamnesis/Actor/Posing/Views/PoseMatrixView.xaml.cs
@@ -3,7 +3,10 @@
namespace Anamnesis.Actor.Views;
+using Anamnesis.Actor.Posing;
+using PropertyChanged;
using System.Collections.Generic;
+using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -11,28 +14,84 @@ namespace Anamnesis.Actor.Views;
///
/// Interaction logic for PoseMatrixPage.xaml.
///
+[AddINotifyPropertyChangedInterface]
public partial class PoseMatrixView : UserControl
{
+ private IEnumerable? hairBonesCache;
+ private IEnumerable? metBonesCache;
+ private IEnumerable? topBonesCache;
+ private IEnumerable? mainHandBonesCache;
+ private IEnumerable? offHandBonesCache;
+
public PoseMatrixView()
{
this.InitializeComponent();
+ this.DataContextChanged += this.OnDataContextChanged;
+ }
+
+ public SkeletonEntity? Skeleton { get; private set; }
+
+ [DependsOn(nameof(this.Skeleton))]
+ public IEnumerable HairBones => this.hairBonesCache ??= this.GetBonesByCategory(BoneCategory.Hair);
+
+ [DependsOn(nameof(this.Skeleton))]
+ public IEnumerable MetBones => this.metBonesCache ??= this.GetBonesByCategory(BoneCategory.Met);
+
+ [DependsOn(nameof(this.Skeleton))]
+ public IEnumerable TopBones => this.topBonesCache ??= this.GetBonesByCategory(BoneCategory.Top);
+
+ [DependsOn(nameof(this.Skeleton))]
+ public IEnumerable MainHandBones => this.mainHandBonesCache ??= this.GetBonesByCategory(BoneCategory.MainHand);
+
+ [DependsOn(nameof(this.Skeleton))]
+ public IEnumerable OffHandBones => this.offHandBonesCache ??= this.GetBonesByCategory(BoneCategory.OffHand);
+
+ public void OnDataContextChanged(object? sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (this.DataContext is not SkeletonEntity skeleton)
+ return;
+
+ this.InvalidateCaches();
+ this.Skeleton = skeleton;
}
- private void OnGroupClicked(object sender, System.Windows.RoutedEventArgs e)
+ private void OnGroupClicked(object sender, RoutedEventArgs e)
{
if (sender is DependencyObject ob)
{
GroupBox? groupBox = ob.FindParent();
- if (groupBox == null)
+ if (groupBox == null || this.Skeleton == null)
return;
- SkeletonVisual3d skeleton = (SkeletonVisual3d)this.DataContext;
-
if (!Keyboard.IsKeyDown(Key.LeftCtrl))
- skeleton.SelectedBones.Clear();
+ this.Skeleton.ClearSelection();
- List bones = groupBox.FindChildren();
- skeleton.Select(bones);
+ List bones = groupBox.FindChildren()
+ .Where(b => b.Bone != null)
+ .Select(b => b.Bone!)
+ .ToList();
+ this.Skeleton.Select(bones);
}
}
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ this.DataContextChanged -= this.OnDataContextChanged;
+ }
+
+ private IEnumerable GetBonesByCategory(BoneCategory category)
+ {
+ return this.Skeleton != null
+ ? SkeletonEntity.TraverseSkeleton(this.Skeleton).Where(b => b.Category == category)
+ : Enumerable.Empty();
+ }
+
+ private void InvalidateCaches()
+ {
+ this.hairBonesCache = null;
+ this.metBonesCache = null;
+ this.topBonesCache = null;
+ this.mainHandBonesCache = null;
+ this.offHandBonesCache = null;
+ }
}
diff --git a/Anamnesis/Actor/Posing/Visuals/BoneTargetVisual3d.cs b/Anamnesis/Actor/Posing/Visuals/BoneTargetVisual3d.cs
deleted file mode 100644
index 430825540..000000000
--- a/Anamnesis/Actor/Posing/Visuals/BoneTargetVisual3d.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-// © Anamnesis.
-// Licensed under the MIT license.
-
-namespace Anamnesis.Posing.Visuals;
-
-using Anamnesis.Actor;
-using Anamnesis.Actor.Views;
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Media3D;
-using XivToolsWpf.Math3D;
-
-public class BoneTargetVisual3d : ModelVisual3D, IDisposable
-{
- private readonly PrsTransform transform = new PrsTransform();
- private readonly PrsTransform sphereTransform = new PrsTransform();
- private readonly BoneVisual3d bone;
-
- private readonly Sphere sphere;
- private readonly Material selected;
- private readonly Material hovered;
- private readonly Material normal;
-
- public BoneTargetVisual3d(BoneVisual3d bone)
- {
- bone.Skeleton.PropertyChanged += this.Skeleton_PropertyChanged;
-
- this.bone = bone;
- this.Transform = this.transform.Transform;
-
- Color c = Colors.White;
- c.A = 128;
- this.normal = new DiffuseMaterial(new SolidColorBrush(c));
- this.hovered = new DiffuseMaterial(new SolidColorBrush(Colors.DarkOrange));
- this.selected = new DiffuseMaterial(new SolidColorBrush(Colors.Orange));
-
- this.sphere = new Sphere();
- this.sphere.Radius = 0.015;
- this.sphere.Material = this.normal;
- this.sphere.Transform = this.sphereTransform.Transform;
- this.Children.Add(this.sphere);
- }
-
- public void Dispose()
- {
- this.bone.Skeleton.PropertyChanged -= this.Skeleton_PropertyChanged;
- this.Children.Clear();
-
- this.sphere.Children.Clear();
- this.sphere.Content = null;
- }
-
- public virtual void OnCameraUpdated(Pose3DView owner)
- {
- double scale = (owner.CameraDistance * 0.015) - 0.02;
- scale = Math.Clamp(scale, 0.02, 10);
- this.sphereTransform.UniformScale = scale;
- }
-
- private void Skeleton_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
- {
- if (this.bone.Skeleton.GetIsBoneHovered(this.bone))
- {
- this.sphere.Material = this.hovered;
- }
- else if (this.bone.Skeleton.GetIsBoneSelected(this.bone))
- {
- this.sphere.Material = this.selected;
- }
- else
- {
- this.sphere.Material = this.normal;
- }
- }
-}
diff --git a/Anamnesis/Actor/Posing/Visuals/BoneVisual3d.cs b/Anamnesis/Actor/Posing/Visuals/BoneVisual3d.cs
index fa4cb2502..02df00331 100644
--- a/Anamnesis/Actor/Posing/Visuals/BoneVisual3d.cs
+++ b/Anamnesis/Actor/Posing/Visuals/BoneVisual3d.cs
@@ -1,163 +1,90 @@
// © Anamnesis.
// Licensed under the MIT license.
-namespace Anamnesis.Actor;
+namespace Anamnesis.Actor.Posing.Visuals;
-using Anamnesis.Actor.Extensions;
-using Anamnesis.Memory;
-using Anamnesis.Posing.Visuals;
-using Anamnesis.Services;
+using Anamnesis.Actor.Views;
using MaterialDesignThemes.Wpf;
-using PropertyChanged;
using System;
-using System.Collections.Generic;
+using System.Linq;
using System.Numerics;
+using System.Windows.Media;
using System.Windows.Media.Media3D;
using XivToolsWpf.Math3D;
using XivToolsWpf.Math3D.Extensions;
-using CmQuaternion = System.Numerics.Quaternion;
-using CmVector = System.Numerics.Vector3;
-using Quaternion = System.Windows.Media.Media3D.Quaternion;
-[AddINotifyPropertyChangedInterface]
-public class BoneVisual3d : ModelVisual3D, ITransform, IBone, IDisposable
+///
+/// Represents a 3D visual representation of a bone.
+/// The bone is part of a .
+///
+public class BoneVisual3D : ModelVisual3D, IDisposable
{
- public readonly List TransformMemories = new();
-
- private const float EqualityTolerance = 0.00001f;
- private static bool scaleLinked = true;
-
- private readonly object transformLock = new();
- private readonly QuaternionRotation3D rotation;
- private readonly TranslateTransform3D position;
- private BoneTargetVisual3d? targetVisual;
- private BoneVisual3d? parent;
+ protected const float EqualityTolerance = 0.00001f;
+ private static readonly double SphereRadius = 0.015;
+ private static readonly Material SelectedMaterial = CreateMaterial(Colors.Orange);
+ private static readonly Material HoveredMaterial = CreateMaterial(Colors.DarkOrange);
+ private static readonly Material NormalMaterial = CreateMaterial(Colors.White, 128);
+ private static readonly Sphere PrecomputedSphere = CreatePrecomputedSphere();
+ private readonly PrsTransform sphereTransform = new();
+
+ private readonly Sphere sphere;
+ private BoneVisual3D? parent;
private Line? lineToParent;
-
- public BoneVisual3d(SkeletonVisual3d skeleton, string name)
+ private Vector3? lastPosition;
+ private System.Numerics.Quaternion? lastRotation;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skeleton visual to which this bone belongs.
+ /// The bone entity to visualize.
+ public BoneVisual3D(SkeletonVisual3D skeleton, BoneEntity bone)
{
this.Skeleton = skeleton;
+ this.Bone = bone;
- this.rotation = new QuaternionRotation3D();
+ this.Rotation = new QuaternionRotation3D(bone.Rotation.ToMedia3DQuaternion());
- RotateTransform3D rot = new() { Rotation = this.rotation };
- this.position = new TranslateTransform3D();
+ RotateTransform3D rot = new() { Rotation = this.Rotation };
+ this.Position = new TranslateTransform3D(bone.Position.ToMedia3DVector());
Transform3DGroup transformGroup = new();
transformGroup.Children.Add(rot);
- transformGroup.Children.Add(this.position);
+ transformGroup.Children.Add(this.Position);
this.Transform = transformGroup;
PaletteHelper ph = new();
ITheme t = ph.GetTheme();
- this.targetVisual = new BoneTargetVisual3d(this);
- this.Children.Add(this.targetVisual);
-
- this.BoneName = name;
- }
+ this.Initialize(bone);
- public SkeletonVisual3d Skeleton { get; private set; }
- public TransformMemory TransformMemory => this.TransformMemories[0];
-
- public bool IsEnabled { get; set; } = true;
- public string BoneName { get; set; }
- public List LinkedBones { get; set; } = new();
- public int LinkedBonesCount => this.LinkedBones.Count;
- public virtual string TooltipKey => "Pose_" + this.BoneName;
- public bool IsTransformLocked { get; set; } = false;
-
- public bool CanRotate => PoseService.Instance.FreezeRotation && !this.IsTransformLocked;
- public CmQuaternion Rotation { get; set; }
- public bool CanScale => PoseService.Instance.FreezeScale && !this.IsTransformLocked;
- public CmVector Scale { get; set; }
- public bool CanTranslate => PoseService.Instance.FreezePositions && !this.IsTransformLocked;
- public CmVector Position { get; set; }
-
- public bool IsAttachmentBone
- {
- get
+ this.sphere = new Sphere(PrecomputedSphere)
{
- return this.BoneName == "n_buki_r" ||
- this.BoneName == "n_buki_l" ||
- this.BoneName == "j_buki_sebo_r" ||
- this.BoneName == "j_buki_sebo_l";
- }
- }
-
- public bool CanLinkScale => !this.IsAttachmentBone;
-
- public bool ScaleLinked
- {
- get
- {
- if (this.IsAttachmentBone)
- return true;
-
- return scaleLinked;
- }
+ Transform = this.sphereTransform.Transform,
+ };
- set => scaleLinked = value;
+ this.Children.Add(this.sphere);
}
- public bool EnableLinkedBones
- {
- get
- {
- if (this.LinkedBonesCount <= 0)
- return false;
+ /// Gets the rotation of the bone.
+ /// The rotation is parent-relative.
+ public QuaternionRotation3D Rotation { get; private set; }
- return SettingsService.Current.PosingBoneLinks.Get(this.BoneName, true);
- }
+ /// Gets the position of the bone.
+ /// The position is parent-relative.
+ public TranslateTransform3D Position { get; private set; }
- set
- {
- SettingsService.Current.PosingBoneLinks.Set(this.BoneName, value);
+ /// Gets the skeleton visual to which this bone belongs.
+ public SkeletonVisual3D Skeleton { get; private set; }
- foreach (BoneVisual3d link in this.LinkedBones)
- {
- SettingsService.Current.PosingBoneLinks.Set(link.BoneName, value);
- }
- }
- }
+ /// Gets the bone entity being visualized.
+ public BoneEntity Bone { get; private set; }
- public string Tooltip
+ /// Gets or sets the parent bone visual.
+ public BoneVisual3D? Parent
{
- get
- {
- string? customName = CustomBoneNameService.GetBoneName(this.BoneName);
-
- if (!string.IsNullOrEmpty(customName))
- return customName;
-
- string str = LocalizationService.GetString(this.TooltipKey, true);
-
- if (string.IsNullOrEmpty(str))
- return this.BoneName;
-
- return str;
- }
-
- set
- {
- if (string.IsNullOrEmpty(value) || LocalizationService.GetString(this.TooltipKey, true) == value)
- {
- CustomBoneNameService.SetBoneName(this.BoneName, null);
- }
- else
- {
- CustomBoneNameService.SetBoneName(this.BoneName, value);
- }
- }
- }
-
- public BoneVisual3d? Parent
- {
- get
- {
- return this.parent;
- }
+ get => this.parent;
set
{
@@ -167,25 +94,19 @@ public BoneVisual3d? Parent
this.parent.Children.Remove(this.lineToParent);
}
- if (this.Skeleton.Children.Contains(this))
- this.Skeleton.Children.Remove(this);
-
this.parent = value;
if (this.parent != null)
{
- if (this.lineToParent == null)
+ this.lineToParent ??= new Line
{
- this.lineToParent = new Line();
- System.Windows.Media.Color c = default;
- c.R = 128;
- c.G = 128;
- c.B = 128;
- c.A = 255;
- this.lineToParent.Color = c;
- this.lineToParent.Points.Add(new Point3D(0, 0, 0));
- this.lineToParent.Points.Add(new Point3D(0, 0, 0));
- }
+ Color = Color.FromArgb(255, 128, 128, 128),
+ Points = new Point3DCollection
+ {
+ new Point3D(0, 0, 0),
+ new Point3D(0, 0, 0),
+ },
+ };
this.parent.Children.Add(this);
this.parent.Children.Add(this.lineToParent);
@@ -193,263 +114,167 @@ public BoneVisual3d? Parent
}
}
- public CmQuaternion RootRotation
+ /// Disposes the bone visual and its children.
+ public void Dispose()
{
- get
- {
- CmQuaternion rot = this.Skeleton.RootRotation;
-
- if (this.Parent == null)
- return rot;
-
- return rot * this.Parent.TransformMemory.Rotation;
- }
+ this.Dispose(true);
+ GC.SuppressFinalize(this);
}
- public BoneVisual3d? Visual => this;
-
- public void Dispose()
+ /// Updates the visual representation of the bone.
+ ///
+ /// Use this method to update the bone's position and rotation in the visual tree.
+ ///
+ public void Update()
{
- this.Children.Clear();
- this.targetVisual?.Dispose();
- this.targetVisual = null;
+ if (this.Bone == null)
+ return;
- this.parent?.Children.Remove(this);
+ var currentPosition = this.Bone.Position;
+ var currentRotation = this.Bone.Rotation;
+ bool positionChanged = !(this.lastPosition?.IsApproximately(currentPosition, EqualityTolerance) ?? false);
+ bool rotationChanged = !(this.lastRotation?.IsApproximately(currentRotation, EqualityTolerance) ?? false);
- if (this.lineToParent != null)
+ if (positionChanged)
{
- this.parent?.Children.Remove(this.lineToParent);
- this.lineToParent.Dispose();
- this.lineToParent = null;
+ this.lastPosition = currentPosition;
+ this.Position.OffsetX = currentPosition.X;
+ this.Position.OffsetY = currentPosition.Y;
+ this.Position.OffsetZ = currentPosition.Z;
}
- this.parent = null;
- }
+ if (rotationChanged)
+ {
+ this.lastRotation = currentRotation;
+ this.Rotation.Quaternion = currentRotation.ToMedia3DQuaternion();
+ }
- public virtual void Synchronize()
- {
- foreach (TransformMemory transformMemory in this.TransformMemories)
- transformMemory.Synchronize();
+ if (positionChanged || rotationChanged)
+ {
+ // Redraw the line connecting this bone to its parent
+ this.RedrawLineToParent();
+ }
- this.ReadTransform();
+ // Handle child bones
+ foreach (BoneVisual3D bone in this.Children.OfType())
+ {
+ bone.Update();
+ }
}
- public virtual void ReadTransform(bool readChildren = false, Dictionary? snapshot = null)
+ public virtual void OnCameraUpdated(Pose3DView owner)
{
- if (!this.IsEnabled)
- return;
+ double scale = Math.Clamp(owner.CameraDistance * 0.5, 0.2, 1);
+ this.sphereTransform.UniformScale = scale;
+ this.sphere.Transform = this.sphereTransform.Transform;
- lock (this.transformLock)
+ // Handle child bones
+ foreach (BoneVisual3D bone in this.Children.OfType())
{
- Transform newTransform;
-
- // Use the snapshot if provided
- if (snapshot != null && snapshot.TryGetValue(this.BoneName, out var transform))
- {
- newTransform = transform;
- }
- else
- {
- newTransform = new Transform
- {
- Position = this.TransformMemory.Position,
- Rotation = this.TransformMemory.Rotation,
- Scale = this.TransformMemory.Scale,
- };
- }
-
- // Convert the character-relative transform into a parent-relative transform
- if (this.Parent != null)
- {
- Transform parentTransform;
- if (snapshot != null && snapshot.TryGetValue(this.Parent.BoneName, out var parentSnapshot))
- {
- parentTransform = parentSnapshot;
- }
- else
- {
- parentTransform = new Transform
- {
- Position = this.Parent.TransformMemory.Position,
- Rotation = this.Parent.TransformMemory.Rotation,
- Scale = this.Parent.TransformMemory.Scale,
- };
- }
-
- CmVector parentPosition = parentTransform.Position;
- CmQuaternion parentRot = CmQuaternion.Normalize(parentTransform.Rotation);
- parentRot = CmQuaternion.Inverse(parentRot);
-
- // Relative position
- newTransform.Position -= parentPosition;
-
- // Unrotate bones, since we will transform them ourselves.
- Matrix4x4 rotMatrix = Matrix4x4.CreateFromQuaternion(parentRot);
- newTransform.Position = CmVector.Transform(newTransform.Position, rotMatrix);
-
- // Relative rotation
- newTransform.Rotation = CmQuaternion.Normalize(CmQuaternion.Multiply(parentRot, newTransform.Rotation));
- }
-
- // Apply the updates in a single step
- this.Position = newTransform.Position;
- this.Rotation = newTransform.Rotation;
- this.Scale = newTransform.Scale;
-
- // Set the Media3D hierarchy transforms
- this.rotation.Quaternion = newTransform.Rotation.ToMedia3DQuaternion();
- this.position.OffsetX = newTransform.Position.X;
- this.position.OffsetY = newTransform.Position.Y;
- this.position.OffsetZ = newTransform.Position.Z;
-
- // Draw a line for visualization
- if (this.Parent != null && this.lineToParent != null)
- {
- var endPoint = this.lineToParent.Points[1];
- endPoint.X = newTransform.Position.X;
- endPoint.Y = newTransform.Position.Y;
- endPoint.Z = newTransform.Position.Z;
- this.lineToParent.Points[1] = endPoint;
- }
-
- if (readChildren)
- {
- foreach (Visual3D child in this.Children)
- {
- if (child is BoneVisual3d childBone)
- {
- childBone.ReadTransform(true, snapshot);
- }
- }
- }
+ bone.OnCameraUpdated(owner);
}
}
- public virtual void WriteTransform(ModelVisual3D root, bool writeChildren = true, bool writeLinked = true)
+ ///
+ /// Updates the material of the bone based on its selection and hover state.
+ ///
+ internal void UpdateMaterial()
{
- if (!this.IsEnabled)
- return;
-
- lock (this.transformLock)
+ if (this.Bone.IsHovered)
+ this.sphere.Material = HoveredMaterial;
+ else if (this.Bone.IsSelected)
+ this.sphere.Material = SelectedMaterial;
+ else
+ this.sphere.Material = NormalMaterial;
+
+ // Handle child bones
+ foreach (BoneVisual3D bone in this.Children.OfType())
{
- foreach (TransformMemory transformMemory in this.TransformMemories)
- {
- transformMemory.EnableReading = false;
- }
-
- // Apply the current values to the visual tree
- this.rotation.Quaternion = this.Rotation.ToMedia3DQuaternion();
- this.position.OffsetX = this.Position.X;
- this.position.OffsetY = this.Position.Y;
- this.position.OffsetZ = this.Position.Z;
-
- // convert the values in the tree to character-relative space
- MatrixTransform3D transform;
+ bone.UpdateMaterial();
+ }
+ }
- try
- {
- transform = (MatrixTransform3D)this.TransformToAncestor(root);
- }
- catch (Exception ex)
+ ///
+ /// Disposes the bone visual and its children.
+ ///
+ /// True if the object is being disposed; otherwise, false.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ foreach (BoneVisual3D child in this.Children.OfType().ToList())
{
- throw new Exception($"Failed to transform bone: {this.BoneName} to root", ex);
+ child.Dispose();
}
- var matrix = transform.Matrix;
- Quaternion rotation = matrix.ToQuaternion();
- rotation.Invert();
+ this.Children.Clear();
- var position = new CmVector((float)matrix.OffsetX, (float)matrix.OffsetY, (float)matrix.OffsetZ);
+ this.sphere?.Dispose();
- // and push those values to the game memory
- bool changed = false;
- foreach (TransformMemory transformMemory in this.TransformMemories)
+ if (this.lineToParent != null)
{
- if (this.CanTranslate && !transformMemory.Position.IsApproximately(position, EqualityTolerance))
- {
- transformMemory.Position = position;
- changed = true;
- }
-
- if (this.CanScale && !transformMemory.Scale.IsApproximately(this.Scale, EqualityTolerance))
- {
- transformMemory.Scale = this.Scale;
- changed = true;
- }
-
- if (this.CanRotate)
- {
- CmQuaternion newRot = rotation.FromMedia3DQuaternion();
- if (!transformMemory.Rotation.IsApproximately(newRot, EqualityTolerance))
- {
- transformMemory.Rotation = newRot;
- changed = true;
- }
- }
+ this.parent?.Children.Remove(this.lineToParent);
+ this.lineToParent.Dispose();
+ this.lineToParent = null;
}
- if (changed)
- {
- if (writeLinked && this.EnableLinkedBones)
- {
- foreach (BoneVisual3d link in this.LinkedBones)
- {
- link.Rotation = this.Rotation;
- link.WriteTransform(root, writeChildren, false);
- }
- }
-
- if (writeChildren)
- {
- foreach (Visual3D child in this.Children)
- {
- if (child is BoneVisual3d childBone)
- {
- if (PoseService.Instance.EnableParenting)
- {
- childBone.WriteTransform(root);
- }
- else
- {
- childBone.ReadTransform(true);
- }
- }
- }
- }
- }
-
- foreach (TransformMemory transformMemory in this.TransformMemories)
- {
- transformMemory.EnableReading = true;
- }
+ this.parent?.Children.Remove(this);
+ this.parent = null;
}
}
- public void GetChildren(ref List bones)
+ ///
+ /// Creates a precomputed sphere instance.
+ ///
+ /// The precomputed sphere.
+ private static Sphere CreatePrecomputedSphere()
{
- foreach (Visual3D? child in this.Children)
+ var sphere = new Sphere
{
- if (child is BoneVisual3d childBoneVisual)
- {
- bones.Add(childBoneVisual);
- childBoneVisual.GetChildren(ref bones);
- }
- }
+ Radius = SphereRadius,
+ Material = NormalMaterial,
+ };
+
+ return sphere;
}
- public bool HasParent(BoneVisual3d target)
+ ///
+ /// Creates a material with the specified color and alpha value.
+ ///
+ /// The color of the material.
+ /// The alpha value of the material.
+ /// The created material.
+ private static Material CreateMaterial(Color color, byte alpha = 255)
{
- if (this.parent == null)
- return false;
+ color.A = alpha;
+ return new DiffuseMaterial(new SolidColorBrush(color));
+ }
- if (this.parent == target)
- return true;
+ /// Initializes the visual representation of the bone.
+ /// The bone entity to initialize.
+ private void Initialize(BoneEntity bone)
+ {
+ this.Children.Clear();
- return this.parent.HasParent(target);
+ foreach (var childBone in bone.Children.OfType())
+ {
+ var childVisual = new BoneVisual3D(this.Skeleton, childBone)
+ {
+ Parent = this,
+ };
+ }
}
- public override string ToString()
+ /// Redraws the line connecting this bone to its parent.
+ private void RedrawLineToParent()
{
- return base.ToString() + "(" + this.BoneName + ")";
+ if (this.Parent == null || this.lineToParent == null)
+ return;
+
+ var endPoint = this.lineToParent.Points[1];
+ endPoint.X = this.Position.OffsetX;
+ endPoint.Y = this.Position.OffsetY;
+ endPoint.Z = this.Position.OffsetZ;
+ this.lineToParent.Points[1] = endPoint;
}
}
diff --git a/Anamnesis/Actor/Posing/Visuals/SkeletonVisual3d.cs b/Anamnesis/Actor/Posing/Visuals/SkeletonVisual3d.cs
index c6f515263..452c15846 100644
--- a/Anamnesis/Actor/Posing/Visuals/SkeletonVisual3d.cs
+++ b/Anamnesis/Actor/Posing/Visuals/SkeletonVisual3d.cs
@@ -1,838 +1,160 @@
// © Anamnesis.
// Licensed under the MIT license.
-namespace Anamnesis.Actor;
+namespace Anamnesis.Actor.Posing.Visuals;
-using Anamnesis.Actor.Posing;
+using Anamnesis.Actor.Views;
using Anamnesis.Memory;
-using Anamnesis.Posing;
-using Anamnesis.Services;
-using PropertyChanged;
-using Serilog;
using System;
-using System.Collections.Generic;
using System.ComponentModel;
-using System.IO.Enumeration;
using System.Linq;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Input;
using System.Windows.Media.Media3D;
using XivToolsWpf;
using XivToolsWpf.Math3D.Extensions;
-using AnQuaternion = System.Numerics.Quaternion;
-[AddINotifyPropertyChangedInterface]
-public class SkeletonVisual3d : ModelVisual3D, INotifyPropertyChanged
+///
+/// Represents a 3D visual representation of a skeleton.
+/// The visual skeleton is comprised of a collection of objects.
+///
+public class SkeletonVisual3D : ModelVisual3D, IDisposable
{
- public readonly Dictionary Bones = new();
- public readonly List SelectedBones = new();
- public readonly HashSet HoverBones = new();
+ /// The root rotation of the skeleton.
+ private readonly RotateTransform3D rotateTransform;
+ private bool disposed = false;
- private readonly QuaternionRotation3D rootRotation;
- private readonly List rootBones = new();
- private readonly List hairBones = new();
- private readonly List metBones = new();
- private readonly List topBones = new();
- private readonly List mainHandBones = new();
- private readonly List offHandBones = new();
-
- private readonly Dictionary> hairNameToSuffixMap = new()
- {
- { "HairAutoFrontLeft", new("l", "j_kami_f_l") }, // Hair, Front Left
- { "HairAutoFrontRight", new("r", "j_kami_f_r") }, // Hair, Front Right
- { "HairAutoA", new("a", "j_kami_a") }, // Hair, Back Up
- { "HairAutoB", new("b", "j_kami_b") }, // Hair, Back Down
- { "HairFront", new("f", string.Empty) }, // Hair, Front (Custom Bone Name)
- };
-
- public SkeletonVisual3d()
- {
- this.rootRotation = new QuaternionRotation3D();
- this.Transform = new RotateTransform3D(this.rootRotation);
- this.OnTransformPropertyChanged(null, null);
- }
-
- public event PropertyChangedEventHandler? PropertyChanged;
-
- public enum SelectMode
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skeleton entity to visualize.
+ public SkeletonVisual3D(SkeletonEntity skeleton)
{
- Override,
- Add,
- Toggle,
- }
-
- public ActorMemory? Actor { get; private set; }
- public int SelectedCount => this.SelectedBones.Count;
- public bool CanEditBone => this.SelectedBones.Count == 1;
- public bool HasSelection => this.SelectedBones.Count > 0;
- public bool HasHover => this.HoverBones.Count > 0;
- public bool HasEquipmentBones => this.metBones.Count > 0 || this.topBones.Count > 0;
- public bool HasWeaponBones => this.mainHandBones.Count > 0 || this.offHandBones.Count > 0;
-
- public IEnumerable AllBones => this.Bones.Values;
- public IEnumerable HairBones => this.hairBones;
- public IEnumerable MetBones => this.metBones;
- public IEnumerable TopBones => this.topBones;
- public IEnumerable MainHandBones => this.mainHandBones;
- public IEnumerable OffHandBones => this.offHandBones;
+ this.Skeleton = skeleton;
+ this.RootRotation = new QuaternionRotation3D(skeleton.RootRotation.ToMedia3DQuaternion());
+ this.rotateTransform = new RotateTransform3D(this.RootRotation);
+ this.Transform = this.rotateTransform;
- public string BoneSearch { get; set; } = string.Empty;
-
- public IEnumerable BoneSearchResult => string.IsNullOrWhiteSpace(this.BoneSearch) ? this.AllBones : this.AllBones.Where(b => FileSystemName.MatchesSimpleExpression($"*{this.BoneSearch}*", b.BoneName) || FileSystemName.MatchesSimpleExpression($"*{this.BoneSearch}*", b.Tooltip));
-
- public bool FlipSides
- {
- get => SettingsService.Current.FlipPoseGuiSides;
- set => SettingsService.Current.FlipPoseGuiSides = value;
- }
-
- public BoneVisual3d? CurrentBone
- {
- get
- {
- if (this.SelectedBones.Count <= 0)
- return null;
-
- return this.SelectedBones[this.SelectedBones.Count - 1];
- }
-
- set
- {
- throw new NotSupportedException();
- }
- }
-
- public bool HasTail => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Miqote
- || this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.AuRa
- || this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Hrothgar
- || this.IsIVCS;
-
- public bool IsStandardFace => this.Actor == null ? true : !this.IsMiqote && !this.IsHrothgar && !this.IsViera;
- public bool IsMiqote => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Miqote;
- public bool IsViera => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Viera;
- public bool IsElezen => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Elezen;
- public bool IsHrothgar => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Hrothgar;
- public bool HasTailOrEars => this.IsViera || this.HasTail;
-
- public bool IsEars01 => this.IsViera && this.Actor?.Customize?.TailEarsType <= 1;
- public bool IsEars02 => this.IsViera && this.Actor?.Customize?.TailEarsType == 2;
- public bool IsEars03 => this.IsViera && this.Actor?.Customize?.TailEarsType == 3;
- public bool IsEars04 => this.IsViera && this.Actor?.Customize?.TailEarsType == 4;
-
- public bool IsIVCS { get; private set; }
-
- public bool IsVieraEarsFlop
- {
- get
- {
- if (!this.IsViera)
- return false;
-
- ActorCustomizeMemory? customize = this.Actor?.Customize;
-
- if (customize == null)
- return false;
-
- if (customize.Gender == ActorCustomizeMemory.Genders.Feminine && customize.TailEarsType == 3)
- return true;
-
- if (customize.Gender == ActorCustomizeMemory.Genders.Masculine && customize.TailEarsType == 2)
- return true;
-
- return false;
- }
- }
+ this.Initialize(skeleton);
- public bool HasPreDTFace
- {
- get
+ this.Skeleton.PropertyChanged += this.OnSkeletonPropertyChanged;
+ if (this.Skeleton.Actor.ModelObject?.Transform != null)
{
- // If the skeleton is not initialized, we can't determine if it's a pre-DT face.
- if (this.Bones.Count == 0)
- return false;
-
- // We can determine if we have a DT-updated face if we have a tongue bone.
- // EW faces don't have this bone, where as all updated faces in DT have it.
- // It would be better to enumerate all of the faces and be more specific.
- BoneVisual3d? tongueABone = this.GetBone("j_f_bero_01");
- if (tongueABone == null)
- return true;
- return false;
+ this.Skeleton.Actor.ModelObject.Transform.PropertyChanged += this.OnTransformPropertyChanged;
}
}
- public AnQuaternion RootRotation
- {
- get
- {
- return this.Actor?.ModelObject?.Transform?.Rotation ?? AnQuaternion.Identity;
- }
- }
+ /// Gets the root rotation of the skeleton.
+ public QuaternionRotation3D RootRotation { get; private set; }
- private static ILogger Log => Serilog.Log.ForContext();
+ /// Gets the skeleton entity being visualized.
+ public SkeletonEntity Skeleton { get; private set; }
- public static List SortBonesByHierarchy(IEnumerable bones)
+ ///
+ /// Disposes the resources used by the class.
+ ///
+ public void Dispose()
{
- return bones.OrderBy(bone => GetBoneDepth(bone)).ToList();
+ this.Dispose(true);
+ GC.SuppressFinalize(this);
}
- public static int GetBoneDepth(BoneVisual3d bone)
+ /// Updates the visual representation of the skeleton.
+ ///
+ /// Use this method if you want to update the positions and rotations
+ /// of the bones in the visual tree.
+ ///
+ public void Update()
{
- int depth = 0;
- while (bone.Parent != null)
+ foreach (BoneVisual3D bone in this.Children.OfType())
{
- depth++;
- bone = bone.Parent;
+ bone.Update();
}
-
- return depth;
}
- public void Clear()
+ ///
+ /// Updates the camera view of the skeleton.
+ ///
+ /// The owner of the viewport.
+ public void OnCameraUpdated(Pose3DView owner)
{
- this.ClearSelection();
- this.ClearBones();
- this.Children.Clear();
- }
-
- public void Select(IBone bone)
- {
- if (bone.Visual == null)
- return;
-
- SkeletonVisual3d.SelectMode mode = SkeletonVisual3d.SelectMode.Override;
-
- if (Keyboard.IsKeyDown(Key.LeftCtrl))
- mode = SkeletonVisual3d.SelectMode.Toggle;
-
- if (Keyboard.IsKeyDown(Key.LeftShift))
- mode = SkeletonVisual3d.SelectMode.Add;
-
- if (mode == SelectMode.Override)
- this.SelectedBones.Clear();
-
- if (this.SelectedBones.Contains(bone.Visual))
+ foreach (BoneVisual3D bone in this.Children.OfType())
{
- if (mode == SelectMode.Toggle)
- {
- this.SelectedBones.Remove(bone.Visual);
- }
+ bone.OnCameraUpdated(owner);
}
- else
- {
- this.SelectedBones.Add(bone.Visual);
- }
-
- PoseService.SelectedBoneName = this.CurrentBone?.Tooltip;
-
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CurrentBone));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasSelection));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.SelectedCount));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CanEditBone));
}
- public void Select(IEnumerable bones)
+ ///
+ /// Disposes the resources used by the class.
+ ///
+ /// True if managed resources should be disposed; otherwise, false.
+ protected virtual void Dispose(bool disposing)
{
- SkeletonVisual3d.SelectMode mode = SkeletonVisual3d.SelectMode.Override;
-
- if (Keyboard.IsKeyDown(Key.LeftCtrl))
- mode = SkeletonVisual3d.SelectMode.Toggle;
-
- if (Keyboard.IsKeyDown(Key.LeftShift))
- mode = SkeletonVisual3d.SelectMode.Add;
-
- if (mode == SelectMode.Override)
- {
- this.SelectedBones.Clear();
- mode = SelectMode.Add;
- }
-
- foreach (IBone bone in bones)
+ if (!this.disposed)
{
- if (bone.Visual == null)
- continue;
-
- if (this.SelectedBones.Contains(bone.Visual))
+ if (disposing)
{
- if (mode == SelectMode.Toggle)
+ // Dispose managed resources
+ if (this.Skeleton.Actor.ModelObject?.Transform != null)
{
- this.SelectedBones.Remove(bone.Visual);
+ this.Skeleton.Actor.ModelObject.Transform.PropertyChanged -= this.OnTransformPropertyChanged;
}
- }
- else
- {
- this.SelectedBones.Add(bone.Visual);
- }
- }
- PoseService.SelectedBoneName = this.CurrentBone?.Tooltip;
-
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CurrentBone));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasSelection));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.SelectedCount));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CanEditBone));
- }
-
- public void Select(List bones, SelectMode mode)
- {
- if (mode == SelectMode.Override)
- this.SelectedBones.Clear();
+ this.Skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged;
- foreach (BoneVisual3d bone in bones)
- {
- if (this.SelectedBones.Contains(bone))
- {
- if (mode == SelectMode.Toggle)
+ foreach (var child in this.Children.OfType())
{
- this.SelectedBones.Remove(bone);
+ child.Dispose();
}
- }
- else
- {
- this.SelectedBones.Add(bone);
- }
- }
-
- PoseService.SelectedBoneName = this.CurrentBone?.Tooltip;
-
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CurrentBone));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasSelection));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.SelectedCount));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CanEditBone));
- }
-
- public void ClearSelection()
- {
- if (this.SelectedBones.Count == 0)
- return;
-
- this.SelectedBones.Clear();
-
- Application.Current?.Dispatcher.Invoke(() =>
- {
- PoseService.SelectedBoneName = this.CurrentBone?.Tooltip;
-
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CurrentBone));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasSelection));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.SelectedCount));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CanEditBone));
- });
- }
-
- public void Hover(BoneVisual3d bone, bool hover, bool notify = true)
- {
- if (this.HoverBones.Contains(bone) && !hover)
- {
- this.HoverBones.Remove(bone);
- }
- else if (!this.HoverBones.Contains(bone) && hover)
- {
- this.HoverBones.Add(bone);
- }
- else
- {
- return;
- }
-
- if (notify)
- {
- this.NotifyHover();
- }
- }
-
- public void NotifyHover()
- {
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasHover));
- }
-
- public void NotifySkeletonChanged()
- {
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.AllBones));
- }
-
- public bool GetIsBoneHovered(BoneVisual3d bone)
- {
- return this.HoverBones.Contains(bone);
- }
-
- public bool GetIsBoneSelected(BoneVisual3d bone)
- {
- return this.SelectedBones.Contains(bone);
- }
-
- public bool GetIsBoneParentsSelected(BoneVisual3d? bone)
- {
- while (bone != null)
- {
- if (this.GetIsBoneSelected(bone))
- return true;
-
- bone = bone.Parent;
- }
-
- return false;
- }
-
- public bool GetIsBoneParentsHovered(BoneVisual3d? bone)
- {
- while (bone != null)
- {
- if (this.GetIsBoneHovered(bone))
- return true;
-
- bone = bone.Parent;
- }
-
- return false;
- }
-
- public BoneVisual3d? GetBone(string name)
- {
- if (this.Actor?.ModelObject?.Skeleton == null)
- return null;
-
- // only show actors that have atleast one partial skeleton
- if (this.Actor.ModelObject.Skeleton.Length <= 0)
- return null;
-
- string? modernName = LegacyBoneNameConverter.GetModernName(name);
- if (modernName != null)
- name = modernName;
-
- BoneVisual3d? bone;
-
- // Attempt to find hairstyle-specific bones. If not found, default to the standard hair bones.
- if (this.hairNameToSuffixMap.TryGetValue(name, out Tuple? suffixAndDefault))
- {
- bone = this.FindHairBoneByPattern(suffixAndDefault.Item1);
- if (bone != null)
- return bone;
- else
- name = suffixAndDefault.Item2; // If not found, default to the standard hair bones.
- }
-
- this.Bones.TryGetValue(name, out bone);
- return bone;
- }
-
- public void SelectBody()
- {
- this.SelectHead();
- this.InvertSelection();
-
- List additionalBones = new List();
- BoneVisual3d? headBone = this.GetBone("j_kao");
- if (headBone != null)
- additionalBones.Add(headBone);
- this.Select(additionalBones, SkeletonVisual3d.SelectMode.Add);
- }
-
- public void SelectHead()
- {
- this.ClearSelection();
-
- BoneVisual3d? headBone = this.GetBone("j_kao");
- if (headBone == null)
- return;
-
- List headBones = new List();
- headBones.Add(headBone);
-
- this.GetBoneChildren(headBone, ref headBones);
- this.Select(headBones, SkeletonVisual3d.SelectMode.Add);
- }
-
- public void SelectWeapons()
- {
- this.ClearSelection();
- var bonesToSelect = this.mainHandBones.Concat(this.offHandBones).ToList();
-
- if (this.GetBone("n_buki_l") is BoneVisual3d boneLeft)
- bonesToSelect.Add(boneLeft);
-
- if (this.GetBone("n_buki_r") is BoneVisual3d boneRight)
- bonesToSelect.Add(boneRight);
-
- this.Select(bonesToSelect, SkeletonVisual3d.SelectMode.Add);
- }
-
- public void InvertSelection()
- {
- foreach ((string name, BoneVisual3d bone) in this.Bones)
- {
- bool selected = this.SelectedBones.Contains(bone);
-
- if (selected)
- {
- this.SelectedBones.Remove(bone);
+ this.Children.Clear();
}
- else
- {
- this.SelectedBones.Add(bone);
- }
- }
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CurrentBone));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.HasSelection));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.SelectedCount));
- this.RaisePropertyChanged(nameof(SkeletonVisual3d.CanEditBone));
- }
+ /* Dispose unmanaged resources here if any */
- public void GetBoneChildren(BoneVisual3d bone, ref List bones)
- {
- foreach (Visual3D child in bone.Children)
- {
- if (child is BoneVisual3d childBone)
- {
- bones.Add(childBone);
- this.GetBoneChildren(childBone, ref bones);
- }
- }
- }
-
- public void Reselect()
- {
- List selection = new List(this.SelectedBones);
- this.ClearSelection();
- this.Select(selection);
- }
-
- public Dictionary TakeSnapshot()
- {
- var snapshot = new Dictionary();
-
- if (this.Actor?.ModelObject?.Skeleton == null)
- return snapshot;
-
- this.Actor.ModelObject.Skeleton.EnableReading = false;
-
- foreach (var bone in this.AllBones)
- {
- snapshot[bone.BoneName] = new Transform
- {
- Position = bone.TransformMemory.Position,
- Rotation = bone.TransformMemory.Rotation,
- Scale = bone.TransformMemory.Scale,
- };
+ this.disposed = true;
}
-
- this.Actor.ModelObject.Skeleton.EnableReading = true;
-
- return snapshot;
}
- public void ReadTransforms()
+ /// Initializes the visual representation of the skeleton.
+ /// The skeleton entity to initialize.
+ private void Initialize(SkeletonEntity skeleton)
{
- if (this.Bones == null || this.Actor?.ModelObject?.Skeleton == null)
- return;
-
- if (!GposeService.GetIsGPose())
- return;
-
- // If history is restoring, wait until it's done.
- lock (HistoryService.Instance.LockObject)
- {
- // Take a snapshot of the current transforms
- var snapshot = this.TakeSnapshot();
-
- // Read skeleton transforms, starting from the root bones
- foreach (var rootBone in this.rootBones)
- {
- rootBone.ReadTransform(true, snapshot);
- }
- }
- }
+ this.Children.Clear();
- public void ClearBones()
- {
- foreach (BoneVisual3d bone in this.Bones.Values)
+ // Add root bones to visual tree
+ foreach (var bone in skeleton.Bones.Values.OfType().Where(b => b.Parent == null))
{
- bone.Dispose();
+ var boneVisual = new BoneVisual3D(this, bone);
+ this.Children.Add(boneVisual);
}
-
- this.Bones.Clear();
-
- this.hairBones.Clear();
- this.metBones.Clear();
- this.topBones.Clear();
- this.mainHandBones.Clear();
- this.offHandBones.Clear();
-
- this.SelectedBones.Clear();
- this.HoverBones.Clear();
}
- public async Task SetActor(ActorMemory actor)
+ /// Handles property changes in the skeleton entity.
+ /// The source of the property change event.
+ /// The event data.
+ private async void OnSkeletonPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (this.Actor != null && this.Actor.ModelObject?.Transform != null)
- this.Actor.ModelObject.Transform.PropertyChanged -= this.OnTransformPropertyChanged;
-
- this.Actor = actor;
-
- if (actor.ModelObject?.Transform != null)
- actor.ModelObject.Transform.PropertyChanged += this.OnTransformPropertyChanged;
-
- this.Clear();
-
- await Dispatch.MainThread();
-
- this.ClearSelection();
-
- try
+ if (e.PropertyName == nameof(SkeletonEntity.SelectedBones) || e.PropertyName == nameof(SkeletonEntity.HoveredBones))
{
await Dispatch.MainThread();
- if (!GposeService.Instance.IsGpose)
- return;
-
- this.ClearBones();
- this.Children.Clear();
-
- if (this.Actor?.ModelObject?.Skeleton == null)
- return;
-
- // Get all bones
- this.AddBones(this.Actor.ModelObject.Skeleton);
-
- if (this.Actor.MainHand?.Model?.Skeleton != null)
- this.AddBones(this.Actor.MainHand.Model.Skeleton, "mh_");
-
- if (this.Actor.OffHand?.Model?.Skeleton != null)
- this.AddBones(this.Actor.OffHand.Model.Skeleton, "oh_");
-
- // Create Bone links from the link database
- foreach ((string name, BoneVisual3d bone) in this.Bones)
- {
- foreach (LinkedBones.LinkSet links in LinkedBones.Links)
- {
- if (links.Tribe != null && this.Actor?.Customize?.Tribe != links.Tribe)
- continue;
-
- if (links.Gender != null && this.Actor?.Customize?.Gender != links.Gender)
- continue;
-
- if (!links.Contains(name))
- continue;
-
- foreach (string linkedBoneName in links.Bones)
- {
- if (linkedBoneName == name)
- continue;
-
- BoneVisual3d? linkedBone = this.GetBone(linkedBoneName);
-
- if (linkedBone == null)
- continue;
-
- bone.LinkedBones.Add(linkedBone);
- }
- }
- }
-
- // Read the initial transforms of all bones.
- foreach ((string name, BoneVisual3d bone) in this.Bones)
- {
- bone.ReadTransform();
- }
-
- // Check for ivcs bones
- this.IsIVCS = false;
- foreach ((string name, BoneVisual3d bone) in this.Bones)
+ foreach (BoneVisual3D bone in this.Children.OfType())
{
- if (name.StartsWith("iv_"))
- {
- this.IsIVCS = true;
- break;
- }
+ bone.UpdateMaterial();
}
-
- // Notify that the skeleton has changed.
- // All properties that depend on the skeleton are prompted to update.
- this.RaisePropertyChanged(string.Empty);
- }
- catch (Exception)
- {
- throw;
}
}
- public void WriteSkeleton()
+ /// Handles the transform property changed event.
+ /// The sender of the event.
+ /// The event arguments.
+ private async void OnTransformPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (this.Actor == null || this.Actor.ModelObject?.Skeleton == null)
- return;
-
- if (this.CurrentBone != null && PoseService.Instance.IsEnabled)
+ if (e.PropertyName == nameof(TransformMemory.Rotation))
{
- lock (HistoryService.Instance.LockObject)
- {
- try
- {
- this.Actor.PauseSynchronization = true;
- this.CurrentBone.WriteTransform(this);
- this.Actor.PauseSynchronization = false;
- }
- catch (Exception ex)
- {
- Log.Error(ex, $"Failed to write bone transform: {this.CurrentBone.BoneName}");
- this.ClearSelection();
- }
- }
- }
- }
-
- private void AddBones(SkeletonMemory skeleton, string? namePrefix = null)
- {
- for (int partialSkeletonIndex = 0; partialSkeletonIndex < skeleton.Length; partialSkeletonIndex++)
- {
- PartialSkeletonMemory partialSkeleton = skeleton[partialSkeletonIndex];
-
- HkaPoseMemory? bestHkaPose = partialSkeleton.Pose1;
-
- if (bestHkaPose == null || bestHkaPose.Skeleton?.Bones == null || bestHkaPose.Skeleton?.ParentIndices == null || bestHkaPose.Transforms == null)
- {
- Log.Warning("Failed to find best HkaSkeleton for partial skeleton");
- continue;
- }
-
- int count = bestHkaPose.Transforms.Length;
-
- // Load all bones first
- for (int boneIndex = partialSkeletonIndex == 0 ? 0 : 1; boneIndex < count; boneIndex++)
- {
- string originalName = bestHkaPose.Skeleton.Bones[boneIndex].Name.ToString();
- string name = this.ConvertBoneName(namePrefix, originalName);
-
- TransformMemory? transform = bestHkaPose.Transforms[boneIndex];
-
- BoneVisual3d visual;
- if (this.Bones.ContainsKey(name))
- {
- visual = this.Bones[name];
- }
- else
- {
- // new bone
- visual = new BoneVisual3d(this, name);
- this.Bones.Add(name, visual);
- }
-
- // Do not allow modification of the root bone, things get weird.
- if (originalName == "n_root")
- visual.IsTransformLocked = true;
-
- // Ugh this whole mess here is /just/ for the pose matrix categories.
- if (namePrefix == "mh_")
- {
- this.mainHandBones.Add(visual);
- }
- else if (namePrefix == "oh_")
- {
- this.offHandBones.Add(visual);
- }
- else
- {
- if (originalName != "j_kao")
- {
- // Special logic to get the Hair, Met, and Helm bones for pose matrix.
- if (partialSkeletonIndex == 2)
- {
- this.hairBones.Add(visual);
- }
- else if (partialSkeletonIndex == 3)
- {
- this.metBones.Add(visual);
- }
- else if (partialSkeletonIndex == 4)
- {
- this.topBones.Add(visual);
- }
- }
- }
-
- visual.TransformMemories.Insert(0, transform);
- }
-
- // Set parents now all the bones are loaded
- for (int boneIndex = 0; boneIndex < count; boneIndex++)
- {
- int parentIndex = bestHkaPose.Skeleton.ParentIndices[boneIndex];
- string boneName = bestHkaPose.Skeleton.Bones[boneIndex].Name.ToString();
- boneName = this.ConvertBoneName(namePrefix, boneName);
-
- BoneVisual3d bone = this.Bones[boneName];
-
- if (bone.Parent != null || this.Children.Contains(bone))
- continue;
-
- try
- {
- if (parentIndex < 0)
- {
- // this bone has no parent, is root.
- this.Children.Add(bone);
- }
- else
- {
- string parentBoneName = bestHkaPose.Skeleton.Bones[parentIndex].Name.ToString();
- parentBoneName = this.ConvertBoneName(namePrefix, parentBoneName);
- bone.Parent = this.Bones[parentBoneName];
- }
- }
- catch (Exception ex)
- {
- Log.Error(ex, $"Failed to parent bone: {boneName}");
- }
- }
-
- // Find all root bones (bones without parents)
- this.rootBones.Clear();
- this.rootBones.AddRange(this.Bones.Values.Where(bone => bone.Parent == null));
- }
- }
-
- private string ConvertBoneName(string? prefix, string name)
- {
- if (prefix != null)
- name = prefix + name;
-
- return name;
- }
-
- private async void OnTransformPropertyChanged(object? sender, PropertyChangedEventArgs? e)
- {
- await Dispatch.MainThread();
-
- if (Application.Current == null)
- return;
-
- this.rootRotation.Quaternion = this.RootRotation.ToMedia3DQuaternion();
- }
-
- private void RaisePropertyChanged(string propertyName)
- {
- this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
- }
-
- private BoneVisual3d? FindHairBoneByPattern(string suffix)
- {
- string pattern = $@"j_ex_h\d{{4}}_ke_{suffix}";
- Regex regex = new Regex(pattern);
+ await Dispatch.MainThread();
- foreach (var (boneName, bone) in this.Bones)
- {
- if (regex.IsMatch(boneName))
- return bone;
+ this.RootRotation.Quaternion = this.Skeleton.RootRotation.ToMedia3DQuaternion();
+ this.rotateTransform.Rotation = this.RootRotation;
}
-
- return null;
}
}
-
-#pragma warning disable SA1201
-public interface IBone
-{
- BoneVisual3d? Visual { get; }
-}
diff --git a/Anamnesis/Actor/Refresh/BrioActorRefresher.cs b/Anamnesis/Actor/Refresh/BrioActorRefresher.cs
index 244411120..e3595bc5b 100644
--- a/Anamnesis/Actor/Refresh/BrioActorRefresher.cs
+++ b/Anamnesis/Actor/Refresh/BrioActorRefresher.cs
@@ -4,6 +4,7 @@
namespace Anamnesis.Actor.Refresh;
using Anamnesis.Brio;
+using Anamnesis.Core;
using Anamnesis.Files;
using Anamnesis.Memory;
using Anamnesis.Services;
@@ -34,10 +35,9 @@ public async Task RefreshActor(ActorMemory actor)
if (PoseService.Instance.IsEnabled)
{
// Save the current pose
- PoseFile poseFile = new PoseFile();
- SkeletonVisual3d skeletonVisual3D = new SkeletonVisual3d();
- await skeletonVisual3D.SetActor(actor);
- poseFile.WriteToFile(actor, skeletonVisual3D, null);
+ var poseFile = new PoseFile();
+ var skeleton = new Skeleton(actor);
+ poseFile.WriteToFile(actor, skeleton, null);
// Redraw
var result = await Brio.Redraw(actor.ObjectIndex);
@@ -50,9 +50,8 @@ public async Task RefreshActor(ActorMemory actor)
await Dispatch.MainThread();
// Restore current pose
- skeletonVisual3D = new SkeletonVisual3d();
- await skeletonVisual3D.SetActor(actor);
- poseFile.Apply(actor, skeletonVisual3D, null, PoseFile.Mode.All, true);
+ skeleton = new Skeleton(actor);
+ poseFile.Apply(actor, skeleton, null, PoseFile.Mode.All, true);
}).Start();
}
}
diff --git a/Anamnesis/Anamnesis.csproj b/Anamnesis/Anamnesis.csproj
index 2457300e6..b6946b4be 100644
--- a/Anamnesis/Anamnesis.csproj
+++ b/Anamnesis/Anamnesis.csproj
@@ -31,6 +31,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/Anamnesis/Core/Bone.cs b/Anamnesis/Core/Bone.cs
new file mode 100644
index 000000000..2d67ded4f
--- /dev/null
+++ b/Anamnesis/Core/Bone.cs
@@ -0,0 +1,463 @@
+// © Anamnesis.
+// Licensed under the MIT license.
+
+namespace Anamnesis.Core;
+
+using Anamnesis.Actor;
+using Anamnesis.Memory;
+using Anamnesis.Services;
+using PropertyChanged;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using XivToolsWpf.Math3D.Extensions;
+
+///
+/// Represents a bone in a skeleton, providing mechanisms for reading and writing transforms,
+/// synchronizing with memory, and handling linked bones.
+///
+[AddINotifyPropertyChangedInterface]
+public class Bone : ITransform
+{
+ protected const float EqualityTolerance = 0.00001f;
+ protected readonly ReaderWriterLockSlim transformLock = new();
+ private static readonly HashSet AttachmentBoneNames = new() { "n_buki_r", "n_buki_l", "j_buki_sebo_r", "j_buki_sebo_l" };
+ private static bool scaleLinked = true;
+ private bool hasInitialReading = false;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skeleton to which this bone belongs.
+ /// The list of transform memory objects linked to this bone.
+ /// The bone's internal name.
+ /// The index of the bone in the partial skeleton.
+ /// The parent bone, if any.
+ public Bone(Skeleton skeleton, List transformMemories, string name, int partialSkeletonIndex, Bone? parent = null)
+ {
+ this.Skeleton = skeleton;
+ this.Name = name;
+ this.PartialSkeletonIndex = partialSkeletonIndex;
+ this.Parent = parent;
+ this.TransformMemories = transformMemories;
+
+ this.Position = this.TransformMemory?.Position ?? Vector3.Zero;
+ this.Rotation = this.TransformMemory?.Rotation ?? Quaternion.Identity;
+ this.Scale = this.TransformMemory?.Scale ?? Vector3.Zero;
+ }
+
+ /// Gets or sets the skeleton to which this bone belongs.
+ public Skeleton Skeleton { get; protected set; }
+
+ /// Gets all transform memory objects linked to this bone.
+ public List TransformMemories { get; }
+
+ /// Gets the primary transform memory object for this bone.
+ public TransformMemory? TransformMemory => this.TransformMemories.FirstOrDefault();
+
+ /// Gets or sets the parent bone of this bone.
+ public Bone? Parent { get; protected set; }
+
+ /// Gets or sets the index of the bone in the partial skeleton.
+ public int PartialSkeletonIndex { get; protected set; }
+
+ /// Gets or sets the list of child bones of this bone.
+ public List Children { get; protected set; } = new();
+
+ /// Gets or sets the bone's internal name.
+ public string Name { get; protected set; }
+
+ /// Gets or sets the list of bones linked to this bone.
+ public List LinkedBones { get; set; } = new();
+
+ /// Gets or sets a value indicating whether the transform of this bone is locked.
+ public bool IsTransformLocked { get; set; } = false;
+
+ ///
+ public bool CanTranslate => PoseService.Instance.FreezePositions && !this.IsTransformLocked;
+
+ ///
+ /// Gets or sets the parent-relative position of the bone.
+ /// If the bone has no parent, this value will be relative to the root of the skeleton.
+ ///
+ ///
+ /// If you want to get character-relative position, use the position in property instead.
+ ///
+ public Vector3 Position { get; set; }
+
+ ///
+ public bool CanRotate => PoseService.Instance.FreezeRotation && !this.IsTransformLocked;
+
+ ///
+ /// Gets or sets the parent-relative rotation of the bone.
+ /// If the bone has no parent, this value will be relative to the root of the skeleton.
+ ///
+ ///
+ /// If you want to get character-relative rotation, use the rotation in property instead.
+ ///
+ public Quaternion Rotation { get; set; }
+
+ /// Gets the root rotation of the bone.
+ public Quaternion RootRotation => this.Parent == null
+ ? this.Skeleton.RootRotation
+ : Quaternion.Normalize(this.Skeleton.RootRotation * this.Parent.TransformMemory!.Rotation);
+
+ ///
+ public bool CanScale => PoseService.Instance.FreezeScale && !this.IsTransformLocked;
+
+ /// Gets or sets the scale of the bone.
+ public Vector3 Scale { get; set; }
+
+ /// Gets a value indicating whether this bone is an attachment bone.
+ ///
+ /// Attachment bones are bones that are used to attach items to a character, such as weapons or shields.
+ ///
+ public bool IsAttachmentBone => AttachmentBoneNames.Contains(this.Name);
+
+ ///
+ public bool CanLinkScale => !this.IsAttachmentBone;
+
+ ///
+ public bool ScaleLinked
+ {
+ get => this.IsAttachmentBone || scaleLinked;
+ set => scaleLinked = value;
+ }
+
+ /// Gets or sets a value indicating whether linked bones are enabled.
+ public bool EnableLinkedBones
+ {
+ get => this.LinkedBones.Count > 0 && SettingsService.Current.PosingBoneLinks.Get(this.Name, true);
+ set
+ {
+ SettingsService.Current.PosingBoneLinks.Set(this.Name, value);
+ foreach (var link in this.LinkedBones)
+ {
+ SettingsService.Current.PosingBoneLinks.Set(link.Name, value);
+ }
+ }
+ }
+
+ /// Sorts the specified bones by their depth in the skeleton hierarchy.
+ /// The enumerable collection of bones to sort.
+ /// The sorted list of bones.
+ public static List SortBonesByHierarchy(IEnumerable bones)
+ => bones.OrderBy(bone => Bone.GetBoneDepth(bone)).ToList();
+
+ ///
+ /// Converts a local (parent-relative) transform to model (character-relative) space.
+ ///
+ /// The local transform.
+ /// The parent's character-relative transform.
+ /// The model space transform.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Transform LocalToModelSpace(Transform localTransform, Transform parentTransform)
+ {
+ // Apply the parent's character-relative rotation to the local position
+ Vector3 modelPosition = Vector3.Transform(localTransform.Position, parentTransform.Rotation);
+ modelPosition += parentTransform.Position;
+
+ // Apply the parent's character-relative rotation to the local rotation
+ Quaternion modelRotation = Quaternion.Normalize(parentTransform.Rotation * localTransform.Rotation);
+
+ return new Transform
+ {
+ Position = modelPosition,
+ Rotation = modelRotation,
+ Scale = localTransform.Scale, // Scale is not affected by parent transform
+ };
+ }
+
+ ///
+ /// Converts a model (character-relative) transform to local (parent-relative) space.
+ ///
+ /// The model transform.
+ /// The parent's character-relative transform.
+ /// The local space transform.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Transform ModelToLocalSpace(Transform modelTransform, Transform parentTransform)
+ {
+ // Subtract the parent's character-relative position from the model position
+ Vector3 localPosition = modelTransform.Position - parentTransform.Position;
+
+ // Apply the inverse of the parent's character-relative rotation to the local position
+ Quaternion parentRotInverse = Quaternion.Inverse(parentTransform.Rotation);
+ localPosition = Vector3.Transform(localPosition, parentRotInverse);
+
+ // Apply the inverse of the parent's character-relative rotation to the model rotation
+ Quaternion localRotation = Quaternion.Normalize(parentRotInverse * modelTransform.Rotation);
+
+ return new Transform
+ {
+ Position = localPosition,
+ Rotation = localRotation,
+ Scale = modelTransform.Scale, // Scale is not affected by parent transform
+ };
+ }
+
+ ///
+ /// Gets the depth of the specified bone in the skeleton hierarchy.
+ ///
+ /// The bone whose depth is to be determined.
+ /// The depth of the bone in the hierarchy.
+ public static int GetBoneDepth(Bone bone)
+ {
+ int depth = 0;
+ while (bone.Parent != null)
+ {
+ depth++;
+ bone = bone.Parent;
+ }
+
+ return depth;
+ }
+
+ /// Synchronizes the bone with its transform memories.
+ public virtual void Synchronize()
+ {
+ foreach (TransformMemory transformMemory in this.TransformMemories)
+ transformMemory.Synchronize();
+
+ this.ReadTransform();
+ }
+
+ /// Reads the transform of the bone from game memory or a snapshot.
+ ///
+ /// Snapshots are primarily used by the skeleton object to optimize memory reads.
+ ///
+ /// Whether to read the transforms of child bones.
+ /// An optional snapshot of transforms to use instead of memory.
+ public virtual void ReadTransform(bool readChildren = false, Dictionary? snapshot = null)
+ {
+ if (this.TransformMemories.Count == 0)
+ return;
+
+ Stack bonesToProcess = new();
+ bonesToProcess.Push(this);
+
+ while (bonesToProcess.Count > 0)
+ {
+ Bone currentBone = bonesToProcess.Pop();
+
+ // Use snapshot if available, otherwise use values from memory
+ // Note: Values are expected to be in model space
+ Transform localTransform;
+ if (snapshot != null && snapshot.TryGetValue(currentBone.Name, out var transform))
+ {
+ localTransform = transform;
+ }
+ else
+ {
+ var transformMemory = currentBone.TransformMemories[0];
+ localTransform = new Transform
+ {
+ Position = transformMemory.Position,
+ Rotation = transformMemory.Rotation,
+ Scale = transformMemory.Scale,
+ };
+ }
+
+ // Convert the character-relative transform into a parent-relative transform
+ if (currentBone.Parent != null)
+ {
+ Transform parentTransform;
+ if (snapshot != null && snapshot.TryGetValue(currentBone.Parent.Name, out var parentSnapshot))
+ {
+ parentTransform = parentSnapshot;
+ }
+ else
+ {
+ var parentTransformMemory = currentBone.Parent.TransformMemories[0];
+ parentTransform = new Transform
+ {
+ Position = parentTransformMemory.Position,
+ Rotation = parentTransformMemory.Rotation,
+ Scale = parentTransformMemory.Scale,
+ };
+ }
+
+ localTransform = ModelToLocalSpace(localTransform, parentTransform);
+ }
+
+ currentBone.transformLock.EnterReadLock();
+ try
+ {
+ currentBone.Position = localTransform.Position;
+ currentBone.Rotation = localTransform.Rotation;
+ currentBone.Scale = localTransform.Scale;
+ currentBone.hasInitialReading = true;
+ }
+ finally
+ {
+ currentBone.transformLock.ExitReadLock();
+ }
+
+ if (readChildren)
+ {
+ foreach (var child in currentBone.Children)
+ {
+ bonesToProcess.Push(child);
+ }
+ }
+ }
+ }
+
+ /// Writes the transform of the bone to game memory.
+ /// Whether to write the transforms of child bones.
+ /// Whether to write the transforms of linked bones.
+ public virtual void WriteTransform(bool writeChildren = true, bool writeLinked = true)
+ {
+ Stack<(Bone bone, bool writeLinked)> bonesToProcess = new();
+ bonesToProcess.Push((this, writeLinked));
+
+ while (bonesToProcess.Count > 0)
+ {
+ var (currentBone, currentWriteLinked) = bonesToProcess.Pop();
+ var transformMemories = currentBone.TransformMemories;
+
+ if (transformMemories.Count == 0)
+ throw new System.InvalidOperationException($"Bone \"{currentBone.Name}\" does not have any transform memories.");
+
+ // Carry out initial transform if it hasn't been done yet
+ if (!currentBone.hasInitialReading)
+ {
+ currentBone.ReadTransform();
+ }
+
+ Transform modelTransform = new()
+ {
+ Position = currentBone.Position,
+ Rotation = currentBone.Rotation,
+ Scale = currentBone.Scale,
+ };
+
+ if (currentBone.Parent != null)
+ {
+ var parentTransformMemory = currentBone.Parent.TransformMemory!;
+ Transform parentTransform = new()
+ {
+ Position = parentTransformMemory.Position,
+ Rotation = parentTransformMemory.Rotation,
+ Scale = parentTransformMemory.Scale,
+ };
+
+ modelTransform = LocalToModelSpace(modelTransform, parentTransform);
+ }
+
+ currentBone.transformLock.EnterWriteLock();
+ try
+ {
+ foreach (TransformMemory transformMemory in transformMemories)
+ {
+ transformMemory.EnableReading = false;
+ }
+
+ bool changed = false;
+
+ foreach (TransformMemory transformMemory in transformMemories)
+ {
+ if (currentBone.CanTranslate && !transformMemory.Position.IsApproximately(modelTransform.Position, EqualityTolerance))
+ {
+ transformMemory.Position = modelTransform.Position;
+ changed = true;
+ }
+
+ if (currentBone.CanScale && !transformMemory.Scale.IsApproximately(modelTransform.Scale, EqualityTolerance))
+ {
+ transformMemory.Scale = modelTransform.Scale;
+ changed = true;
+ }
+
+ if (currentBone.CanRotate && !transformMemory.Rotation.IsApproximately(modelTransform.Rotation, EqualityTolerance))
+ {
+ transformMemory.Rotation = modelTransform.Rotation;
+ changed = true;
+ }
+ }
+
+ if (changed)
+ {
+ if (currentWriteLinked && currentBone.EnableLinkedBones)
+ {
+ foreach (var link in currentBone.LinkedBones)
+ {
+ // TODO: Figure out how to sync linked bone positions
+ link.Rotation = currentBone.Rotation;
+ link.Scale = currentBone.Scale;
+ bonesToProcess.Push((link, false));
+ }
+ }
+
+ if (writeChildren && PoseService.Instance.EnableParenting)
+ {
+ foreach (var child in currentBone.Children)
+ {
+ bonesToProcess.Push((child, currentWriteLinked));
+ }
+ }
+ }
+
+ foreach (TransformMemory transformMemory in transformMemories)
+ {
+ transformMemory.EnableReading = true;
+ }
+ }
+ finally
+ {
+ currentBone.transformLock.ExitWriteLock();
+ }
+ }
+ }
+
+ /// Gets the descendants of this bone.
+ /// The list of descendant bones.
+ public List GetDescendants()
+ {
+ List descendants = new();
+ Stack stack = new(this.Children);
+
+ while (stack.Count > 0)
+ {
+ Bone current = stack.Pop();
+ descendants.Add(current);
+ foreach (Bone child in current.Children)
+ {
+ stack.Push(child);
+ }
+ }
+
+ return descendants;
+ }
+
+ /// Determines whether the bone has the specified target bone as an ancestor.
+ /// The target bone to check.
+ /// True if the target bone is an ancestor of this bone; otherwise, false.
+ public bool HasAncestor(Bone target)
+ {
+ Bone? current = this.Parent;
+ while (current != null)
+ {
+ if (current == target)
+ return true;
+ current = current.Parent;
+ }
+
+ return false;
+ }
+
+ /// Returns a string that represents the current object.
+ /// A string that represents the current object.
+ public override string ToString() => base.ToString() + "(" + this.Name + ")";
+
+ /// Sets the parent of this bone.
+ /// The new parent bone.
+ internal virtual void SetParent(TBone newParent)
+ where TBone : Bone?
+ {
+ this.Parent?.Children.Remove(this);
+ this.Parent = newParent;
+ newParent?.Children.Add(this);
+ }
+}
diff --git a/Anamnesis/Core/IService.cs b/Anamnesis/Core/IService.cs
index b790493bc..d660d45e6 100644
--- a/Anamnesis/Core/IService.cs
+++ b/Anamnesis/Core/IService.cs
@@ -7,6 +7,8 @@ namespace Anamnesis;
public interface IService
{
+ public bool IsAlive { get; }
+
Task Initialize();
Task Start();
Task Shutdown();
diff --git a/Anamnesis/Actor/Posing/LinkedBones.cs b/Anamnesis/Core/LinkedBones.cs
similarity index 98%
rename from Anamnesis/Actor/Posing/LinkedBones.cs
rename to Anamnesis/Core/LinkedBones.cs
index 202c5701e..0dfe00a21 100644
--- a/Anamnesis/Actor/Posing/LinkedBones.cs
+++ b/Anamnesis/Core/LinkedBones.cs
@@ -1,7 +1,7 @@
// © Anamnesis.
// Licensed under the MIT license.
-namespace Anamnesis.Actor.Posing;
+namespace Anamnesis.Core;
using Anamnesis.Memory;
using System.Collections.Generic;
diff --git a/Anamnesis/Core/Memory/MemoryService.cs b/Anamnesis/Core/Memory/MemoryService.cs
index d5305df65..931e8f11d 100644
--- a/Anamnesis/Core/Memory/MemoryService.cs
+++ b/Anamnesis/Core/Memory/MemoryService.cs
@@ -36,7 +36,7 @@ public class MemoryService : ServiceBase
///
/// The number of milliseconds to wait between read attempts.
///
- private const int TimeBetweenReadAttempts = 20;
+ private const int TimeBetweenReadAttempts = 10;
///
/// The interval in milliseconds to wait for process refresh checks.
@@ -156,25 +156,30 @@ public static T Read(IntPtr address)
where T : struct
{
if (address == IntPtr.Zero)
- throw new Exception("Invalid address");
+ throw new ArgumentException("Invalid address", nameof(address));
int attempt = 0;
+ int size = Marshal.SizeOf();
+ Span buffer = stackalloc byte[size];
+
while (attempt < MaxReadAttempts)
{
- int size = Marshal.SizeOf();
- IntPtr mem = Marshal.AllocHGlobal(size);
- ReadProcessMemory(Handle, address, mem, size, out _);
- T? val = Marshal.PtrToStructure(mem);
- Marshal.FreeHGlobal(mem);
- attempt++;
-
- if (val != null)
- return (T)val;
+ unsafe
+ {
+ fixed (byte* ptr = buffer)
+ {
+ if (ReadProcessMemory(Handle, address, (IntPtr)ptr, size, out _))
+ {
+ return MemoryMarshal.Read(buffer);
+ }
+ }
+ }
+ attempt++;
Thread.Sleep(TimeBetweenReadAttempts);
}
- throw new Exception($"Failed to read memory {typeof(T)} from address {address}");
+ throw new InvalidOperationException($"Failed to read memory {typeof(T)} from address {address}");
}
///
@@ -187,7 +192,7 @@ public static T Read(IntPtr address)
public static object Read(IntPtr address, Type type)
{
if (address == IntPtr.Zero)
- throw new Exception("Invalid address");
+ throw new ArgumentException("Invalid address", nameof(address));
Type readType = type;
@@ -197,32 +202,39 @@ public static object Read(IntPtr address, Type type)
if (type == typeof(bool))
readType = typeof(OneByteBool);
- for (int attempt = 0; attempt < MaxReadAttempts; attempt++)
- {
- int size = Marshal.SizeOf(readType);
- IntPtr mem = Marshal.AllocHGlobal(size);
+ int attempt = 0;
+ int size = Marshal.SizeOf(readType);
+ Span buffer = stackalloc byte[size];
- if (ReadProcessMemory(Handle, address, mem, size, out _))
+ while (attempt < MaxReadAttempts)
+ {
+ unsafe
{
- object? val = Marshal.PtrToStructure(mem, readType);
- Marshal.FreeHGlobal(mem);
+ fixed (byte* ptr = buffer)
+ {
+ if (ReadProcessMemory(Handle, address, (IntPtr)ptr, size, out _))
+ {
+ object? val = Marshal.PtrToStructure((IntPtr)ptr, readType);
- if (val == null)
- continue;
+ if (val == null)
+ continue;
- if (type.IsEnum)
- val = Enum.ToObject(type, val);
+ if (type.IsEnum)
+ return Enum.ToObject(type, val);
- if (val is OneByteBool obb)
- return obb.Value;
+ if (val is OneByteBool obb)
+ return obb.Value;
- return val;
+ return val;
+ }
+ }
}
+ attempt++;
Thread.Sleep(TimeBetweenReadAttempts);
}
- throw new Exception($"Failed to read memory {type} from address {address}");
+ throw new InvalidOperationException($"Failed to read memory {type} from address {address}");
}
///
@@ -333,6 +345,27 @@ public static bool Write(IntPtr address, byte[] buffer, bool writingCode)
return WriteProcessMemory(Handle, address, buffer, buffer.Length, out _);
}
+ ///
+ /// Writes a span buffer to a specified memory address.
+ ///
+ /// The memory address to write to.
+ /// The span buffer to write.
+ /// Indicates whether the write operation involves writing executable code.
+ /// True if the write operation was successful, otherwise False.
+ public static bool Write(IntPtr address, Span buffer, bool writingCode)
+ {
+ if (writingCode)
+ VirtualProtectEx(Handle, address, buffer.Length, VirtualProtectReadWriteExecute, out _);
+
+ unsafe
+ {
+ fixed (byte* ptr = buffer)
+ {
+ return WriteProcessMemory(Handle, address, (IntPtr)ptr, buffer.Length, out _);
+ }
+ }
+ }
+
///
/// Writes a value of a specified type to a given memory address.
///
@@ -373,52 +406,66 @@ public static bool Write(IntPtr address, object value, Type type, string reason)
return false;
if (type.IsEnum)
- type = type.GetEnumUnderlyingType();
+ type = Enum.GetUnderlyingType(type);
byte[] buffer;
- if (type == typeof(bool))
- {
- buffer = new[] { (byte)((bool)value == true ? 1 : 0) };
- }
- else if (type == typeof(byte))
- {
- buffer = new[] { (byte)value };
- }
- else if (type == typeof(int))
- {
- buffer = BitConverter.GetBytes((int)value);
- }
- else if (type == typeof(uint))
- {
- buffer = BitConverter.GetBytes((uint)value);
- }
- else if (type == typeof(short))
- {
- buffer = BitConverter.GetBytes((short)value);
- }
- else if (type == typeof(ushort))
- {
- buffer = BitConverter.GetBytes((ushort)value);
- }
- else
+ unsafe
{
- try
+ if (type == typeof(bool))
{
- int size = Marshal.SizeOf(type);
- buffer = new byte[size];
- IntPtr mem = Marshal.AllocHGlobal(size);
- Marshal.StructureToPtr(value, mem, false);
- Marshal.Copy(mem, buffer, 0, size);
- Marshal.FreeHGlobal(mem);
+ buffer = new[] { (byte)((bool)value == true ? 1 : 0) };
}
- catch (Exception ex)
+ else if (type == typeof(byte))
{
- throw new Exception($"Failed to marshal type: {type} to memory", ex);
+ buffer = new[] { (byte)value };
+ }
+ else
+ {
+ int size = Marshal.SizeOf(type);
+ buffer = new byte[size];
+ fixed (byte* ptr = buffer)
+ {
+ if (type == typeof(short))
+ {
+ *(short*)ptr = (short)value;
+ }
+ else if (type == typeof(ushort))
+ {
+ *(ushort*)ptr = (ushort)value;
+ }
+ else if (type == typeof(int))
+ {
+ *(int*)ptr = (int)value;
+ }
+ else if (type == typeof(uint))
+ {
+ *(uint*)ptr = (uint)value;
+ }
+ else if (type == typeof(long))
+ {
+ *(long*)ptr = (long)value;
+ }
+ else if (type == typeof(ulong))
+ {
+ *(ulong*)ptr = (ulong)value;
+ }
+ else if (type == typeof(float))
+ {
+ *(float*)ptr = (float)value;
+ }
+ else if (type == typeof(double))
+ {
+ *(double*)ptr = (double)value;
+ }
+ else
+ {
+ buffer = MarshalToByteArray(value, type);
+ }
+ }
}
}
- // Log.Verbose($"Writing: {buffer.Length} bytes to {address} for type {type.Name} for reason: {reason}");
return Write(address, buffer, false);
}
@@ -529,6 +576,35 @@ public async Task OpenProcess(Process process)
Scanner = new SignatureScanner(process.MainModule);
}
+ ///
+ /// Converts a value of a specified type to a byte array using marshaling.
+ ///
+ /// The value to marshal.
+ /// The type of the value to marshal.
+ /// A byte array containing the marshaled value.
+ /// Thrown if the marshaling operation fails.
+ private static byte[] MarshalToByteArray(object value, Type type)
+ {
+ int size = Marshal.SizeOf(type);
+ byte[] buffer = new byte[size];
+ IntPtr mem = Marshal.AllocHGlobal(size);
+ try
+ {
+ Marshal.StructureToPtr(value, mem, false);
+ Marshal.Copy(mem, buffer, 0, size);
+ }
+ catch (Exception ex)
+ {
+ throw new Exception($"Failed to marshal type: {type} to memory", ex);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(mem);
+ }
+
+ return buffer;
+ }
+
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int processId);
diff --git a/Anamnesis/Core/Skeleton.cs b/Anamnesis/Core/Skeleton.cs
new file mode 100644
index 000000000..571a8d433
--- /dev/null
+++ b/Anamnesis/Core/Skeleton.cs
@@ -0,0 +1,416 @@
+// © Anamnesis.
+// Licensed under the MIT license.
+
+namespace Anamnesis.Core;
+
+using Anamnesis.Memory;
+using Anamnesis.Posing;
+using Anamnesis.Services;
+using PropertyChanged;
+using Serilog;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+
+///
+/// Represents a skeleton of hierarchically-parented bones of an actor that can be posed.
+///
+[AddINotifyPropertyChangedInterface]
+public class Skeleton : INotifyPropertyChanged
+{
+ ///
+ /// A dictionary containing all bones in the skeleton, indexed by their names.
+ ///
+ public readonly ConcurrentDictionary Bones = new();
+
+ /// A mapping of hair bone names to their suffixes and default names.
+ ///
+ /// This is used to automatically pick a hair bone when multiple options are available.
+ ///
+ protected readonly Dictionary> hairNameToSuffixMap = new()
+ {
+ { "HairAutoFrontLeft", new("l", "j_kami_f_l") }, // Hair, Front Left
+ { "HairAutoFrontRight", new("r", "j_kami_f_r") }, // Hair, Front Right
+ { "HairAutoA", new("a", "j_kami_a") }, // Hair, Back Up
+ { "HairAutoB", new("b", "j_kami_b") }, // Hair, Back Down
+ { "HairFront", new("f", string.Empty) }, // Hair, Front (Custom Bone Name)
+ };
+
+ /// Initializes a new instance of the class.
+ /// The actor memory associated with this skeleton.
+ public Skeleton(ActorMemory actor)
+ {
+ this.Actor = actor;
+ this.SetActor(actor);
+ }
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ /// Gets the actor memory associated with this skeleton.
+ public ActorMemory Actor { get; private set; }
+
+ /// Gets the model space root rotation of the skeleton.
+ public Quaternion RootRotation => this.Actor?.ModelObject?.Transform?.Rotation ?? Quaternion.Identity;
+
+ /// Gets a value indicating whether the actor has a tail.
+ public bool HasTail => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Miqote
+ || this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.AuRa
+ || this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Hrothgar
+ || this.IsIVCS;
+
+ /// Gets a value indicating whether the actor has a standard face.
+ public bool IsStandardFace => this.Actor == null || (!this.IsMiqote && !this.IsHrothgar && !this.IsViera);
+
+ /// Gets a value indicating whether the actor is a Miqote.
+ public bool IsMiqote => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Miqote;
+
+ /// Gets a value indicating whether the actor is a Viera.
+ public bool IsViera => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Viera;
+
+ /// Gets a value indicating whether the actor is an Elezen.
+ public bool IsElezen => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Elezen;
+
+ /// Gets a value indicating whether the actor is a Hrothgar.
+ public bool IsHrothgar => this.Actor?.Customize?.Race == ActorCustomizeMemory.Races.Hrothgar;
+
+ /// Gets a value indicating whether the actor has a tail or ears.
+ public bool HasTailOrEars => this.IsViera || this.HasTail;
+
+ /// Gets a value indicating whether the actor is a Viera and has ears type 01.
+ public bool IsEars01 => this.IsViera && this.Actor?.Customize?.TailEarsType <= 1;
+
+ /// Gets a value indicating whether the actor is a Viera and has ears type 02.
+ public bool IsEars02 => this.IsViera && this.Actor?.Customize?.TailEarsType == 2;
+
+ /// Gets a value indicating whether the actor is a Viera and has ears type 03.
+ public bool IsEars03 => this.IsViera && this.Actor?.Customize?.TailEarsType == 3;
+
+ /// Gets a value indicating whether the actor is a Viera and has ears type 04.
+ public bool IsEars04 => this.IsViera && this.Actor?.Customize?.TailEarsType == 4;
+
+ /// Gets a value indicating whether the skeleton has IVCS bones.
+ public bool IsIVCS { get; private set; }
+
+ /// Gets a value indicating whether the actor is a Viera and has floppy ears.
+ public bool IsVieraEarsFlop
+ {
+ get
+ {
+ if (!this.IsViera)
+ return false;
+
+ ActorCustomizeMemory? customize = this.Actor?.Customize;
+
+ if (customize == null)
+ return false;
+
+ if (customize.Gender == ActorCustomizeMemory.Genders.Feminine && customize.TailEarsType == 3)
+ return true;
+
+ if (customize.Gender == ActorCustomizeMemory.Genders.Masculine && customize.TailEarsType == 2)
+ return true;
+
+ return false;
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the actor has a legacy face with bones from before Dawntrail.
+ ///
+ public bool HasPreDTFace
+ {
+ get
+ {
+ // If the skeleton is not initialized, we can't determine if it's a pre-DT face.
+ if (this.Bones.IsEmpty)
+ return false;
+
+ // We can determine if we have a DT-updated face if we have a tongue bone.
+ // EW faces don't have this bone, whereas all updated faces in DT have it.
+ // It would be better to enumerate all of the faces and be more specific.
+ // Note: This only applies to humanoid skeletons.
+ return this.GetBone("j_f_bero_01") == null;
+ }
+ }
+
+ /// Gets the logger instance for the class.
+ private static ILogger Log => Serilog.Log.ForContext();
+
+ /// Clears all bones from the skeleton.
+ public virtual void Clear()
+ {
+ this.Bones.Clear();
+ }
+
+ /// Gets a bone from the skeleton by its name.
+ /// The name of the bone.
+ /// The bone if found; otherwise, null.
+ public virtual Bone? GetBone(string name)
+ {
+ // Only process valid skeletons that have atleast one partial skeleton
+ if (this.Actor?.ModelObject?.Skeleton == null || this.Actor.ModelObject.Skeleton.Length <= 0)
+ return null;
+
+ string? modernName = LegacyBoneNameConverter.GetModernName(name);
+ if (modernName != null)
+ name = modernName;
+
+ // Attempt to find hairstyle-specific bones. If not found, default to the standard hair bones.
+ if (this.hairNameToSuffixMap.TryGetValue(name, out Tuple? suffixAndDefault))
+ {
+ Bone? bone = this.FindHairBoneByPattern(suffixAndDefault.Item1);
+ if (bone != null)
+ return bone;
+
+ name = suffixAndDefault.Item2; // If not found, default to the standard hair bones.
+ }
+
+ this.Bones.TryGetValue(name, out var result);
+ return result;
+ }
+
+ /// Reads the transforms of all bones in the skeleton.
+ public void ReadTransforms()
+ {
+ if (this.Bones == null || this.Actor?.ModelObject?.Skeleton == null || !GposeService.GetIsGPose())
+ return;
+
+ // If history is restoring, wait until it's done.
+ lock (HistoryService.Instance.LockObject)
+ {
+ // Take a snapshot of the current transforms and update bone transforms.
+ var snapshot = this.TakeSnapshot();
+ var rootBones = new List();
+ foreach (var bone in this.Bones.Values)
+ {
+ if (bone.Parent == null)
+ rootBones.Add(bone);
+ }
+
+ foreach (var rootBone in rootBones)
+ {
+ rootBone.ReadTransform(true, snapshot);
+ }
+ }
+ }
+
+ ///
+ /// Prepend a prefix to the bone name and return the converted bone name.
+ ///
+ /// The prefix to add to the bone name.
+ /// The original bone name.
+ /// The converted bone name.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected static string ConvertBoneName(string? prefix, string name) => prefix != null ? prefix + name : name;
+
+ /// Takes a snapshot of the current transforms of all bones.
+ ///
+ /// The intended use of this method is to speed up memory reads by reading all bone transforms at once.
+ ///
+ /// A dictionary containing the transforms of all bones.
+ protected Dictionary TakeSnapshot()
+ {
+ var snapshot = new Dictionary();
+
+ if (this.Actor?.ModelObject?.Skeleton == null)
+ return snapshot;
+
+ this.Actor.ModelObject.Skeleton.EnableReading = false;
+
+ foreach (var (name, bone) in this.Bones)
+ {
+ var transform = bone.TransformMemory;
+ if (transform == null)
+ continue;
+
+ snapshot[name] = new Transform
+ {
+ Position = transform.Position,
+ Rotation = transform.Rotation,
+ Scale = transform.Scale,
+ };
+ }
+
+ this.Actor.ModelObject.Skeleton.EnableReading = true;
+
+ return snapshot;
+ }
+
+ /// Sets the actor memory for the skeleton and initializes all bones.
+ /// The actor memory to set.
+ protected virtual void SetActor(ActorMemory actor)
+ {
+ this.Actor = actor;
+
+ this.Clear();
+
+ if (!GposeService.Instance.IsGpose || this.Actor?.ModelObject?.Skeleton == null)
+ return;
+
+ // Get all bones
+ this.AddBones(this.Actor.ModelObject.Skeleton);
+
+ if (this.Actor.MainHand?.Model?.Skeleton != null)
+ this.AddBones(this.Actor.MainHand.Model.Skeleton, "mh_");
+
+ if (this.Actor.OffHand?.Model?.Skeleton != null)
+ this.AddBones(this.Actor.OffHand.Model.Skeleton, "oh_");
+
+ // Create Bone links from the link database
+ foreach ((string name, Bone bone) in this.Bones)
+ {
+ foreach (LinkedBones.LinkSet links in LinkedBones.Links)
+ {
+ if (links.Tribe != null && this.Actor?.Customize?.Tribe != links.Tribe)
+ continue;
+
+ if (links.Gender != null && this.Actor?.Customize?.Gender != links.Gender)
+ continue;
+
+ if (!links.Contains(name))
+ continue;
+
+ foreach (string linkedBoneName in links.Bones)
+ {
+ if (linkedBoneName == name)
+ continue;
+
+ Bone? linkedBone = this.GetBone(linkedBoneName);
+
+ if (linkedBone == null)
+ continue;
+
+ bone.LinkedBones.Add(linkedBone);
+ }
+ }
+ }
+
+ // Read the initial transforms of all bones
+ var snapshot = this.TakeSnapshot();
+ var rootBones = new List();
+ foreach (var bone in this.Bones.Values)
+ {
+ if (bone.Parent == null)
+ rootBones.Add(bone);
+ }
+
+ foreach (var rootBone in rootBones)
+ {
+ rootBone.ReadTransform(true, snapshot);
+ }
+
+ // Check for IVCS bones
+ this.IsIVCS = this.Bones.Keys.Any(name => name.StartsWith("iv_"));
+
+ // Notify that the skeleton has changed.
+ // All properties that depend on the skeleton are prompted to update.
+ this.RaisePropertyChanged(string.Empty);
+ }
+
+ ///
+ /// Adds bones from a skeleton memory to the current skeleton.
+ ///
+ /// The skeleton memory to add bones from.
+ /// An optional prefix to add to the bone names.
+ protected virtual void AddBones(SkeletonMemory skeleton, string? namePrefix = null)
+ {
+ for (int partialSkeletonIndex = 0; partialSkeletonIndex < skeleton.Length; partialSkeletonIndex++)
+ {
+ PartialSkeletonMemory partialSkeleton = skeleton[partialSkeletonIndex];
+ HkaPoseMemory? bestHkaPose = partialSkeleton.Pose1;
+
+ if (bestHkaPose == null || bestHkaPose.Skeleton?.Bones == null || bestHkaPose.Skeleton?.ParentIndices == null || bestHkaPose.Transforms == null)
+ {
+ Log.Verbose("Failed to find best HkaSkeleton for partial skeleton");
+ continue;
+ }
+
+ int count = bestHkaPose.Transforms.Length;
+
+ // Load all bones first
+ for (int boneIndex = 0; boneIndex < count; boneIndex++)
+ {
+ string originalName = bestHkaPose.Skeleton.Bones[boneIndex].Name.ToString();
+ string name = ConvertBoneName(namePrefix, originalName);
+ TransformMemory? transform = bestHkaPose.Transforms[boneIndex];
+
+ if (!this.Bones.TryGetValue(name, out var currentBone))
+ {
+ currentBone = this.CreateBone(this, new List { transform }, name, partialSkeletonIndex);
+ if (currentBone == null)
+ throw new Exception($"Failed to create bone: {name}");
+
+ this.Bones[name] = currentBone;
+ }
+ else
+ {
+ currentBone.TransformMemories.Add(transform);
+ }
+
+ // Do not allow modification of the root bone, things get weird.
+ if (originalName == "n_root")
+ currentBone.IsTransformLocked = true;
+ }
+
+ // Set parents now all the bones are loaded
+ for (int boneIndex = 0; boneIndex < count; boneIndex++)
+ {
+ int parentIndex = bestHkaPose.Skeleton.ParentIndices[boneIndex];
+ string boneName = ConvertBoneName(namePrefix, bestHkaPose.Skeleton.Bones[boneIndex].Name.ToString());
+ Bone bone = this.Bones[boneName];
+
+ // If the bone already has a parent, skip it.
+ if (bone.Parent != null)
+ continue;
+
+ // If parent index is -1, it means the bone has no parent.
+ if (parentIndex >= 0)
+ {
+ string parentBoneName = ConvertBoneName(namePrefix, bestHkaPose.Skeleton.Bones[parentIndex].Name.ToString());
+ if (this.Bones.TryGetValue(parentBoneName, out var parentBone) && parentBone is Bone typedParentBone)
+ {
+ bone.SetParent(typedParentBone);
+ }
+ else
+ {
+ Log.Warning($"Parent bone '{parentBoneName}' not found for bone '{bone.Name}'");
+ }
+ }
+ }
+ }
+ }
+
+ /// Raises the property changed event.
+ /// The name of the property that changed.
+ protected void RaisePropertyChanged(string propertyName)
+ {
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ /// Attempts to find a hair bone by its pattern.
+ /// The suffix of the hair bone.
+ /// The bone if found; otherwise, null.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected virtual Bone? FindHairBoneByPattern(string suffix)
+ {
+ var regex = new Regex($@"j_ex_h\d{{4}}_ke_{suffix}");
+ return this.Bones.FirstOrDefault(b => regex.IsMatch(b.Key)).Value;
+ }
+
+ /// Creates a new bone instance.
+ /// The skeleton to which the bone belongs.
+ /// The list of transform memories for the bone.
+ /// The name of the bone.
+ /// The index of the partial skeleton.
+ /// The created bone.
+ protected virtual Bone CreateBone(Skeleton skeleton, List transformMemories, string name, int partialSkeletonIndex)
+ {
+ return new Bone(skeleton, transformMemories, name, partialSkeletonIndex);
+ }
+}
\ No newline at end of file
diff --git a/Anamnesis/Download dotNet desktop runtime.URL b/Anamnesis/Download dotNet desktop runtime.URL
index b931dbed8..76363f8cd 100644
--- a/Anamnesis/Download dotNet desktop runtime.URL
+++ b/Anamnesis/Download dotNet desktop runtime.URL
@@ -1,5 +1,5 @@
[InternetShortcut]
-URL=https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-6.0.6-windows-x64-installer
+URL=https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-9.0.1-windows-x64-installer
IDList=
HotKey=0
IconFile=C:\Users\yukiw\AppData\Local\Mozilla\Firefox\Profiles\nmh78buc.default-release\shortcutCache\I5dsSbYJHpDZigeLere_Pg==.ico
diff --git a/Anamnesis/Files/FileBase.cs b/Anamnesis/Files/FileBase.cs
index 9da2a6ddc..ffab9161c 100644
--- a/Anamnesis/Files/FileBase.cs
+++ b/Anamnesis/Files/FileBase.cs
@@ -3,13 +3,13 @@
namespace Anamnesis.Files;
+using Anamnesis.Serialization;
+using Anamnesis.Serialization.Converters;
+using Anamnesis.Services;
using System;
using System.IO;
using System.Text.Json.Serialization;
using System.Windows.Media.Imaging;
-using Anamnesis.Serialization;
-using Anamnesis.Serialization.Converters;
-using Anamnesis.Services;
[Serializable]
public abstract class FileBase
diff --git a/Anamnesis/Files/FileService.cs b/Anamnesis/Files/FileService.cs
index cbfa21270..b09677838 100644
--- a/Anamnesis/Files/FileService.cs
+++ b/Anamnesis/Files/FileService.cs
@@ -3,6 +3,13 @@
namespace Anamnesis.Files;
+using Anamnesis;
+using Anamnesis.GUI.Dialogs;
+using Anamnesis.GUI.Views;
+using Anamnesis.Services;
+using Anamnesis.Utils;
+using Microsoft.Win32;
+using Serilog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -13,13 +20,6 @@ namespace Anamnesis.Files;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
-using Anamnesis;
-using Anamnesis.GUI.Dialogs;
-using Anamnesis.GUI.Views;
-using Anamnesis.Services;
-using Anamnesis.Utils;
-using Microsoft.Win32;
-using Serilog;
public class FileService : ServiceBase
{
@@ -47,7 +47,7 @@ public class FileService : ServiceBase
public static Shortcut DefaultCameraDirectory => new Shortcut(
new DirectoryInfo(ParseToFilePath(SettingsService.Current.DefaultCameraShotDirectory)),
"Shortcuts/Anamnesis.png",
- "Shortcut_AnamnesisScenes");
+ "Shortcut_AnamnesisCamera");
public static Shortcut DefaultSceneDirectory => new Shortcut(
new DirectoryInfo(ParseToFilePath(SettingsService.Current.DefaultSceneDirectory)),
diff --git a/Anamnesis/Files/PoseFile.cs b/Anamnesis/Files/PoseFile.cs
index 29364ae09..dba582927 100644
--- a/Anamnesis/Files/PoseFile.cs
+++ b/Anamnesis/Files/PoseFile.cs
@@ -4,6 +4,7 @@
namespace Anamnesis.Files;
using Anamnesis.Actor;
+using Anamnesis.Core;
using Anamnesis.Memory;
using Anamnesis.Posing;
using Serilog;
@@ -45,12 +46,12 @@ public enum BoneProcessingModes
public Dictionary? Bones { get; set; }
- public static BoneProcessingModes GetBoneMode(ActorMemory? actor, SkeletonVisual3d? skeleton, string boneName)
+ public static BoneProcessingModes GetBoneMode(ActorMemory? actor, Skeleton? skeleton, string boneName)
{
return boneName != "n_root" ? BoneProcessingModes.FullLoad : BoneProcessingModes.Ignore;
}
- public static async Task Save(DirectoryInfo? dir, ActorMemory? actor, SkeletonVisual3d? skeleton, HashSet? bones = null, bool editMeta = false)
+ public static async Task Save(DirectoryInfo? dir, ActorMemory? actor, Skeleton? skeleton, HashSet? bones = null, bool editMeta = false)
{
if (actor == null || skeleton == null)
return null;
@@ -72,7 +73,7 @@ public static BoneProcessingModes GetBoneMode(ActorMemory? actor, SkeletonVisual
return result.Directory;
}
- public void WriteToFile(ActorMemory actor, SkeletonVisual3d skeleton, HashSet? bones)
+ public void WriteToFile(ActorMemory actor, Skeleton skeleton, HashSet? bones)
{
if (actor.ModelObject == null || actor.ModelObject.Transform == null)
throw new Exception("No model in actor");
@@ -84,18 +85,18 @@ public void WriteToFile(ActorMemory actor, SkeletonVisual3d skeleton, HashSet();
+ this.Bones = new Dictionary(skeleton.Bones.Count, StringComparer.Ordinal);
- foreach (BoneVisual3d bone in skeleton.Bones.Values)
+ foreach (Core.Bone bone in skeleton.Bones.Values)
{
- if (bones != null && !bones.Contains(bone.BoneName))
+ if (bones != null && !bones.Contains(bone.Name))
continue;
- this.Bones.Add(bone.BoneName, new Bone(bone));
+ this.Bones[bone.Name] = new Bone(bone);
}
}
- public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet? bones, Mode mode, bool doFacialExpressionHack)
+ public void Apply(ActorMemory actor, Skeleton skeleton, HashSet? bones, Mode mode, bool doFacialExpressionHack)
{
if (actor == null)
throw new ArgumentNullException(nameof(actor));
@@ -132,12 +133,12 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
// Create a backup of all bone transforms.
// Note: Unposed bone transforms are parent-relative, while the restore bone position list is character-relative.
- Dictionary unposedBoneTransforms = new();
- Dictionary posedBonePos = new();
- List bonePosRestore = new();
+ Dictionary unposedBoneTransforms = new();
+ Dictionary posedBonePos = new();
+ List bonePosRestore = new();
foreach (var bone in skeleton.Bones.Values)
{
- if (GetBoneMode(actor, skeleton, bone.BoneName) == BoneProcessingModes.Ignore)
+ if (GetBoneMode(actor, skeleton, bone.Name) == BoneProcessingModes.Ignore)
continue;
unposedBoneTransforms[bone] = new Transform
@@ -154,7 +155,7 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
// We then just set the head back to where it should be afterwards.
// We can skip this if we actually intend to pose the head
// in the case of posing by selected bones or by body.
- BoneVisual3d? headBone = skeleton.GetBone("j_kao");
+ Core.Bone? headBone = skeleton.GetBone("j_kao");
Quaternion? originalHeadRotation = null;
Vector3? originalHeadPosition = null;
if (doFacialExpressionHack && bones != null && bones.Contains("j_kao"))
@@ -176,7 +177,7 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
if (GetBoneMode(actor, skeleton, boneName) != BoneProcessingModes.FullLoad)
continue;
- BoneVisual3d? bone = skeleton.GetBone(boneName);
+ Core.Bone? bone = skeleton.GetBone(boneName);
if (bone == null)
{
@@ -204,9 +205,14 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
// Record position changes if bones are posed without position to preserve positions.
foreach (var bone in bonePosRestore)
{
- if (!bone.TransformMemory.Binds.TryGetValue("Position", out PropertyBindInfo? bindInfo))
+ if (bone.TransformMemory == null)
{
- Log.Error($"Failed to find position bind for bone: {bone.BoneName}");
+ Log.Error($"Bone {bone.Name} has no transform memory");
+ }
+
+ if (!bone.TransformMemory!.Binds.TryGetValue("Position", out PropertyBindInfo? bindInfo))
+ {
+ Log.Error($"Failed to find position bind for bone: {bone.Name}");
continue;
}
@@ -230,7 +236,7 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
if (GetBoneMode(actor, skeleton, boneName) != BoneProcessingModes.FullLoad)
continue;
- BoneVisual3d? bone = skeleton.GetBone(boneName);
+ Core.Bone? bone = skeleton.GetBone(boneName);
if (bone == null)
{
@@ -268,32 +274,32 @@ public void Apply(ActorMemory actor, SkeletonVisual3d skeleton, HashSet?
{
headBone.Rotation = (Quaternion)originalHeadRotation;
headBone.Position = (Vector3)originalHeadPosition;
- headBone.WriteTransform(skeleton, true);
+ headBone.WriteTransform(true);
}
// If we are not loading the position of bones, restore the positions of all bones that were not explicitly written to.
if (!mode.HasFlag(Mode.Position))
{
- var sortedBones = SkeletonVisual3d.SortBonesByHierarchy(posedBonePos.Keys);
+ var sortedBones = Core.Bone.SortBonesByHierarchy(posedBonePos.Keys);
foreach (var bone in sortedBones)
{
bone.Position = posedBonePos[bone];
- bone.WriteTransform(skeleton, true);
+ bone.WriteTransform(true);
}
}
// Restore the transforms of any bones that we did not explicitly write to.
if (unposedBoneTransforms.Count > 0)
{
- var sortedBones = SkeletonVisual3d.SortBonesByHierarchy(unposedBoneTransforms.Keys);
+ var sortedBones = Core.Bone.SortBonesByHierarchy(unposedBoneTransforms.Keys);
foreach (var bone in sortedBones)
{
bone.Rotation = unposedBoneTransforms[bone].Rotation;
bone.Position = unposedBoneTransforms[bone].Position;
bone.Scale = unposedBoneTransforms[bone].Scale;
- bone.WriteTransform(skeleton, false);
+ bone.WriteTransform(false);
}
}
@@ -336,11 +342,14 @@ public Bone()
{
}
- public Bone(BoneVisual3d boneVisual)
+ public Bone(Core.Bone bone)
{
- this.Position = boneVisual.TransformMemory.Position;
- this.Rotation = boneVisual.TransformMemory.Rotation;
- this.Scale = boneVisual.TransformMemory.Scale;
+ if (bone.TransformMemory != null)
+ {
+ this.Position = bone.TransformMemory.Position;
+ this.Rotation = bone.TransformMemory.Rotation;
+ this.Scale = bone.TransformMemory.Scale;
+ }
}
public Vector3? Position { get; set; }
diff --git a/Anamnesis/Files/SceneFile.cs b/Anamnesis/Files/SceneFile.cs
index 53e985e7c..489de9361 100644
--- a/Anamnesis/Files/SceneFile.cs
+++ b/Anamnesis/Files/SceneFile.cs
@@ -3,7 +3,7 @@
namespace Anamnesis.Files;
-using Anamnesis.Actor;
+using Anamnesis.Core;
using Anamnesis.GUI.Dialogs;
using Anamnesis.Memory;
using System;
@@ -113,8 +113,7 @@ public async Task Apply(Mode mode)
rootActor.ModelObject!.Transform!.Rotation = rootRotation;
// Adjust for waist
- SkeletonVisual3d rootSkeleton = new();
- await rootSkeleton.SetActor(rootActor);
+ var rootSkeleton = new Skeleton(rootActor);
Vector3 rootOriginalWaist = rootActorEntry.Pose?.Bones?["n_hara"]?.Position ?? Vector3.Zero;
Vector3 rootCurrentWaist = rootSkeleton.GetBone("n_hara")?.Position ?? Vector3.Zero;
Vector3 rootAdjustedWaist = Vector3.Transform(rootCurrentWaist - rootOriginalWaist, rootRotation);
@@ -129,9 +128,7 @@ public async Task Apply(Mode mode)
if (actor == null)
continue;
- SkeletonVisual3d skeleton = new();
- await skeleton.SetActor(actor);
-
+ var skeleton = new Skeleton(actor);
ActorEntry entry = this.ActorEntries[name];
if (actor != rootActor && mode.HasFlag(Mode.RelativePosition))
@@ -165,7 +162,7 @@ public async Task Apply(Mode mode)
}
}
- public async Task WriteToFile()
+ public void WriteToFile()
{
this.Territory = TerritoryService.Instance.CurrentTerritoryId;
this.Weather = TerritoryService.Instance.CurrentWeatherId;
@@ -213,8 +210,7 @@ public async Task WriteToFile()
characterFile.WriteToFile(actor, CharacterFile.SaveModes.All);
PoseFile poseFile = new();
- SkeletonVisual3d skeleton = new();
- await skeleton.SetActor(actor);
+ var skeleton = new Skeleton(actor);
poseFile.WriteToFile(actor, skeleton, null);
ActorEntry entry = new();
@@ -243,7 +239,7 @@ public async Task WriteToFile()
private static ActorMemory? GetPinnedActor(string name)
{
- foreach (PinnedActor pinnedActor in TargetService.Instance.PinnedActors)
+ foreach (PinnedActor pinnedActor in TargetService.Instance.PinnedActors.ToList())
{
ActorMemory? actorMemory = pinnedActor.GetMemory();
diff --git a/Anamnesis/Install .NET Desktop Runtime.bat b/Anamnesis/Install .NET Desktop Runtime.bat
index 5daa250de..4dcdd3491 100644
--- a/Anamnesis/Install .NET Desktop Runtime.bat
+++ b/Anamnesis/Install .NET Desktop Runtime.bat
@@ -1,4 +1,4 @@
@echo off
title .NET Desktop Runtime Installer
-echo The Microsoft .NET Desktop Runtime will be installed. Please do not close the console until it has completed.
-winget install Microsoft.DotNet.DesktopRuntime.6 -e -v 6.0.6 --accept-package-agreements
+echo The Microsoft .NET Desktop Runtime will be installed. Please do not close the console until it has completed.
+winget install Microsoft.DotNet.DesktopRuntime.9 -e -v 9.0.1 --accept-package-agreements
diff --git a/Anamnesis/Languages/en.json b/Anamnesis/Languages/en.json
index 9c35845b9..36e99f89c 100644
--- a/Anamnesis/Languages/en.json
+++ b/Anamnesis/Languages/en.json
@@ -51,6 +51,7 @@
"Shortcut_AnamnesisPose": "Anamnesis Poses",
"Shortcut_AnamnesisCharacter": "Anamnesis Characters",
+ "Shortcut_AnamnesisCamera": "Anamnesis Camera Shots",
"Shortcut_AnamnesisScenes": "Anamnesis Scenes",
"Shortcut_BuiltInPose": "Built In Poses",
"Shortcut_CMToolPose": "CMTool Poses",
@@ -352,6 +353,11 @@
"Pose_Position": "Position",
"Pose_Linked": "Linked Bones",
"Pose_Linked_Tooltip": "Linked bones will be moved in relation to the current bone",
+ "Pose_SyncWithGameCamera": "Camera Sync",
+ "Pose_SyncWithGameCamera_Tooltip": "Synchronize with the in-game camera",
+
+ "Pose_SelectedBones_TooltipTrimmed": " and {0} more bone(s)",
+ "Pose_SelectedBones_MultiSelected": "Selected Bones: {0}",
"Pose_EnableRotation": "R",
"Pose_EnableRotationTooltip": "Enable editing rotation",
@@ -752,6 +758,8 @@
"SettingsPages_Input_Tooltip": "Input-related settings",
"SettingsPages_Connections": "Connections",
"SettingsPages_Connections_Tooltip": "Manage connections with external services",
+ "SettingsPages_BackupAndRecovery": "Backup & Recovery",
+ "SettingsPages_BackupAndRecovery_Tooltip": "Backup and recovery settings",
"Settings_Header": "Settings",
"Settings_InterfaceHeader": "Interface",
@@ -759,6 +767,7 @@
"Settings_FilesHeader": "Files",
"Settings_KeysHeader": "Hotkeys",
"Settings_Language": "Language",
+ "Settings_CrowdinTooltip": "Anamnesis is translated by the community via Crowdin. If you'd like to help, please visit the project page.",
"Settings_AlwaysOnTop": "Always on top",
"Settings_Overlay": "Mini mode",
"Settings_Translucency": "Window Translucency",
@@ -801,6 +810,15 @@
"Settings_EnableGameHotkeys_Tooltip": "Listen for hotkeys even when FFXIV has focus",
"Settings_EnableForwardKeys": "Forward keys",
"Settings_EnableForwardKeys_Tooltip": "Forward unused key events to FFXIV",
+ "Settings_3DSkeletonViewport": "3D Skeleton Viewport",
+ "Settings_ViewportPanSpeed": "Pan Speed",
+ "Settings_ViewportZoomSpeed": "Zoom Speed",
+ "Settings_ViewportRotationSpeed": "Rotation Speed",
+ "Settings_Backup": "Backup",
+ "Settings_EnableAutoSave": "Enable Auto Save",
+ "Settings_AutoSaveInterval": "Interval (minutes)",
+ "Settings_AutoSaveDirectory": "Auto Save Directory",
+ "Settings_AutoSaveSaveLast": "Keep Latest Entries",
"About_Title": "About Anamnesis",
"About_Version_Header": "Version",
"About_OpenSource_Header": "Proudly Open Source",
@@ -817,6 +835,9 @@
"DevBuild_Title": "Development Build",
"DevBuild_Body": "This is an experimental development build of Anamnesis and is not officially supported. These builds are likely to contain serious issues and should be avoided unless you know what you are doing.\n\nWould you like to continue using this build?\nSelecting no will offer you an update to the latest supported build.",
+ "DotNetPrompt_Title": "Install .NET 9",
+ "DotNetPrompt_Body": "In the upcoming version of Anamnesis, .NET 9 will be required.\nWould you like to install it now?\n\nClicking \"Yes\" will start the download process for you. Otherwise, you will be prompted again on next application launch.",
+
"Item_Unknown": "Unknown",
"Item_None": "None",
"Item_NoneDesc": "Nothing",
@@ -889,6 +910,6 @@
"Hotkey_Undo": "Undo",
"Hotkey_Redo": "Redo",
- "History_ChangeBone": "Change Bone: \"{0}\"",
- "History_ClearHistory": "Clear History",
+ "History_ChangeBone": "Change Bone: {0}",
+ "History_ClearHistory": "Clear History"
}
diff --git a/Anamnesis/Memory/ActorMemory.cs b/Anamnesis/Memory/ActorMemory.cs
index fbb8815da..0416e2bca 100644
--- a/Anamnesis/Memory/ActorMemory.cs
+++ b/Anamnesis/Memory/ActorMemory.cs
@@ -146,7 +146,7 @@ public bool IsMotionEnabled
}
[DependsOn(nameof(ObjectIndex), nameof(CharacterMode))]
- public bool CanAnimate => (this.CharacterMode == CharacterModes.Normal || this.CharacterMode == CharacterModes.AnimLock) || !ActorService.Instance.IsLocalOverworldPlayer(this.ObjectIndex);
+ public bool CanAnimate => (this.CharacterMode == CharacterModes.Normal || this.CharacterMode == CharacterModes.AnimLock) || !ActorService.IsLocalOverworldPlayer(this.ObjectIndex);
[DependsOn(nameof(CharacterMode))]
public bool IsAnimationOverridden => this.CharacterMode == CharacterModes.AnimLock;
@@ -242,9 +242,9 @@ private void HandlePropertyChanged(object? sender, PropertyChangedEventArgs e)
// Big hack to keep bone change history names short.
if (change.OriginBind.Memory.ParentBind?.Type == typeof(TransformMemory))
{
- change.Name = (PoseService.SelectedBoneName == null) ?
- LocalizationService.GetStringFormatted("History_ChangeBone", "??") :
- LocalizationService.GetStringFormatted("History_ChangeBone", PoseService.SelectedBoneName);
+ change.Name = (PoseService.SelectedBonesText == null) ?
+ LocalizationService.GetStringFormatted("History_ChangeBone", LocalizationService.GetString("Pose_OtherUnknown")) :
+ LocalizationService.GetStringFormatted("History_ChangeBone", PoseService.SelectedBonesText);
}
this.History.Record(change);
diff --git a/Anamnesis/Memory/ActorModelMemory.cs b/Anamnesis/Memory/ActorModelMemory.cs
index 1a99d3345..69fbe6bb2 100644
--- a/Anamnesis/Memory/ActorModelMemory.cs
+++ b/Anamnesis/Memory/ActorModelMemory.cs
@@ -72,7 +72,7 @@ public bool IsHuman
if (!Enum.IsDefined(typeof(DataPaths), this.DataPath))
return false;
- if (this.Parent is ActorMemory actor)
+ if (this.parent is ActorMemory actor)
{
return actor.ModelType == 0;
}
diff --git a/Anamnesis/Memory/Binds/BindFlags.cs b/Anamnesis/Memory/Binds/BindFlags.cs
index 7239c557b..69f1195c8 100644
--- a/Anamnesis/Memory/Binds/BindFlags.cs
+++ b/Anamnesis/Memory/Binds/BindFlags.cs
@@ -6,7 +6,7 @@ namespace Anamnesis.Memory;
///
/// Flags used to specify binding behaviors in memory operations.
///
-public enum BindFlags
+public enum BindFlags : byte
{
None = 0,
Pointer = 1,
diff --git a/Anamnesis/Memory/Binds/PropertyBindInfo.cs b/Anamnesis/Memory/Binds/PropertyBindInfo.cs
index e4ec86145..65fdd123c 100644
--- a/Anamnesis/Memory/Binds/PropertyBindInfo.cs
+++ b/Anamnesis/Memory/Binds/PropertyBindInfo.cs
@@ -6,7 +6,6 @@ namespace Anamnesis.Memory;
using System;
using System.Diagnostics;
using System.Reflection;
-using System.Runtime.CompilerServices;
///
/// Represents binding information for a property.
@@ -19,19 +18,11 @@ public class PropertyBindInfo : BindInfo
/// The bind attribute associated with the property.
public readonly BindAttribute Attribute;
- ///
- /// The property that provides the offset, if any.
- ///
- ///
- /// An offset property needs to provided if no offsets are provided in the bind attribute.
- ///
- public readonly PropertyInfo? OffsetProperty;
-
- /// Lock object for offset caching.
- private readonly object offsetLock = new();
+ /// Cached bind flags.
+ private readonly BindFlags flags;
/// Cached offsets for the property.
- private int[]? cachedOffsets;
+ private readonly int[] cachedOffsets;
///
/// Initializes a new instance of the class.
@@ -48,11 +39,15 @@ public PropertyBindInfo(MemoryBase memory, PropertyInfo property, BindAttribute
Debug.Assert(this.Property != null, "Property is null");
Debug.Assert(this.Attribute != null, "Attribute is null");
- if (attribute.OffsetPropertyName != null)
- {
- Type memoryType = memory.GetType();
- this.OffsetProperty = memoryType.GetProperty(attribute.OffsetPropertyName);
- }
+ this.cachedOffsets = this.GetOffsetsInternal();
+
+ this.flags = this.Attribute.Flags;
+
+ if (this.cachedOffsets == null || this.cachedOffsets.Length == 0)
+ throw new NullReferenceException("Cached offsets are not initialized.");
+
+ if (this.cachedOffsets.Length > 1 && !this.flags.HasFlag(BindFlags.Pointer))
+ throw new InvalidOperationException("Bind address has multiple offsets but is not a pointer. This is not supported.");
}
/// Gets the name of the bound property.
@@ -65,7 +60,7 @@ public PropertyBindInfo(MemoryBase memory, PropertyInfo property, BindAttribute
public override Type Type => this.Property.PropertyType;
/// Gets the bind flags.
- public override BindFlags Flags => this.Attribute.Flags;
+ public override BindFlags Flags => this.flags;
///
/// Gets the address of the bind.
@@ -80,45 +75,16 @@ public PropertyBindInfo(MemoryBase memory, PropertyInfo property, BindAttribute
///
public override IntPtr GetAddress()
{
- int[] offsets;
- lock (this.offsetLock)
- {
- // Get offsets if they are not cached
- this.cachedOffsets ??= this.GetOffsetsInternal();
- offsets = this.cachedOffsets;
- }
-
- if (offsets == null || offsets.Length == 0)
- throw new NullReferenceException("Cached offsets are not initialized.");
+ IntPtr bindAddress = this.Memory.Address + this.cachedOffsets[0];
- if (offsets.Length > 1 && !this.Flags.HasFlag(BindFlags.Pointer))
- throw new InvalidOperationException("Bind address has multiple offsets but is not a pointer. This is not supported.");
-
- IntPtr bindAddress = this.Memory.Address + offsets[0];
-
- if (typeof(MemoryBase).IsAssignableFrom(this.Type))
- {
- if (this.Flags.HasFlag(BindFlags.Pointer))
- {
- bindAddress = MemoryService.Read(bindAddress);
-
- for (int i = 1; i < offsets.Length; i++)
- {
- bindAddress += offsets[i];
- bindAddress = MemoryService.Read(bindAddress);
- }
- }
- }
- else if (this.Flags.HasFlag(BindFlags.Pointer))
+ if (this.flags.HasFlag(BindFlags.Pointer))
{
bindAddress = MemoryService.Read(bindAddress);
- }
- if (this.Flags.HasFlag(BindFlags.DontCacheOffsets))
- {
- lock (this.offsetLock)
+ for (int i = 1; i < this.cachedOffsets.Length; i++)
{
- this.cachedOffsets = null;
+ bindAddress += this.cachedOffsets[i];
+ bindAddress = MemoryService.Read(bindAddress);
}
}
@@ -133,23 +99,27 @@ public override IntPtr GetAddress()
/// Thrown when the offset type is unknown or when no offset(s)
/// and offset property are provided.
///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
private int[] GetOffsetsInternal()
{
if (this.Attribute.Offsets != null)
return this.Attribute.Offsets;
- if (this.OffsetProperty != null)
+ if (this.Attribute.OffsetPropertyName != null)
{
- object? offsetValue = this.OffsetProperty.GetValue(this.Memory);
+ Type memoryType = this.Memory.GetType();
+ PropertyInfo? offsetProperty = memoryType.GetProperty(this.Attribute.OffsetPropertyName);
+ if (offsetProperty != null)
+ {
+ object? offsetValue = offsetProperty.GetValue(this.Memory);
- if (offsetValue is int[] offsetInts)
- return offsetInts;
+ if (offsetValue is int[] offsetInts)
+ return offsetInts;
- if (offsetValue is int offset)
- return new int[] { offset };
+ if (offsetValue is int offset)
+ return new int[] { offset };
- throw new InvalidOperationException($"Unknown offset type: {offsetValue} bind: {this}");
+ throw new InvalidOperationException($"Unknown offset type: {offsetValue} bind: {this}");
+ }
}
throw new InvalidOperationException($"No offsets for bind: {this}");
diff --git a/Anamnesis/Memory/History.cs b/Anamnesis/Memory/History.cs
index ab4781460..5d60f042e 100644
--- a/Anamnesis/Memory/History.cs
+++ b/Anamnesis/Memory/History.cs
@@ -10,7 +10,6 @@ namespace Anamnesis.Memory;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
-using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
@@ -295,23 +294,30 @@ public void Redo()
/// Records a property change in the entry.
/// The property change to record.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Record(PropertyChange change)
{
+ if (!change.ShouldRecord())
+ return;
+
lock (this.changes)
{
// Keep only the latest change for each bind to minimize stored changes and recovery steps
- PropertyChange? existingChange = this.changes.FirstOrDefault(c => c.Path == change.Path);
- if (existingChange.HasValue)
+ for (int i = 0; i < this.changes.Count; i++)
{
- // Validate the existing change's OldValue
- if (IsValidOldValue(existingChange.Value.OldValue))
+ if (this.changes[i].Path == change.Path)
{
- // Transfer the old value of the existing change to the new change if it is valid
- change.OldValue = existingChange.Value.OldValue;
+ // Validate the existing change's OldValue
+ if (IsValidOldValue(this.changes[i].OldValue))
+ {
+ // Transfer the old value of the existing change to the new change if it is valid
+ change.OldValue = this.changes[i].OldValue;
+ }
+
+ // Remove the existing change
+ this.changes.RemoveAt(i);
+ break;
}
-
- // Remove the existing change
- this.changes.Remove(existingChange.Value);
}
// Add the latest change into the history entry's changes list
diff --git a/Anamnesis/Memory/InplaceFixedArrayMemory.cs b/Anamnesis/Memory/InplaceFixedArrayMemory.cs
index fc78dbf51..b0dc43784 100644
--- a/Anamnesis/Memory/InplaceFixedArrayMemory.cs
+++ b/Anamnesis/Memory/InplaceFixedArrayMemory.cs
@@ -158,7 +158,7 @@ public virtual void ReadArrayMemory()
///
/// Represents binding information for an array element.
///
- public class ArrayBindInfo : BindInfo
+ public sealed class ArrayBindInfo : BindInfo
{
/// Gets the index of the array element.
public readonly int Index;
diff --git a/Anamnesis/Memory/MemoryBase.cs b/Anamnesis/Memory/MemoryBase.cs
index 2a47d02d7..fcf91888f 100644
--- a/Anamnesis/Memory/MemoryBase.cs
+++ b/Anamnesis/Memory/MemoryBase.cs
@@ -7,6 +7,8 @@ namespace Anamnesis.Memory;
using PropertyChanged;
using Serilog;
using System;
+using System.Buffers;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
@@ -58,15 +60,18 @@ public abstract class MemoryBase : INotifyPropertyChanged, IDisposable
/// List of child memory objects.
protected readonly List Children = new();
- /// Set of delayed binds to be written later.
+ protected MemoryBase? parent;
+ protected BindInfo? parentBind;
+
+ /// Lock object for thread synchronization.
+ private readonly object lockObject = new();
+
+ /// A collection of delayed binds to be written later.
///
/// Stores binds that could not be written to memory immediately
/// due to ongoing memory reads.
///
- private readonly HashSet delayedBinds = new();
-
- /// Lock object for thread synchronization.
- private readonly object lockObject = new();
+ private ConcurrentQueue delayedBinds = new();
private int enableReading = 1;
private int enableWriting = 1;
@@ -113,11 +118,19 @@ public MemoryBase()
/// Gets or sets the parent memory object.
[DoNotNotify]
- public MemoryBase? Parent { get; set; }
+ public MemoryBase? Parent
+ {
+ get => this.parent;
+ set => this.parent = value;
+ }
/// Gets or sets the parent's bind information.
[DoNotNotify]
- public BindInfo? ParentBind { get; set; }
+ public BindInfo? ParentBind
+ {
+ get => this.parentBind;
+ set => this.parentBind = value;
+ }
/// Gets or sets a value indicating whether reading is enabled.
[DoNotNotify]
@@ -227,15 +240,14 @@ public virtual void Synchronize()
return;
// A sync is already in progress, cancel request.
- if (this.IsSynchronizing)
+ if (Interlocked.CompareExchange(ref this.isSynchronizing, 0, 0) == 1)
return;
// If synchronization is paused, cancel request.
- if (this.PauseSynchronization)
+ if (Interlocked.CompareExchange(ref this.pauseSynchronization, 0, 0) == 1)
return;
this.ClaimLocks();
- this.SetIsSynchronizing(true);
try
{
this.SynchronizeInternal();
@@ -246,7 +258,10 @@ public virtual void Synchronize()
}
finally
{
- this.SetIsSynchronizing(false);
+ // Write delayed binds to memory after synchronization.
+ // This ensures that writes are not blocked by ongoing reads.
+ this.WriteDelayedBindsInternal();
+
this.ReleaseLocks();
}
}
@@ -269,12 +284,12 @@ public virtual void WriteDelayedBinds()
}
finally
{
+ // Sync object immediately after writing to memory to
+ // ensure that the latest state is propagated to the application.
+ this.SynchronizeInternal();
+
this.ReleaseLocks();
}
-
- // Sync object immediately after writing to memory to
- // ensure that the latest state is propagated to the application.
- this.Synchronize();
}
/// Gets the address of a property by its name.
@@ -296,6 +311,7 @@ public IntPtr GetAddressOfProperty(string propertyName)
///
/// The lock claim covers the memory object and all its descendants.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
protected static void ClaimLocksOn(MemoryBase memory)
{
memory.ClaimLocks();
@@ -306,6 +322,7 @@ protected static void ClaimLocksOn(MemoryBase memory)
///
/// The lock release covers the memory object and all its descendants.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
protected static void ReleaseLocksOn(MemoryBase memory)
{
memory.ReleaseLocks();
@@ -322,11 +339,11 @@ protected virtual void Dispose(bool managedResources)
if (managedResources)
{
/* Dispose managed resources here */
- this.Parent?.Children.Remove(this);
+ this.parent?.Children.Remove(this);
this.Address = IntPtr.Zero;
- this.Parent = null;
- this.ParentBind = null;
+ this.parent = null;
+ this.parentBind = null;
for (int i = this.Children.Count - 1; i >= 0; i--)
{
@@ -380,23 +397,37 @@ protected void SetFrozen(string propertyName, bool freeze, object? value = null)
/// Determines if a bind can be read from.
/// The bind information.
/// True if the bind can be read; otherwise, false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
protected virtual bool CanRead(BindInfo bind)
{
- if (this.Parent != null)
- return this.EnableReading && this.Parent.CanRead(bind);
+ MemoryBase? current = this;
+ while (current != null)
+ {
+ if (Interlocked.CompareExchange(ref current.enableReading, 0, 0) != 1)
+ return false;
+
+ current = current.parent;
+ }
- return this.EnableReading;
+ return true;
}
/// Determines if a bind can be written to.
/// The bind information.
/// True if the bind can be written; otherwise, false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
protected virtual bool CanWrite(BindInfo bind)
{
- if (this.Parent != null)
- return this.EnableWriting && this.Parent.CanWrite(bind);
+ MemoryBase? current = this;
+ while (current != null)
+ {
+ if (Interlocked.CompareExchange(ref current.enableWriting, 0, 0) != 1)
+ return false;
+
+ current = current.parent;
+ }
- return this.EnableWriting;
+ return true;
}
///
@@ -428,15 +459,11 @@ protected virtual void OnSelfPropertyChanged(object? sender, PropertyChangedEven
if (bind.LastValue == currentValue)
return;
- if (!this.CanWrite(bind) || this.IsSynchronizing)
+ if (!this.CanWrite(bind) || Interlocked.CompareExchange(ref this.isSynchronizing, 0, 0) == 1)
{
// If this bind couldn't be written right now, add it to the delayed bind list
// to attempt to write later.
- lock (this.delayedBinds)
- {
- this.delayedBinds.Add(bind);
- }
-
+ this.delayedBinds.Enqueue(bind);
return;
}
@@ -444,6 +471,9 @@ protected virtual void OnSelfPropertyChanged(object? sender, PropertyChangedEven
this.ClaimLocks();
try
{
+ // Make sure that all delayed binds are written before this bind to preserve order
+ this.WriteDelayedBindsInternal();
+
this.WriteToMemory(bind);
}
finally
@@ -469,13 +499,19 @@ protected virtual void OnSelfPropertyChanged(object? sender, PropertyChangedEven
///
protected void ClaimLocks()
{
- // Monitor.Enter(this.lockObject);
- if (!Monitor.TryEnter(this.lockObject, 1000))
- throw new Exception("Failed to claim lock on memory object. Possible deadlock?");
+ Queue queue = new();
+ queue.Enqueue(this);
- foreach (MemoryBase child in this.Children)
+ while (queue.Count > 0)
{
- child.ClaimLocks();
+ MemoryBase current = queue.Dequeue();
+ if (!Monitor.TryEnter(current.lockObject, 5000))
+ throw new Exception("Failed to claim lock on memory object. Possible deadlock?");
+
+ foreach (MemoryBase child in current.Children)
+ {
+ queue.Enqueue(child);
+ }
}
}
@@ -484,12 +520,19 @@ protected void ClaimLocks()
///
protected void ReleaseLocks()
{
- foreach (MemoryBase child in this.Children)
+ Stack stack = new();
+ stack.Push(this);
+
+ while (stack.Count > 0)
{
- child.ReleaseLocks();
- }
+ MemoryBase current = stack.Pop();
+ Monitor.Exit(current.lockObject);
- Monitor.Exit(this.lockObject);
+ foreach (MemoryBase child in current.Children)
+ {
+ stack.Push(child);
+ }
+ }
}
///
@@ -498,11 +541,18 @@ protected void ReleaseLocks()
/// The synchronization state.
protected void SetIsSynchronizing(bool value)
{
- this.IsSynchronizing = value;
+ Stack stack = new();
+ stack.Push(this);
- foreach (MemoryBase child in this.Children)
+ while (stack.Count > 0)
{
- child.SetIsSynchronizing(value);
+ MemoryBase current = stack.Pop();
+ Interlocked.Exchange(ref current.isSynchronizing, value ? 1 : 0);
+
+ foreach (MemoryBase child in current.Children)
+ {
+ stack.Push(child);
+ }
}
}
@@ -554,10 +604,7 @@ protected virtual void ReadFromMemory(PropertyBindInfo bind)
// Invalidate all delayed binds if they were created prior to the memory address change
// Note: This is only relevant to MemoryBase objects as they are reference type objects
- lock (this.delayedBinds)
- {
- this.delayedBinds.RemoveWhere(b => b == bind);
- }
+ this.delayedBinds = new ConcurrentQueue(this.delayedBinds.Where(b => b != bind));
try
{
@@ -577,8 +624,8 @@ protected virtual void ReadFromMemory(PropertyBindInfo bind)
if (isNew)
{
- memory.Parent = this;
- memory.ParentBind = bind;
+ memory.parent = this;
+ memory.parentBind = bind;
memory.ClaimLocks();
this.Children.Add(memory);
}
@@ -619,10 +666,6 @@ protected virtual void ReadFromMemory(PropertyBindInfo bind)
// Notify the application of the property change
this.PropagatePropertyChanged(bind.Name, new PropertyChange(bind, oldValue, bind.LastValue, PropertyChange.Origins.Game));
}
- catch (Exception)
- {
- throw;
- }
finally
{
bind.IsReading = false;
@@ -651,10 +694,6 @@ protected virtual void WriteToMemory(PropertyBindInfo bind)
MemoryService.Write(bindAddress, val, $"memory: {this} bind: {bind} changed");
bind.LastValue = val;
}
- catch (Exception)
- {
- throw;
- }
finally
{
bind.IsWriting = false;
@@ -672,51 +711,64 @@ private void SynchronizeInternal()
if (this.Address == IntPtr.Zero)
return;
- if (this is IArrayMemory arrayMemory)
- {
- arrayMemory.ReadArrayMemory();
- }
- else
+ try
{
- // Go through all binds that belong to this memory object.
- foreach (PropertyBindInfo bind in this.Binds.Values)
+ this.SetIsSynchronizing(true);
+
+ Stack stack = new();
+ stack.Push(this);
+
+ while (stack.Count > 0)
{
- // Skip if we can't read this bind right now.
- if (!this.CanRead(bind))
- continue;
+ MemoryBase current = stack.Pop();
- try
+ if (current is IArrayMemory arrayMemory)
{
- this.ReadFromMemory(bind);
+ arrayMemory.ReadArrayMemory();
}
- catch (Exception ex)
+ else
{
- throw new Exception($"Failed to read {this.GetType()} - {bind.Name}", ex);
+ // Go through all binds that belong to this memory object.
+ foreach (PropertyBindInfo bind in current.Binds.Values)
+ {
+ // Skip if we can't read this bind right now.
+ if (!current.CanRead(bind))
+ continue;
+
+ try
+ {
+ current.ReadFromMemory(bind);
+ }
+ catch (Exception ex)
+ {
+ throw new Exception($"Failed to read {current.GetType()} - {bind.Name}", ex);
+ }
+ }
}
- }
- }
- // Go through all child memory objects.
- foreach (MemoryBase child in this.Children)
- {
- // If the child has no parent bind, then it is not a bind, and should not be refreshed.
- if (child.ParentBind == null)
- continue;
+ // Go through all child memory objects.
+ foreach (MemoryBase child in current.Children)
+ {
+ // If the child has no parent bind, then it is not a bind, and should not be refreshed.
+ if (child.parentBind == null)
+ continue;
- // If the child is a bind, and the parent bind is not readable, then skip it.
- if (!this.CanRead(child.ParentBind))
- continue;
+ // If the child is a bind, and the parent bind is not readable, then skip it.
+ if (!current.CanRead(child.parentBind))
+ continue;
- // If the child is a bind but only applies in gpose and we are not in gpose, then skip it.
- if (child.ParentBind.Flags.HasFlag(BindFlags.OnlyInGPose) && !GposeService.Instance.IsGpose)
- continue;
+ // If the child is a bind but only applies in gpose and we are not in gpose, then skip it.
+ if (child.parentBind.Flags.HasFlag(BindFlags.OnlyInGPose) && !GposeService.Instance.IsGpose)
+ continue;
- child.SynchronizeInternal();
+ stack.Push(child);
+ }
+ }
+ }
+ finally
+ {
+ this.SetIsSynchronizing(false);
}
-
- // Write delayed binds to memory after synchronization.
- // This ensures that writes are not blocked by ongoing reads.
- this.WriteDelayedBindsInternal();
}
///
@@ -727,47 +779,63 @@ private void SynchronizeInternal()
///
private void WriteDelayedBindsInternal()
{
- var remainingBinds = new List();
- lock (this.delayedBinds)
+ Stack stack = new();
+ stack.Push(this);
+
+ while (stack.Count > 0)
{
- foreach (PropertyBindInfo bind in this.delayedBinds.Cast())
+ MemoryBase current = stack.Pop();
+
+ var remainingBinds = ArrayPool.Shared.Rent(current.delayedBinds.Count);
+ int remainingCount = 0;
+
+ try
{
- // If we still cant write this bind, just skip it.
- if (!this.CanWrite(bind))
+ while (current.delayedBinds.TryDequeue(out BindInfo? bind))
{
- remainingBinds.Add(bind);
- continue;
- }
+ if (bind is not PropertyBindInfo propertyBind)
+ continue;
+
+ // If we still can't write this bind, just skip it.
+ if (!current.CanWrite(propertyBind))
+ {
+ remainingBinds[remainingCount++] = propertyBind;
+ continue;
+ }
+
+ // Store the old value before it is overwritten by the new value in WriteToMemory
+ object? oldValue = propertyBind.LastValue;
- // Store the old value before it is overwritten by the new value in WriteToMemory
- object? oldValue = bind.LastValue;
+ current.WriteToMemory(propertyBind);
- this.WriteToMemory(bind);
+ // Propagate property changed event to the rest of the application after writing to memory
+ var origin = PropertyChange.Origins.User;
+ if (!propertyBind.Flags.HasFlag(BindFlags.DontRecordHistory) && HistoryService.Instance.IsRestoring)
+ {
+ origin = PropertyChange.Origins.History;
+ }
- // Propagte property changed event to the rest of the application after writing to memory
- var origin = PropertyChange.Origins.User;
- if (!bind.Flags.HasFlag(BindFlags.DontRecordHistory) && HistoryService.Instance.IsRestoring)
+ var change = new PropertyChange(propertyBind, oldValue, propertyBind.LastValue, origin);
+ current.PropagatePropertyChanged(propertyBind.Name, change);
+ }
+ }
+ finally
+ {
+ for (int i = 0; i < remainingCount; i++)
{
- origin = PropertyChange.Origins.History;
+ current.delayedBinds.Enqueue(remainingBinds[i]);
}
- var change = new PropertyChange(bind, oldValue, bind.LastValue, origin);
- this.PropagatePropertyChanged(bind.Name, change);
+ ArrayPool.Shared.Return(remainingBinds, clearArray: true);
+
+ if (!current.delayedBinds.IsEmpty)
+ Log.Warning($"Failed to write all delayed binds, remaining: {current.delayedBinds.Count}");
}
- this.delayedBinds.Clear();
- foreach (var bind in remainingBinds)
+ foreach (var child in current.Children)
{
- this.delayedBinds.Add(bind);
+ stack.Push(child);
}
-
- if (this.delayedBinds.Count > 0)
- Log.Warning("Failed to write all delayed binds, remaining: " + this.delayedBinds.Count);
- }
-
- foreach (MemoryBase? child in this.Children)
- {
- child.WriteDelayedBindsInternal();
}
}
@@ -781,6 +849,7 @@ private void WriteDelayedBindsInternal()
/// that originate from the game do not get processed via the OnSelfPropertyChanged, which is
/// intended to be called only for user-initiated changes (incl. history).
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void SetValueWithoutNotification(PropertyBindInfo bind, object? value)
{
this.suppressPropNotifications.Value = true;
@@ -796,15 +865,19 @@ private void SetValueWithoutNotification(PropertyBindInfo bind, object? value)
/// Thrown when the parent is not null but the parent bind is null.
private void PropagatePropertyChanged(string propertyName, PropertyChange context)
{
- this.PropertyChanged?.Invoke(this, new MemObjPropertyChangedEventArgs(propertyName, context));
+ MemoryBase? current = this;
+ while (current != null)
+ {
+ current.PropertyChanged?.Invoke(current, new MemObjPropertyChangedEventArgs(propertyName, context));
- if (this.Parent == null)
- return;
+ if (current.parent == null)
+ break;
- if (this.ParentBind == null)
- throw new Exception("Parent was not null, but parent bind was!");
+ if (current.parentBind == null)
+ throw new Exception("Parent was not null, but parent bind was!");
- context.AddPath(this.ParentBind);
- this.Parent.PropagatePropertyChanged(propertyName, context);
+ context.AddPath(current.parentBind);
+ current = current.parent;
+ }
}
}
diff --git a/Anamnesis/Memory/PropertyChange.cs b/Anamnesis/Memory/PropertyChange.cs
index fc0f59c64..9f67289dd 100644
--- a/Anamnesis/Memory/PropertyChange.cs
+++ b/Anamnesis/Memory/PropertyChange.cs
@@ -5,6 +5,7 @@ namespace Anamnesis.Memory;
using System.Collections.Generic;
using System.Linq;
+using System.Runtime.CompilerServices;
///
/// Represents a change in a property, including its old and new values, the
@@ -51,7 +52,7 @@ public struct PropertyChange
/// The origin of the property change.
public PropertyChange(BindInfo bind, object? oldValue, object? newValue, Origins origin)
{
- this.BindPath = new() { bind };
+ this.BindPath = new List(1) { bind };
this.path = bind.Path;
this.OldValue = oldValue;
this.NewValue = newValue;
@@ -65,7 +66,7 @@ public PropertyChange(BindInfo bind, object? oldValue, object? newValue, Origins
/// The other instance to copy.
public PropertyChange(PropertyChange other)
{
- this.BindPath = new();
+ this.BindPath = new List(other.BindPath.Count);
this.BindPath.AddRange(other.BindPath);
this.OldValue = other.OldValue;
this.NewValue = other.NewValue;
@@ -95,6 +96,7 @@ public enum Origins
/// Determines whether the property change should be recorded.
///
/// True if the change should be recorded; otherwise, false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly bool ShouldRecord()
{
// Don't record changes that originate anywhere other than the user interface.
@@ -114,10 +116,11 @@ public readonly bool ShouldRecord()
/// Adds a bind to the property change, appended to the end of the bind path.
///
/// The bind information to add.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AddPath(BindInfo bind)
{
this.BindPath.Add(bind);
- this.path += bind.Path;
+ this.path = bind.Path + this.path;
}
///
@@ -126,6 +129,7 @@ public void AddPath(BindInfo bind)
///
/// Use this method only if the change's bind path is not already configured.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ConfigureBindPath()
{
var parentBind = this.BindPath[0].Memory.ParentBind;
diff --git a/Anamnesis/Memory/WeaponMemory.cs b/Anamnesis/Memory/WeaponMemory.cs
index 9d017394f..9eccc1846 100644
--- a/Anamnesis/Memory/WeaponMemory.cs
+++ b/Anamnesis/Memory/WeaponMemory.cs
@@ -63,7 +63,7 @@ public void Clear(bool isPlayer)
{
bool useEmperorsFists = true;
- if (this.Parent is ActorMemory actor)
+ if (this.parent is ActorMemory actor)
{
if (actor.OffHand == this && actor.MainHand != null)
{
diff --git a/Anamnesis/ServiceManager.cs b/Anamnesis/ServiceManager.cs
index 6740e9d5e..ae807b9ff 100644
--- a/Anamnesis/ServiceManager.cs
+++ b/Anamnesis/ServiceManager.cs
@@ -3,18 +3,18 @@
namespace Anamnesis.Services;
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
+using Anamnesis.Actor;
using Anamnesis.Core.Memory;
using Anamnesis.Files;
using Anamnesis.Memory;
-using Anamnesis.Actor;
using Anamnesis.Serialization;
using Anamnesis.TexTools;
using Serilog;
-using XivToolsWpf;
+using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Threading.Tasks;
+using XivToolsWpf;
public class ServiceManager
{
@@ -72,6 +72,7 @@ public async Task InitializeServices()
await Add();
await Add();
await Add();
+ await Add();
IsInitialized = true;
diff --git a/Anamnesis/Services/ActorService.cs b/Anamnesis/Services/ActorService.cs
index cdfcb5a2a..eae99208d 100644
--- a/Anamnesis/Services/ActorService.cs
+++ b/Anamnesis/Services/ActorService.cs
@@ -6,12 +6,18 @@ namespace Anamnesis;
using Anamnesis.Actor.Refresh;
using Anamnesis.Core.Memory;
using Anamnesis.Memory;
+using Anamnesis.Services;
using PropertyChanged;
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Threading;
using System.Threading.Tasks;
+/// Service for managing and refreshing actors.
[AddINotifyPropertyChangedInterface]
public class ActorService : ServiceBase
{
@@ -23,6 +29,7 @@ public class ActorService : ServiceBase
private const int GPosePlayerIndex = 201;
private readonly IntPtr[] actorTable = new IntPtr[ActorTableSize];
+ private readonly ReaderWriterLockSlim actorTableLock = new();
private readonly List actorRefreshers = new()
{
@@ -31,8 +38,57 @@ public class ActorService : ServiceBase
new AnamnesisActorRefresher(),
};
- public ReadOnlyCollection ActorTable => Array.AsReadOnly(this.actorTable);
+ /// Gets the actor table as a read-only collection.
+ public ReadOnlyCollection ActorTable
+ {
+ get
+ {
+ this.actorTableLock.EnterReadLock();
+ try
+ {
+ return Array.AsReadOnly(this.actorTable);
+ }
+ finally
+ {
+ this.actorTableLock.ExitReadLock();
+ }
+ }
+ }
+ /// Determines if the actor is in GPose.
+ /// The index of the actor.
+ /// True if the actor is in GPose, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsGPoseActor(int objectIndex) => objectIndex >= GPoseIndexStart && objectIndex < GPoseIndexEnd;
+
+ /// Determines if the actor is in the overworld.
+ /// The index of the actor.
+ /// True if the actor is in the overworld, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsOverworldActor(int objectIndex) => !IsGPoseActor(objectIndex);
+
+ /// Determines if the actor is the local overworld player.
+ /// The index of the actor.
+ /// True if the actor is the local overworld player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsLocalOverworldPlayer(int objectIndex) => objectIndex == OverworldPlayerIndex;
+
+ /// Determines if the actor is the local GPose player.
+ /// The index of the actor.
+ /// True if the actor is the local GPose player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsLocalGPosePlayer(int objectIndex) => objectIndex == GPosePlayerIndex;
+
+ /// Determines if the actor is the local player.
+ /// The index of the actor.
+ /// True if the actor is the local player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsLocalPlayer(int objectIndex) => IsLocalOverworldPlayer(objectIndex) || IsLocalGPosePlayer(objectIndex);
+
+ /// Determines if the actor can be refreshed.
+ /// The actor to check.
+ /// True if the actor can be refreshed, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool CanRefreshActor(ActorMemory actor)
{
if (!actor.IsValid)
@@ -47,6 +103,9 @@ public bool CanRefreshActor(ActorMemory actor)
return false;
}
+ /// Refreshes the specified actor.
+ /// The actor to refresh.
+ /// True if the actor was refreshed, otherwise false.
public async Task RefreshActor(ActorMemory actor)
{
if (this.CanRefreshActor(actor))
@@ -65,6 +124,11 @@ public async Task RefreshActor(ActorMemory actor)
return false;
}
+ /// Gets the index of the actor in the actor table.
+ /// The pointer to the actor.
+ /// Whether to refresh the actor table.
+ /// The index of the actor in the actor table, or -1 if not found.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetActorTableIndex(IntPtr pointer, bool refresh = false)
{
if (pointer == IntPtr.Zero)
@@ -73,18 +137,38 @@ public int GetActorTableIndex(IntPtr pointer, bool refresh = false)
if (refresh)
this.UpdateActorTable();
- return Array.IndexOf(this.actorTable, pointer);
+ this.actorTableLock.EnterReadLock();
+ try
+ {
+ return Array.IndexOf(this.actorTable, pointer);
+ }
+ finally
+ {
+ this.actorTableLock.ExitReadLock();
+ }
}
+ /// Determines if the actor is in the actor table.
+ /// The pointer to the actor.
+ /// Whether to refresh the actor table.
+ /// True if the actor is in the actor table, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsActorInTable(IntPtr ptr, bool refresh = false)
{
return this.GetActorTableIndex(ptr, refresh) != -1;
}
+ /// Determines if the actor is in the actor table.
+ /// The memory of the actor.
+ /// Whether to refresh the actor table.
+ /// True if the actor is in the actor table, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsActorInTable(MemoryBase memory, bool refresh = false) => this.IsActorInTable(memory.Address, refresh);
- public bool IsGPoseActor(int objectIndex) => objectIndex >= GPoseIndexStart && objectIndex < GPoseIndexEnd;
-
+ /// Determines if the actor is in GPose.
+ /// The address of the actor.
+ /// True if the actor is in GPose, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsGPoseActor(IntPtr actorAddress)
{
int objectIndex = this.GetActorTableIndex(actorAddress);
@@ -92,13 +176,19 @@ public bool IsGPoseActor(IntPtr actorAddress)
if (objectIndex == -1)
return false;
- return this.IsGPoseActor(objectIndex);
+ return IsGPoseActor(objectIndex);
}
- public bool IsOverworldActor(int objectIndex) => !this.IsGPoseActor(objectIndex);
+ /// Determines if the actor is in the overworld.
+ /// The address of the actor.
+ /// True if the actor is in the overworld, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsOverworldActor(IntPtr actorAddress) => !this.IsGPoseActor(actorAddress);
- public bool IsLocalOverworldPlayer(int objectIndex) => objectIndex == OverworldPlayerIndex;
+ /// Determines if the actor is the local overworld player.
+ /// The address of the actor.
+ /// True if the actor is the local overworld player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsLocalOverworldPlayer(IntPtr actorAddress)
{
int objectIndex = this.GetActorTableIndex(actorAddress);
@@ -106,10 +196,13 @@ public bool IsLocalOverworldPlayer(IntPtr actorAddress)
if (objectIndex == -1)
return false;
- return this.IsLocalOverworldPlayer(objectIndex);
+ return IsLocalOverworldPlayer(objectIndex);
}
- public bool IsLocalGPosePlayer(int objectIndex) => objectIndex == GPosePlayerIndex;
+ /// Determines if the actor is the local GPose player.
+ /// The address of the actor.
+ /// True if the actor is the local GPose player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsLocalGPosePlayer(IntPtr actorAddress)
{
int objectIndex = this.GetActorTableIndex(actorAddress);
@@ -117,12 +210,18 @@ public bool IsLocalGPosePlayer(IntPtr actorAddress)
if (objectIndex == -1)
return false;
- return this.IsLocalGPosePlayer(objectIndex);
+ return IsLocalGPosePlayer(objectIndex);
}
- public bool IsLocalPlayer(int objectIndex) => this.IsLocalOverworldPlayer(objectIndex) || this.IsLocalGPosePlayer(objectIndex);
+ /// Determines if the actor is the local player.
+ /// The address of the actor.
+ /// True if the actor is the local player, otherwise false.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsLocalPlayer(IntPtr actorAddress) => this.IsLocalOverworldPlayer(actorAddress) || this.IsLocalGPosePlayer(actorAddress);
+ /// Gets all actors from actor table.
+ /// Whether to refresh the actor table.
+ /// A list of all actors.
public List GetAllActors(bool refresh = false)
{
if (refresh)
@@ -130,36 +229,47 @@ public List GetAllActors(bool refresh = false)
List results = new();
- foreach (var ptr in this.actorTable)
+ this.actorTableLock.EnterReadLock();
+ try
{
- if (ptr == IntPtr.Zero)
- continue;
-
- try
+ foreach (var ptr in this.actorTable)
{
- ActorBasicMemory actor = new();
- actor.SetAddress(ptr);
- results.Add(actor);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, $"Failed to create Actor Basic View Model for address: {ptr}");
+ if (ptr == IntPtr.Zero)
+ continue;
+
+ try
+ {
+ ActorBasicMemory actor = new();
+ actor.SetAddress(ptr);
+ results.Add(actor);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, $"Failed to create Actor Basic View Model for address: {ptr}");
+ }
}
}
+ finally
+ {
+ this.actorTableLock.ExitReadLock();
+ }
return results;
}
+ /// Forces a refresh of the actor table.
public void ForceRefresh()
{
this.UpdateActorTable();
}
+ ///
public override async Task Initialize()
{
await base.Initialize();
}
+ ///
public override Task Start()
{
this.UpdateActorTable();
@@ -168,11 +278,13 @@ public override Task Start()
return base.Start();
}
+ ///
public override async Task Shutdown()
{
await base.Shutdown();
}
+ /// Periodically refreshes the actor table.
private async Task TickTask()
{
while (this.IsAlive)
@@ -183,17 +295,48 @@ private async Task TickTask()
}
}
+ /// Updates the actor table by reading from memory.
private void UpdateActorTable()
{
- lock (this.actorTable)
+ if (!GameService.Ready)
+ return;
+
+ int tableSizeInBytes = ActorTableSize * IntPtr.Size;
+ byte[] buffer = ArrayPool.Shared.Rent(tableSizeInBytes);
+
+ try
{
- for (int i = 0; i < ActorTableSize; i++)
+ if (!MemoryService.Read(AddressService.ActorTable, buffer.AsSpan(0, tableSizeInBytes)))
+ throw new Exception("Failed to read actor table from memory.");
+
+ bool hasChanged = false;
+
+ this.actorTableLock.EnterWriteLock();
+ try
+ {
+ Span currentSpan = this.actorTable.AsSpan();
+ Span newSpan = MemoryMarshal.Cast(buffer.AsSpan(0, tableSizeInBytes));
+
+ if (!currentSpan.SequenceEqual(newSpan))
+ {
+ hasChanged = true;
+ newSpan.CopyTo(currentSpan);
+ }
+ }
+ finally
{
- IntPtr ptr = MemoryService.ReadPtr(AddressService.ActorTable + (i * 8));
- this.actorTable[i] = ptr;
+ this.actorTableLock.ExitWriteLock();
}
- }
- this.RaisePropertyChanged(nameof(this.ActorTable));
+ if (hasChanged)
+ {
+ this.RaisePropertyChanged(nameof(this.ActorTable));
+ Log.Verbose("[ActorService] Actor table updated.");
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
}
}
diff --git a/Anamnesis/Services/AnimationService.cs b/Anamnesis/Services/AnimationService.cs
index b2ff243cf..c702afd00 100644
--- a/Anamnesis/Services/AnimationService.cs
+++ b/Anamnesis/Services/AnimationService.cs
@@ -120,7 +120,7 @@ public void PausePinnedActors()
if (this.SpeedControlEnabled)
{
- var actors = TargetService.Instance.PinnedActors;
+ var actors = TargetService.Instance.PinnedActors.ToList();
foreach (var actor in actors)
{
if (actor.IsValid && actor.Memory != null && actor.Memory.Address != IntPtr.Zero && actor.Memory.IsValid)
diff --git a/Anamnesis/Services/AutoSaveService.cs b/Anamnesis/Services/AutoSaveService.cs
new file mode 100644
index 000000000..c9ec7e160
--- /dev/null
+++ b/Anamnesis/Services/AutoSaveService.cs
@@ -0,0 +1,202 @@
+// © Anamnesis.
+// Licensed under the MIT license.
+
+namespace Anamnesis;
+
+using Anamnesis.Actor;
+using Anamnesis.Core;
+using Anamnesis.Files;
+using Anamnesis.Memory;
+using Anamnesis.Services;
+using PropertyChanged;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+///
+/// A auto-save service that performs a set of backup actions every minutes.
+///
+[AddINotifyPropertyChangedInterface]
+public class AutoSaveService : ServiceBase
+{
+ private const int MinuteToMilliseconds = 60 * 1000;
+ private static readonly int MaxStartAttempts = 10; // Maximum number of attempts to start the service
+ private CancellationTokenSource? cancellationTokenSource;
+
+ /// Starts the auto-save service.
+ /// A representing the asynchronous operation.
+ public override async Task Start()
+ {
+ // Wait for Settings service to be initialized
+ var servicesToCheck = new List { SettingsService.Instance, FileService.Instance, TargetService.Instance, PoseService.Instance };
+ await EnsureServicesAreAlive(servicesToCheck, MaxStartAttempts, 1000);
+
+ this.RestartUpdateTask();
+ await base.Start();
+ }
+
+ /// Shuts down the auto-save service.
+ /// A representing the asynchronous operation.
+ public override Task Shutdown()
+ {
+ this.cancellationTokenSource?.Cancel();
+ return base.Shutdown();
+ }
+
+ /// (Re)starts the update task with the (new) wake-up time.
+ public void RestartUpdateTask()
+ {
+ this.cancellationTokenSource?.Cancel();
+
+ if (!SettingsService.Current.EnableAutoSave)
+ {
+ Log.Verbose("Auto-save is disabled. Update task will not be started.");
+ return;
+ }
+
+ this.cancellationTokenSource = new CancellationTokenSource();
+ _ = Task.Run(() => Update(this.cancellationTokenSource.Token));
+ }
+
+ ///
+ /// The update task that will be executed every minutes.
+ ///
+ /// A to cancel the task.
+ /// A representing the asynchronous operation.
+ private static async Task Update(CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(SettingsService.Current.AutoSaveIntervalMinutes * MinuteToMilliseconds, cancellationToken);
+ PerformAutoSave();
+ }
+ catch (TaskCanceledException)
+ {
+ // Task was canceled, exit the loop
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Performs the auto-save action and manages the save files.
+ ///
+ private static void PerformAutoSave()
+ {
+ // Generate a new directory name with the current date and time
+ char[] invalidPathChars = Path.GetInvalidPathChars();
+ string baseDir = FileService.ParseToFilePath(SettingsService.Current.DefaultAutoSaveDirectory);
+ string dirName = $"Autosave_{DateTime.Now:yyyyMMdd_HHmmss}";
+ string dirPath = Path.Combine(baseDir, dirName);
+
+ try
+ {
+ // Perform the actual save operation (implement the actual save logic here)
+ Log.Verbose($"Performing auto-save to directory: {dirPath}");
+
+ // Save all pinned actors' appearance/equipment
+ string charDirPath = Path.Combine(dirPath, "Characters");
+ if (!Directory.Exists(charDirPath))
+ Directory.CreateDirectory(charDirPath);
+
+ foreach (var pinnedActor in TargetService.Instance.PinnedActors.ToList())
+ {
+ var actor = pinnedActor.Memory;
+ if (actor == null)
+ continue;
+
+ CharacterFile file = new();
+ string fullFilePath = Path.Combine(charDirPath, $"{actor.Name}{file.FileExtension}");
+ if (fullFilePath.Any(c => invalidPathChars.Contains(c)))
+ {
+ Log.Error($"Invalid character file path: {fullFilePath}");
+ break;
+ }
+
+ file.WriteToFile(actor, CharacterFile.SaveModes.All);
+ using FileStream stream = new(fullFilePath, FileMode.Create);
+ file.Serialize(stream);
+ }
+
+ if (GposeService.Instance.IsGpose)
+ {
+ ActorMemory? actorMemory = null;
+ ActorBasicMemory? targetActor = TargetService.Instance.PlayerTarget;
+ if (targetActor != null && targetActor.IsValid)
+ {
+ actorMemory = new ActorMemory();
+ actorMemory.SetAddress(targetActor.Address);
+ }
+
+ if (actorMemory != null)
+ {
+ // Save the current camera configuration
+ string camShotsDir = Path.Combine(dirPath, "CameraShots");
+ if (!Directory.Exists(camShotsDir))
+ Directory.CreateDirectory(camShotsDir);
+
+ CameraShotFile file = new();
+ string fullFilePath = Path.Combine(camShotsDir, $"Camera{file.FileExtension}");
+ if (fullFilePath.Any(c => invalidPathChars.Contains(c)))
+ Log.Error($"Invalid camera shot file path: {fullFilePath}");
+
+ file.WriteToFile(CameraService.Instance, actorMemory);
+ using FileStream stream = new(fullFilePath, FileMode.Create);
+ file.Serialize(stream);
+ }
+
+ // Save all pinned actors' pose configuration
+ // Do not save poses if the pose service is disabled
+ if (!PoseService.Instance.IsEnabled)
+ return;
+
+ string posesDir = Path.Combine(dirPath, "Poses");
+ if (!Directory.Exists(posesDir))
+ Directory.CreateDirectory(posesDir);
+
+ foreach (var pinnedActor in TargetService.Instance.PinnedActors.ToList())
+ {
+ var actor = pinnedActor.Memory;
+ if (actor == null)
+ continue;
+
+ var skeleton = new Skeleton(actor);
+
+ PoseFile file = new();
+ string fullFilePath = Path.Combine(posesDir, $"{actor.Name}{file.FileExtension}");
+ if (fullFilePath.Any(c => invalidPathChars.Contains(c)))
+ {
+ Log.Error($"Invalid pose file path: {fullFilePath}");
+ break;
+ }
+
+ file.WriteToFile(actor, skeleton, null);
+ using FileStream stream = new(fullFilePath, FileMode.Create);
+ file.Serialize(stream);
+ }
+ }
+ }
+ finally
+ {
+ // If the number of auto-save directories exceeds the maximum, delete the oldest
+ var autoSaveDirectories = Directory.GetDirectories(baseDir, "Autosave_*");
+ if (autoSaveDirectories.Length > SettingsService.Current.AutoSaveFileCount)
+ {
+ // Order directories by creation time
+ Array.Sort(autoSaveDirectories, (x, y) => Directory.GetCreationTime(x).CompareTo(Directory.GetCreationTime(y)));
+
+ // Delete the oldest directories until the count is within the limit
+ int directoriesToDelete = autoSaveDirectories.Length - SettingsService.Current.AutoSaveFileCount;
+ for (int i = 0; i < directoriesToDelete; i++)
+ {
+ Directory.Delete(autoSaveDirectories[i], true);
+ }
+ }
+ }
+ }
+}
diff --git a/Anamnesis/Services/LogService.cs b/Anamnesis/Services/LogService.cs
index ffc3d5dbf..693ef6030 100644
--- a/Anamnesis/Services/LogService.cs
+++ b/Anamnesis/Services/LogService.cs
@@ -3,17 +3,17 @@
namespace Anamnesis.Services;
+using Anamnesis.Files;
+using Anamnesis.Windows;
+using Serilog;
+using Serilog.Core;
+using Serilog.Events;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
-using Anamnesis.Files;
-using Anamnesis.Windows;
-using Serilog;
-using Serilog.Core;
-using Serilog.Events;
public class LogService : IService
{
@@ -42,6 +42,8 @@ public static LogService Instance
}
}
+ public bool IsAlive => true;
+
public bool VerboseLogging
{
get => LogLevel.MinimumLevel == LogEventLevel.Verbose;
diff --git a/Anamnesis/Services/ServiceBase{T}.cs b/Anamnesis/Services/ServiceBase{T}.cs
index f9223bf28..3d4c1198c 100644
--- a/Anamnesis/Services/ServiceBase{T}.cs
+++ b/Anamnesis/Services/ServiceBase{T}.cs
@@ -3,11 +3,13 @@
namespace Anamnesis;
+using PropertyChanged;
+using Serilog;
using System;
+using System.Collections.Generic;
using System.ComponentModel;
+using System.Linq;
using System.Threading.Tasks;
-using PropertyChanged;
-using Serilog;
[AddINotifyPropertyChangedInterface]
public abstract class ServiceBase : IService, INotifyPropertyChanged
@@ -56,6 +58,34 @@ public virtual Task Start()
return Task.CompletedTask;
}
+ ///
+ /// Checks if the specified services are alive within a given number of attempts.
+ ///
+ /// The services to check.
+ /// The maximum number of attempts.
+ /// The delay between attempts in milliseconds.
+ /// A representing the asynchronous operation.
+ /// Thrown if the services are not alive within the expected time.
+ protected static async Task EnsureServicesAreAlive(IEnumerable services, int maxAttempts, int delay)
+ {
+ int attempts = 0;
+ while (attempts < maxAttempts)
+ {
+ var notAliveServices = services.Where(service => !service.IsAlive).ToList();
+ if (!notAliveServices.Any())
+ return;
+
+ await Task.Delay(delay);
+ attempts++;
+ }
+
+ var serviceNames = services.Where(service => !service.IsAlive)
+ .Select(service => service.GetType().Name)
+ .ToList();
+
+ throw new TimeoutException($"The following services failed to start within the expected time: {string.Join(", ", serviceNames)}");
+ }
+
protected void RaisePropertyChanged(string property)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
diff --git a/Anamnesis/Services/Settings.cs b/Anamnesis/Services/Settings.cs
index 82fe7f5dc..ffe1f581c 100644
--- a/Anamnesis/Services/Settings.cs
+++ b/Anamnesis/Services/Settings.cs
@@ -17,6 +17,10 @@ namespace Anamnesis.Services;
[AddINotifyPropertyChangedInterface]
public class Settings : INotifyPropertyChanged
{
+ private const int MinAutoSaveIntervalMinutes = 1;
+ private int autoSaveIntervalMinutes = 5;
+ private bool enableAutoSave = true;
+
public event PropertyChangedEventHandler? PropertyChanged;
public enum Fonts
@@ -38,6 +42,7 @@ public enum Fonts
public string DefaultCharacterDirectory { get; set; } = "%MyDocuments%/Anamnesis/Characters/";
public string DefaultCameraShotDirectory { get; set; } = "%MyDocuments%/Anamnesis/CameraShots/";
public string DefaultSceneDirectory { get; set; } = "%MyDocuments%/Anamnesis/Scenes/";
+ public string DefaultAutoSaveDirectory { get; set; } = "%MyDocuments%/Anamnesis/AutoSave/";
public bool ShowAdvancedOptions { get; set; } = true;
public bool FlipPoseGuiSides { get; set; } = false;
public Fonts Font { get; set; } = Fonts.Default;
@@ -63,6 +68,45 @@ public enum Fonts
public Binds KeyboardBindings { get; set; } = new();
public Dictionary ActorTabOrder { get; set; } = new();
public Dictionary PosingBoneLinks { get; set; } = new();
+ public double ViewportPanSpeed { get; set; } = 1;
+ public double ViewportZoomSpeed { get; set; } = 1;
+ public double ViewportRotationSpeed { get; set; } = 1;
+ public bool EnableAutoSave
+ {
+ get
+ {
+ return this.enableAutoSave;
+ }
+ set
+ {
+ if (value == this.enableAutoSave)
+ return;
+
+ this.enableAutoSave = value;
+ AutoSaveService.Instance?.RestartUpdateTask();
+ }
+ }
+
+ public int AutoSaveFileCount { get; set; } = 12;
+ public int AutoSaveIntervalMinutes
+ {
+ get
+ {
+ return Math.Max(this.autoSaveIntervalMinutes, MinAutoSaveIntervalMinutes);
+ }
+ set
+ {
+ if (value == this.autoSaveIntervalMinutes)
+ return;
+
+ // Limit the minimum value
+ if (value < MinAutoSaveIntervalMinutes)
+ value = MinAutoSaveIntervalMinutes;
+
+ this.autoSaveIntervalMinutes = value;
+ AutoSaveService.Instance?.RestartUpdateTask();
+ }
+ }
public double WindowOpacity
{
diff --git a/Anamnesis/Services/TargetService.cs b/Anamnesis/Services/TargetService.cs
index 5a627edfd..9299999dd 100644
--- a/Anamnesis/Services/TargetService.cs
+++ b/Anamnesis/Services/TargetService.cs
@@ -210,19 +210,6 @@ public void UpdatePlayerTarget()
{
// This section can only fail when FFXIV isn't running (fail to set address) so it should be safe to ignore
}
-
- // Tick the actor if it still exists
- if (this.PlayerTarget != null && this.PlayerTarget.Address != IntPtr.Zero)
- {
- try
- {
- this.PlayerTarget.Synchronize();
- }
- catch
- {
- // Should only fail to tick if the game isn't running
- }
- }
}
public override async Task Start()
@@ -425,14 +412,14 @@ private async Task TickPinnedActors()
{
while (this.IsAlive)
{
- await Task.Delay(33);
+ this.UpdatePlayerTarget();
for (int i = this.PinnedActors.Count - 1; i >= 0; i--)
{
this.PinnedActors[i].Tick();
}
- this.UpdatePlayerTarget();
+ await Task.Delay(33);
}
}
diff --git a/Anamnesis/Styles/Controls/QuaternionEditor.xaml.cs b/Anamnesis/Styles/Controls/QuaternionEditor.xaml.cs
index 69f277bd1..33b560c81 100644
--- a/Anamnesis/Styles/Controls/QuaternionEditor.xaml.cs
+++ b/Anamnesis/Styles/Controls/QuaternionEditor.xaml.cs
@@ -8,7 +8,7 @@ namespace Anamnesis.Styles.Controls;
using Anamnesis.Services;
using PropertyChanged;
using System;
-using System.Threading;
+using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -29,7 +29,7 @@ namespace Anamnesis.Styles.Controls;
public partial class QuaternionEditor : UserControl
{
public static readonly IBind ValueDp = Binder.Register(nameof(Value), OnValueChanged);
- public static readonly IBind RootRotationdp = Binder.Register(nameof(RootRotation), OnRootRotationChanged);
+ public static readonly IBind RootRotationDp = Binder.Register(nameof(RootRotation), OnRootRotationChanged);
public static readonly IBind TickDp = Binder.Register(nameof(TickFrequency));
public static readonly IBind ValueQuatDp = Binder.Register(nameof(ValueQuat), OnValueQuatChanged);
@@ -37,6 +37,7 @@ public partial class QuaternionEditor : UserControl
////private Vector3D euler;
private readonly RotationGizmo rotationGizmo;
+ private readonly bool isInitialized = false;
private bool lockdp = false;
private CmQuaternion worldSpaceDelta;
@@ -74,6 +75,8 @@ public QuaternionEditor()
this.Viewport.Camera = new PerspectiveCamera(new Point3D(0, 0, -2.0), new Vector3D(0, 0, 1), new Vector3D(0, 1, 0), 45);
this.worldSpace = false;
+
+ this.isInitialized = true;
}
public double TickFrequency
@@ -90,8 +93,8 @@ public CmQuaternion Value
public CmQuaternion? RootRotation
{
- get => RootRotationdp.Get(this);
- set => RootRotationdp.Set(this, value);
+ get => RootRotationDp.Get(this);
+ set => RootRotationDp.Set(this, value);
}
public CmQuaternion ValueQuat
@@ -145,6 +148,11 @@ public bool WorldSpace
private static void OnValueChanged(QuaternionEditor sender, CmQuaternion value)
{
+ if (!sender.isInitialized || sender.lockdp)
+ return;
+
+ sender.lockdp = true;
+
CmQuaternion valueQuat = new CmQuaternion(value.X, value.Y, value.Z, value.W);
if (sender.RootRotation != null)
@@ -158,11 +166,6 @@ private static void OnValueChanged(QuaternionEditor sender, CmQuaternion value)
sender.rotationGizmo.Transform = new RotateTransform3D(new QuaternionRotation3D(valueQuat.ToMedia3DQuaternion()));
sender.ValueQuat = valueQuat;
- if (sender.lockdp)
- return;
-
- sender.lockdp = true;
-
sender.Euler = sender.Value.ToEuler();
sender.lockdp = false;
@@ -170,11 +173,17 @@ private static void OnValueChanged(QuaternionEditor sender, CmQuaternion value)
private static void OnRootRotationChanged(QuaternionEditor sender, CmQuaternion? value)
{
+ if (!sender.isInitialized)
+ return;
+
OnValueChanged(sender, sender.Value);
}
private static void OnValueQuatChanged(QuaternionEditor sender, CmQuaternion value)
{
+ if (!sender.isInitialized)
+ return;
+
Quaternion newrot = value.ToMedia3DQuaternion();
sender.rotationGizmo.Transform = new RotateTransform3D(new QuaternionRotation3D(newrot));
@@ -205,11 +214,25 @@ private static void OnValueQuatChanged(QuaternionEditor sender, CmQuaternion val
private static void OnEulerChanged(QuaternionEditor sender, CmVector val)
{
- if (sender.lockdp)
+ if (!sender.isInitialized || sender.lockdp)
return;
sender.lockdp = true;
- sender.Value = QuaternionExtensions.FromEuler(sender.Euler);
+
+ var value = QuaternionExtensions.FromEuler(sender.Euler);
+ sender.Value = value;
+
+ if (sender.RootRotation != null)
+ value = sender.Root * value;
+
+ sender.worldSpaceDelta = value;
+
+ if (sender.WorldSpace)
+ value = CmQuaternion.Identity;
+
+ sender.rotationGizmo.Transform = new RotateTransform3D(new QuaternionRotation3D(value.ToMedia3DQuaternion()));
+ sender.ValueQuat = value;
+
sender.lockdp = false;
}
@@ -310,16 +333,25 @@ private bool Rotate(KeyboardKeyStates state, double x, double y, double z)
return true;
}
- private void WatchCamera()
+ private async Task WatchCamera()
{
bool vis = true;
while (vis && Application.Current != null)
{
- try
+ if (Application.Current.Dispatcher.CheckAccess())
+ {
+ vis = this.IsVisible;
+
+ if (CameraService.Instance.Camera != null)
+ {
+ this.Viewport.Camera.Transform = new RotateTransform3D(new QuaternionRotation3D(CameraService.Instance.Camera.Rotation3d.ToMedia3DQuaternion()));
+ }
+ }
+ else
{
Application.Current.Dispatcher.Invoke(() =>
{
- vis = this.IsVisible; ////&& this.IsEnabled;
+ vis = this.IsVisible;
if (CameraService.Instance.Camera != null)
{
@@ -327,11 +359,8 @@ private void WatchCamera()
}
});
}
- catch (Exception)
- {
- }
- Thread.Sleep(16);
+ await Task.Delay(16);
}
}
@@ -340,7 +369,7 @@ private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArg
if (this.IsVisible)
{
// Watch camera thread
- new Thread(new ThreadStart(this.WatchCamera)).Start();
+ Task.Run(() => this.WatchCamera());
}
}
diff --git a/Anamnesis/Tabs/DeveloperTab.xaml.cs b/Anamnesis/Tabs/DeveloperTab.xaml.cs
index b944f8091..70aa617db 100644
--- a/Anamnesis/Tabs/DeveloperTab.xaml.cs
+++ b/Anamnesis/Tabs/DeveloperTab.xaml.cs
@@ -117,7 +117,7 @@ private async void OnSaveSceneClicked(object sender, RoutedEventArgs e)
return;
SceneFile file = new();
- await file.WriteToFile();
+ file.WriteToFile();
using FileStream stream = new FileStream(result.Path.FullName, FileMode.Create);
file.Serialize(stream);
diff --git a/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml b/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml
new file mode 100644
index 000000000..22dce7831
--- /dev/null
+++ b/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml.cs b/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml.cs
new file mode 100644
index 000000000..749db603d
--- /dev/null
+++ b/Anamnesis/Tabs/Settings/BackupAndRecoverySettingsPage.xaml.cs
@@ -0,0 +1,52 @@
+// © Anamnesis.
+// Licensed under the MIT license.
+
+namespace Anamnesis.Tabs.Settings;
+
+using Anamnesis.Files;
+using Anamnesis.Services;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Forms;
+
+///
+/// Interaction logic for BackupAndRecoverySettingsPage.xaml.
+///
+public partial class BackupAndRecoverySettingsPage : System.Windows.Controls.UserControl, ISettingSection
+{
+ public BackupAndRecoverySettingsPage()
+ {
+ this.InitializeComponent();
+ this.ContentArea.DataContext = this;
+
+ // Initialize setting categories
+ this.SettingCategories = new()
+ {
+ { "Backup", new SettingCategory("Backup", this.BackupGroupBox) },
+ };
+
+ // Set up backup category settings
+ this.SettingCategories["Backup"].Settings.Add(new Setting("Settings_EnableAutoSave", this.BnR_Backup_EnableAutoSave));
+ this.SettingCategories["Backup"].Settings.Add(new Setting("Settings_AutoSaveDirectory", this.BnR_Backup_AutoSaveDirectory));
+ this.SettingCategories["Backup"].Settings.Add(new Setting("Settings_AutoSaveInterval", this.BnR_Backup_AutoSaveInterval));
+ this.SettingCategories["Backup"].Settings.Add(new Setting("Settings_AutoSaveSaveLast", this.BnR_Backup_AutoSaveSaveLast));
+ }
+
+ public static SettingsService SettingsService => SettingsService.Instance;
+ public static int LabelColumnWidth => 150;
+ public Dictionary SettingCategories { get; }
+
+ private void OnBrowseAutoSave(object sender, RoutedEventArgs e)
+ {
+ FolderBrowserDialog dlg = new()
+ {
+ SelectedPath = FileService.ParseToFilePath(SettingsService.Current.DefaultAutoSaveDirectory),
+ };
+ DialogResult result = dlg.ShowDialog();
+
+ if (result != DialogResult.OK)
+ return;
+
+ SettingsService.Current.DefaultAutoSaveDirectory = FileService.ParseFromFilePath(dlg.SelectedPath);
+ }
+}
diff --git a/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml b/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml
index 725afc3a3..0da468c6b 100644
--- a/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml
+++ b/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml
@@ -41,6 +41,7 @@
+
@@ -52,6 +53,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml.cs b/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml.cs
index 3a47ce042..5b331ec8c 100644
--- a/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml.cs
+++ b/Anamnesis/Tabs/Settings/GeneralSettingsPage.xaml.cs
@@ -8,9 +8,11 @@ namespace Anamnesis.Tabs.Settings;
using PropertyChanged;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Forms;
+using System.Windows.Navigation;
///
/// Interaction logic for GeneralSettingsPage.xaml.
@@ -147,6 +149,12 @@ private void OnBrowseScene(object sender, RoutedEventArgs e)
SettingsService.Current.DefaultSceneDirectory = FileService.ParseFromFilePath(dlg.SelectedPath);
}
+ private void HyperlinkRequestNavigate(object sender, RequestNavigateEventArgs e)
+ {
+ Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
+ e.Handled = true;
+ }
+
public class FontOption
{
public FontOption(Settings.Fonts font)
diff --git a/Anamnesis/Tabs/Settings/InputSettingsPage.xaml b/Anamnesis/Tabs/Settings/InputSettingsPage.xaml
index 761a2bcc7..4276556b3 100644
--- a/Anamnesis/Tabs/Settings/InputSettingsPage.xaml
+++ b/Anamnesis/Tabs/Settings/InputSettingsPage.xaml
@@ -16,6 +16,7 @@
+
@@ -40,9 +41,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/Anamnesis/Tabs/Settings/InputSettingsPage.xaml.cs b/Anamnesis/Tabs/Settings/InputSettingsPage.xaml.cs
index ecee77a70..27323bcf0 100644
--- a/Anamnesis/Tabs/Settings/InputSettingsPage.xaml.cs
+++ b/Anamnesis/Tabs/Settings/InputSettingsPage.xaml.cs
@@ -27,12 +27,18 @@ public InputSettingsPage()
this.SettingCategories = new()
{
{ "Input", new SettingCategory("Input", this.InputGroupBox) },
+ { "3D Skeleton Viewport", new SettingCategory("3D Skeleton Viewport", this.PoseViewportGroupBox) },
{ "Hotkeys", new SettingCategory("Hotkeys", this.HotkeysGroupBox) },
};
// Set up input category settings
this.SettingCategories["Input"].Settings.Add(new Setting("Settings_WrapRotations", this.Input_Input_WrapRotations));
+ // Set up 3D skeleton viewport category settings
+ this.SettingCategories["3D Skeleton Viewport"].Settings.Add(new Setting("Settings_ViewportPanSpeed", this.Input_3DViewport_PanSpeed));
+ this.SettingCategories["3D Skeleton Viewport"].Settings.Add(new Setting("Settings_ViewportRotationSpeed", this.Input_3DViewport_RotationSpeed));
+ this.SettingCategories["3D Skeleton Viewport"].Settings.Add(new Setting("Settings_ViewportZoomSpeed", this.Input_3DViewport_ZoomSpeed));
+
// Set up hotkeys category settings
this.SettingCategories["Hotkeys"].Settings.Add(new Setting("Settings_EnableHotkeys", this.Input_Hotkeys_EnableHotkeys));
this.SettingCategories["Hotkeys"].Settings.Add(new Setting("Settings_EnableGameHotkeys", this.Input_Hotkeys_EnableGameHotkeys));
diff --git a/Anamnesis/Tabs/SettingsTab.xaml.cs b/Anamnesis/Tabs/SettingsTab.xaml.cs
index bac3d7b7c..8dffa9b55 100644
--- a/Anamnesis/Tabs/SettingsTab.xaml.cs
+++ b/Anamnesis/Tabs/SettingsTab.xaml.cs
@@ -37,6 +37,7 @@ public SettingsTab()
new SettingsPage(IconChar.Keyboard, "SettingsPages", "Input"),
new SettingsPage(IconChar.Palette, "SettingsPages", "Personalization"),
new SettingsPage(IconChar.NetworkWired, "SettingsPages", "Connections"),
+ new SettingsPage(IconChar.Database, "SettingsPages", "BackupAndRecovery"),
};
// Force initialization of all pages
diff --git a/Anamnesis/Updater/UpdateService.cs b/Anamnesis/Updater/UpdateService.cs
index d1113bf8a..e50e62ce4 100644
--- a/Anamnesis/Updater/UpdateService.cs
+++ b/Anamnesis/Updater/UpdateService.cs
@@ -3,6 +3,8 @@
namespace Anamnesis.Updater;
+using Anamnesis.GUI.Dialogs;
+using Anamnesis.Services;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -12,9 +14,8 @@ namespace Anamnesis.Updater;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using Anamnesis.GUI.Dialogs;
-using Anamnesis.Services;
using XivToolsWpf;
public class UpdateService : ServiceBase
@@ -32,6 +33,29 @@ public override async Task Initialize()
bool skipTimeCheck = false;
+ // Prompt user to install .NET 9 (transition period)
+ if (!IsDotNet9Installed())
+ {
+ // Prompt the user to install .NET 9
+ var userResponse = await GenericDialog.ShowLocalizedAsync("DotNetPrompt_Body", "DotNetPrompt_Title", System.Windows.MessageBoxButton.YesNo);
+ if (userResponse == true)
+ {
+ try
+ {
+ // Open the .NET 9 runtime installer download page
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-9.0.1-windows-x64-installer",
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to open .NET 9 runtime installer download page");
+ }
+ }
+ }
+
// Determine if this is a dev build
if (VersionInfo.Date.Year <= 2000)
{
@@ -222,6 +246,37 @@ public async Task DoUpdate(Action? updateProgress = null)
}
}
+ private static bool IsDotNet9Installed()
+ {
+ List versions = new();
+
+ ProcessStartInfo startInfo = new()
+ {
+ FileName = "dotnet",
+ Arguments = "--list-runtimes",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using Process? process = Process.Start(startInfo);
+
+ if (process == null)
+ return false;
+
+ while (!process.StandardOutput.EndOfStream)
+ {
+ string? line = process.StandardOutput.ReadLine();
+ if (line == null)
+ continue;
+
+ if (Regex.IsMatch(line, @"Microsoft\.NETCore\.App 9\.\d+\.\d+"))
+ return true;
+ }
+
+ return false;
+ }
+
public class Release
{
[JsonPropertyName("tag_name")]
diff --git a/Anamnesis/Views/AboutView.xaml b/Anamnesis/Views/AboutView.xaml
index b0e7e4415..01a3df1ce 100644
--- a/Anamnesis/Views/AboutView.xaml
+++ b/Anamnesis/Views/AboutView.xaml
@@ -50,7 +50,7 @@
- Anamnesis is proudly Free and Open Source Software. Please feel free to leave the team a Star on
+ Anamnesis is proudly Free and Open Source Software. Please feel free to leave the team a Star on
GitHub.
Anamnesis is built with the following projects:
@@ -81,9 +81,9 @@
- Want to follow development, ask a question, look for help, raise a bug, or just say hi? Visit us on
- Discord, or follow the XIV Tools
- Twitter!
+ Want to follow development, ask a question, look for help, raise a bug, or just say hi? Visit us on
+ Discord, or follow the Aetherworks
+ Bluesky account!
@@ -92,8 +92,9 @@
- This project is a labor of love. Please consider donating to the Maintainer to say thank you!
+ This project is a labor of love. Please consider donating to the maintainers to say thank you!
chirp
+ Ergo
We would also like to Thank all Contributors from the Past. You can find them on
github.
diff --git a/Anamnesis/Views/HistoryView.xaml b/Anamnesis/Views/HistoryView.xaml
index e4a18eec1..969c0176e 100644
--- a/Anamnesis/Views/HistoryView.xaml
+++ b/Anamnesis/Views/HistoryView.xaml
@@ -1,13 +1,13 @@
+ Width="256">
@@ -54,31 +54,33 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Anamnesis/Views/TargetSelectorView.xaml.cs b/Anamnesis/Views/TargetSelectorView.xaml.cs
index f0f615f45..85d5c9bfb 100644
--- a/Anamnesis/Views/TargetSelectorView.xaml.cs
+++ b/Anamnesis/Views/TargetSelectorView.xaml.cs
@@ -5,6 +5,7 @@ namespace Anamnesis.Views;
using Anamnesis.Actor;
using Anamnesis.Brio;
+using Anamnesis.Core;
using Anamnesis.Files;
using Anamnesis.Memory;
using Anamnesis.Services;
@@ -203,18 +204,14 @@ private async void OnCreateActorClicked(object sender, RoutedEventArgs e)
if (new PoseFile().Deserialize(File.OpenRead(path)) is not PoseFile poseFile)
return;
- SkeletonVisual3d skeletonVisual3D = new();
ActorMemory fullActor = new();
fullActor.SetAddress(newActor.Address);
fullActor.Synchronize();
- await skeletonVisual3D.SetActor(fullActor);
- poseFile.Apply(fullActor, skeletonVisual3D, null, PoseFile.Mode.Rotation, true);
+ var skeleton = new Skeleton(fullActor);
+ poseFile.Apply(fullActor, skeleton, null, PoseFile.Mode.Rotation, true);
}
}
- // Wait for actor's model object to become available
- await Task.Delay(300);
-
this.Value = newActor;
this.OnSelectionChanged(true);
}
diff --git a/Anamnesis/Views/TransformEditor.xaml b/Anamnesis/Views/TransformEditor.xaml
index e450144f9..d63b630b7 100644
--- a/Anamnesis/Views/TransformEditor.xaml
+++ b/Anamnesis/Views/TransformEditor.xaml
@@ -6,42 +6,39 @@
xmlns:controls="clr-namespace:Anamnesis.Styles.Controls"
xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf"
mc:Ignorable="d"
- d:DesignHeight="450" d:DesignWidth="200">
+ d:DesignHeight="450"
+ d:DesignWidth="200"
+ Loaded="OnLoaded"
+ Unloaded="OnUnloaded">
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
+
-
+
-
diff --git a/Anamnesis/Views/TransformEditor.xaml.cs b/Anamnesis/Views/TransformEditor.xaml.cs
index c9507e29e..c60ef2fdd 100644
--- a/Anamnesis/Views/TransformEditor.xaml.cs
+++ b/Anamnesis/Views/TransformEditor.xaml.cs
@@ -3,36 +3,451 @@
namespace Anamnesis.Actor.Controls;
-using System.Windows.Controls;
+using Anamnesis.Actor.Posing;
+using Anamnesis.Core;
using Anamnesis.Memory;
+using Anamnesis.Services;
+using PropertyChanged;
+using Serilog;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Numerics;
+using System.Windows;
+using System.Windows.Controls;
using XivToolsWpf.DependencyProperties;
-///
-/// Interaction logic for BoneEditor.xaml.
-///
-public partial class TransformEditor : UserControl
+/// Interaction logic for TransformEditor.xaml.
+[AddINotifyPropertyChangedInterface]
+public partial class TransformEditor : UserControl, INotifyPropertyChanged
{
- public static readonly IBind ValueDp = Binder.Register(nameof(Value));
- public static readonly IBind CanTranslateDp = Binder.Register(nameof(CanTranslate), BindMode.OneWay);
+ /// Dependency property for the skeleton entity.
+ public static readonly IBind SkeletonDp = Binder.Register(nameof(Skeleton), OnSkeletonChanged, BindMode.OneWay);
+
+ /// Dependency property for the actor transform.
+ ///
+ /// If both the skeleton and actor transform properties are set, the actor transform property will be prioritized by the editor.
+ ///
+ public static readonly IBind ActorTransformDp = Binder.Register(nameof(ActorTransform), OnActorTransformChanged, BindMode.TwoWay);
+ /// Dependency property for the translation override.
+ ///
+ /// If set, the editor will use this value to determine if a transform translation is allowed.
+ ///
+ public static readonly IBind CanTranslateOverrideDp = Binder.Register(nameof(CanTranslateOverride), OnCanTranslateChanged, BindMode.OneWay);
+
+ private readonly Dictionary initialPositions = new();
+ private readonly Dictionary initialRotations = new();
+ private readonly Dictionary initialScales = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
public TransformEditor()
{
this.InitializeComponent();
+ this.ContentArea.DataContext = this;
+ }
- this.CanTranslate = true;
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
- this.ContentArea.DataContext = this;
+ /// Gets or sets the skeleton entity.
+ public SkeletonEntity? Skeleton
+ {
+ get => SkeletonDp.Get(this);
+ set => SkeletonDp.Set(this, value);
+ }
+
+ /// Gets or sets the actor transform.
+ public ITransform? ActorTransform
+ {
+ get => ActorTransformDp.Get(this);
+ set => ActorTransformDp.Set(this, value);
+ }
+
+ /// Gets or sets the translation override.
+ public bool? CanTranslateOverride
+ {
+ get => CanTranslateOverrideDp.Get(this);
+ set => CanTranslateOverrideDp.Set(this, value);
+ }
+
+ /// Gets the selected actor.
+ public ActorMemory? SelectedActor => TargetService.Instance.SelectedActor;
+
+ /// Gets all selected bones for linked skeleton.
+ /// If the skeleton is not set, an empty collection is returned.
+ public IEnumerable SelectedBones => this.Skeleton?.SelectedBones ?? Enumerable.Empty();
+
+ /// Gets a value indicating whether translation is allowed.
+ public bool CanTranslate => this.CanTranslateOverride ?? (this.Skeleton != null && this.Skeleton.SelectedBones != null && this.Skeleton.SelectedBones.All(b => b.CanTranslate) && TargetService.Instance.SelectedActor != null && (TargetService.Instance.SelectedActor.IsMotionDisabled || PoseService.Instance.FreezeWorldPosition));
+
+ /// Gets a value indicating whether rotation is allowed.
+ public bool CanRotate => this.ActorTransform?.CanRotate ?? (this.Skeleton != null && this.Skeleton.SelectedBones != null && this.Skeleton.SelectedBones.All(b => b.CanRotate));
+
+ /// Gets a value indicating whether scaling is allowed.
+ public bool CanScale => this.ActorTransform?.CanScale ?? (this.Skeleton != null && this.Skeleton.SelectedBones != null && this.Skeleton.SelectedBones.All(b => b.CanScale));
+
+ /// Gets a value indicating whether linked scaling is allowed.
+ public bool CanLinkScale => this.ActorTransform?.CanLinkScale ?? (this.Skeleton != null && this.Skeleton.SelectedBones != null && this.Skeleton.SelectedBones.All(b => b.CanLinkScale));
+
+ /// Gets a value indicating whether scaling is linked.
+ public bool ScaleLinked => this.ActorTransform?.ScaleLinked ?? (this.Skeleton != null && this.Skeleton.SelectedBones != null && this.Skeleton.SelectedBones.All(b => b.ScaleLinked));
+
+ /// Gets or sets the position.
+ [DoNotNotify]
+ public Vector3 Position
+ {
+ get
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ return this.ActorTransform.Position;
+
+ // If skeleton is not set, return default value
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return Vector3.Zero;
+
+ // If there is no selection, use the actor transform
+ if (!this.SelectedBones.Any())
+ return this.SelectedActor?.ModelObject?.Transform?.Position ?? Vector3.Zero;
+
+ // If there is a single bone selected, use its position
+ // For multiple bones, return the deviation from the initial position
+ return this.Skeleton.SelectedBones.Count() == 1
+ ? this.Skeleton.SelectedBones.First().Position
+ : this.DeviationPosition;
+ }
+ set
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ {
+ this.ActorTransform.Position = value;
+ return;
+ }
+
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return;
+
+ if (!this.SelectedBones.Any())
+ {
+ if (this.SelectedActor?.ModelObject?.Transform != null)
+ {
+ this.SelectedActor.ModelObject.Transform.Position = value;
+ }
+ }
+ else if (this.Skeleton.SelectedBones.Count() == 1)
+ {
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ bone.Position = value;
+ }
+ }
+ else
+ {
+ Vector3 deviation = value - Vector3.Zero;
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ if (this.initialPositions.TryGetValue(bone.Name, out Vector3 initialPos))
+ {
+ bone.Position = initialPos + deviation;
+ }
+ }
+ }
+ }
+ }
+
+ /// Gets or sets the rotation.
+ [DoNotNotify]
+ public Quaternion Rotation
+ {
+ get
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ return this.ActorTransform.Rotation;
+
+ // If skeleton is not set, return default value
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return Quaternion.Identity;
+
+ // If there is no selection, use the actor transform
+ if (!this.SelectedBones.Any())
+ return this.SelectedActor?.ModelObject?.Transform?.Rotation ?? Quaternion.Identity;
+
+ // If there is a single bone selected, use its rotation
+ // For multiple bones, return the deviation from the initial rotation
+ return this.Skeleton.SelectedBones.Count() == 1
+ ? this.Skeleton.SelectedBones.First().Rotation
+ : this.DeviationRotation;
+ }
+ set
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ {
+ this.ActorTransform.Rotation = value;
+ return;
+ }
+
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return;
+
+ if (!this.SelectedBones.Any())
+ {
+ if (this.SelectedActor?.ModelObject?.Transform != null)
+ {
+ this.SelectedActor.ModelObject.Transform.Rotation = value;
+ }
+ }
+ else if (this.Skeleton.SelectedBones.Count() == 1)
+ {
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ bone.Rotation = value;
+ }
+ }
+ else
+ {
+ Quaternion deviation = value * Quaternion.Inverse(Quaternion.Identity);
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ if (this.initialRotations.TryGetValue(bone.Name, out Quaternion initialRotation))
+ {
+ bone.Rotation = initialRotation * deviation;
+ }
+ }
+ }
+ }
+ }
+
+ /// Gets the root rotation.
+ public Quaternion RootRotation
+ {
+ get
+ {
+ if (this.Skeleton != null)
+ {
+ if (this.Skeleton.SelectedBones?.Count() == 1)
+ return this.Skeleton.SelectedBones.First().RootRotation;
+ else if (this.Skeleton.SelectedBones?.Count() > 1)
+ return Quaternion.Multiply(this.Skeleton.RootRotation, Quaternion.Multiply(Quaternion.CreateFromAxisAngle(-Vector3.UnitX, MathF.PI / 2), Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2)));
+ }
+
+ return Quaternion.Identity;
+ }
+ }
+
+ /// Gets or sets the scale.
+ [DoNotNotify]
+ public Vector3 Scale
+ {
+ get
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ return this.ActorTransform.Scale;
+
+ // If skeleton is not set, return default value
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return Vector3.Zero;
+
+ // If there is no selection, use the actor transform
+ if (!this.SelectedBones.Any())
+ return this.SelectedActor?.ModelObject?.Transform?.Scale ?? Vector3.Zero;
+
+ // If there is a single bone selected, use its scale
+ // For multiple bones, return the deviation from the initial scale
+ return this.Skeleton.SelectedBones.Count() == 1
+ ? this.Skeleton.SelectedBones.First().Scale
+ : this.DeviationScale;
+ }
+ set
+ {
+ // If present, prioritize actor transform
+ if (this.ActorTransform != null)
+ {
+ this.ActorTransform.Scale = value;
+ return;
+ }
+
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return;
+
+ if (!this.SelectedBones.Any())
+ {
+ if (this.SelectedActor?.ModelObject?.Transform != null)
+ {
+ this.SelectedActor.ModelObject.Transform.Scale = value;
+ }
+ }
+ else if (this.Skeleton.SelectedBones.Count() == 1)
+ {
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ bone.Scale = value;
+ }
+ }
+ else
+ {
+ Vector3 deviation = value - Vector3.Zero;
+ foreach (Bone bone in this.Skeleton.SelectedBones)
+ {
+ if (this.initialScales.TryGetValue(bone.Name, out Vector3 initialScale))
+ {
+ bone.Scale = initialScale + deviation;
+ }
+ }
+ }
+ }
+ }
+
+ /// Gets the deviation position for multi-bone selections.
+ private Vector3 DeviationPosition
+ {
+ get
+ {
+ // For any selection, use the first bone as reference point
+ if (this.Skeleton?.SelectedBones?.FirstOrDefault() is Bone bone && this.initialPositions.TryGetValue(bone.Name, out Vector3 initialPosition))
+ return bone.Position - initialPosition;
+
+ return Vector3.Zero;
+ }
}
- public ITransform Value
+ /// Gets the deviation rotation for multi-bone selections.
+ private Quaternion DeviationRotation
{
- get => ValueDp.Get(this);
- set => ValueDp.Set(this, value);
+ get
+ {
+ // For any selection, use the first bone as reference point
+ if (this.Skeleton?.SelectedBones?.FirstOrDefault() is Bone bone && this.initialRotations.TryGetValue(bone.Name, out Quaternion initialRotation))
+ return Quaternion.Multiply(bone.Rotation, Quaternion.Inverse(initialRotation));
+
+ return Quaternion.Identity;
+ }
}
- public bool CanTranslate
+ /// Gets the deviation scale for multi-bone selections.
+ private Vector3 DeviationScale
{
- get => CanTranslateDp.Get(this);
- set => CanTranslateDp.Set(this, value);
+ get
+ {
+ // For any selection, use the first bone as reference point
+ if (this.Skeleton?.SelectedBones?.FirstOrDefault() is Bone bone && this.initialScales.TryGetValue(bone.Name, out Vector3 initialScale))
+ return bone.Scale - initialScale;
+
+ return Vector3.Zero;
+ }
+ }
+
+ /// Handles changes to the skeleton dependency property.
+ /// The sender.
+ /// The new value.
+ private static void OnSkeletonChanged(TransformEditor sender, SkeletonEntity? value)
+ {
+ if (sender.Skeleton != null)
+ sender.Skeleton.PropertyChanged -= sender.OnSkeletonPropertyChanged;
+
+ sender.Skeleton = value;
+
+ if (sender.Skeleton != null)
+ {
+ sender.Skeleton.PropertyChanged += sender.OnSkeletonPropertyChanged;
+
+ sender.SetInitialValues();
+ sender.RaisePropertyChanged(string.Empty);
+ }
+
+ Log.Verbose("[TransformEditor] Skeleton was updated");
+ }
+
+ /// Handles changes to the actor transform dependency property.
+ /// The sender.
+ /// The new value.
+ private static void OnActorTransformChanged(TransformEditor sender, ITransform? value)
+ {
+ sender.RaisePropertyChanged(string.Empty);
+ }
+
+ /// Handles changes to the translation override dependency property.
+ /// The sender.
+ /// The new value.
+ private static void OnCanTranslateChanged(TransformEditor sender, bool? value)
+ {
+ sender.RaisePropertyChanged(nameof(sender.CanTranslate));
+ }
+
+ /// Sets internally the initial values for the selected bones.
+ ///
+ /// The initial values are used to calculate the deviation from the original position, rotation, and scale.
+ ///
+ private void SetInitialValues()
+ {
+ if (this.Skeleton == null || this.Skeleton.SelectedBones == null)
+ return;
+
+ this.initialPositions.Clear();
+ this.initialRotations.Clear();
+ this.initialScales.Clear();
+
+ foreach (var bone in this.Skeleton.SelectedBones)
+ {
+ this.initialPositions[bone.Name] = bone.Position;
+ this.initialRotations[bone.Name] = bone.Rotation;
+ this.initialScales[bone.Name] = bone.Scale;
+ }
+ }
+
+ /// Raises the property changed event.
+ /// The name of the property that changed.
+ private void RaisePropertyChanged(string propertyName)
+ {
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ /// Handles property changes for the skeleton.
+ /// The sender.
+ /// The event arguments.
+ private void OnSkeletonPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SkeletonEntity.SelectedBones))
+ {
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ this.SetInitialValues();
+ this.RaisePropertyChanged(string.Empty);
+ });
+ }
+ }
+
+ /// Handles the history applied event.
+ private void OnHistoryApplied()
+ {
+ this.RaisePropertyChanged(string.Empty);
+ }
+
+ /// Handles the loaded event.
+ /// The sender.
+ /// The event arguments.
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ HistoryService.OnHistoryApplied += this.OnHistoryApplied;
+ }
+
+ /// Handles the unloaded event.
+ /// The sender.
+ /// The event arguments.
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ HistoryService.OnHistoryApplied -= this.OnHistoryApplied;
+
+ if (this.Skeleton != null)
+ {
+ this.Skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged;
+ }
}
}
diff --git a/Lib/XivToolsWpf b/Lib/XivToolsWpf
index cc00863e2..8adba9f8f 160000
--- a/Lib/XivToolsWpf
+++ b/Lib/XivToolsWpf
@@ -1 +1 @@
-Subproject commit cc00863e26d4cb86dd3c64b0e6c4a3a7dd3ff6f9
+Subproject commit 8adba9f8ff6b56225088215b50a1bf7444b8be2d