feat(undo) unified undo redo api.

This commit is contained in:
2026-06-04 20:23:24 +09:00
parent d4238e3086
commit c9d3703fd5
9 changed files with 284 additions and 335 deletions

View File

@@ -68,7 +68,7 @@ public unsafe class ComponentNode
throw new ArgumentException("Property type does not match value type");
}
_undoService.RecordEntityComponent(this, $"Edit property {property.DisplayName} on {Descriptor.DisplayName}");
_undoService.RecordObject(EntityNode, $"Edit property {property.DisplayName} on {Descriptor.DisplayName}");
_worldService.Defer(() =>
{
if (Descriptor.IsShared)
@@ -99,7 +99,7 @@ public unsafe class ComponentNode
throw new ArgumentException("Value type does not match component type");
}
_undoService.RecordEntityComponent(this, $"Edit component {Descriptor.DisplayName}");
_undoService.RecordObject(EntityNode, $"Edit component {Descriptor.DisplayName}");
_worldService.Defer(() =>
{
if (Descriptor.IsShared)

View File

@@ -24,6 +24,199 @@ public sealed partial class EntityNode : SceneGraphNode
public override SceneNode? GetOwningSceneNode() => SceneNode;
public unsafe override void SerializeState(BinaryWriter writer)
{
base.SerializeState(writer);
var isAlive = World.EntityManager.Exists(Entity);
writer.Write(isAlive);
if (!isAlive)
{
return;
}
var locRes = World.EntityManager.GetEntityLocation(Entity);
if (!locRes.IsSuccess)
{
writer.Write(0);
return;
}
var archetypeId = locRes.Value.archetypeID;
ref var archetype = ref World.ComponentManager.GetArchetypeReference(archetypeId);
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
EditorApplication.TryGetService<Services.SceneGraphSyncService>(out var syncService);
writer.Write(archetype._layouts.Count);
for (var i = 0; i < archetype._layouts.Count; i++)
{
var layout = archetype._layouts[i];
var typeId = new Ghost.Core.Identifier<IComponent>(layout.componentID);
writer.Write(typeId.Value);
writer.Write(layout.size);
var pSrc = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
// Copy to temp buffer
var buffer = new byte[layout.size];
fixed (byte* pDst = buffer)
{
Buffer.MemoryCopy(pSrc, pDst, layout.size, layout.size);
}
// Reference Translation
var entityOffsets = Services.EntityFieldTracker.GetEntityOffsets(typeId.Value);
foreach (var offset in entityOffsets)
{
Entity oldEntity;
fixed (byte* pBuf = buffer)
{
oldEntity = *(Entity*)(pBuf + offset);
}
Guid targetGuid = Guid.Empty;
if (syncService != null && syncService.TryGetNode(oldEntity, out var targetNode))
{
targetGuid = targetNode.InstanceID;
}
writer.Write(true);
writer.Write(offset);
writer.Write(targetGuid.ToByteArray());
}
writer.Write(false); // End of patch records
// Write patched bytes
writer.Write(buffer);
}
// Shared Data
if (chunk._groupIndex >= 0 && chunk._groupIndex < archetype._chunkGroups.Count)
{
var group = archetype._chunkGroups[chunk._groupIndex];
writer.Write(true);
writer.Write(group.sharedDataHash);
writer.Write(group.sharedData.Length);
writer.Write(group.sharedData.AsSpan().ToArray());
}
else
{
writer.Write(false);
}
}
public unsafe override void DeserializeState(BinaryReader reader)
{
base.DeserializeState(reader);
var isAlive = reader.ReadBoolean();
var currentlyAlive = World.EntityManager.Exists(Entity);
if (isAlive && !currentlyAlive)
{
// Resurrect
var newEntity = World.EntityManager.CreateEntity();
// Update the Entity property via reflection
var entityField = typeof(EntityNode).GetField("<Entity>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
entityField?.SetValue(this, newEntity);
}
else if (!isAlive && currentlyAlive)
{
// Destroy
World.EntityManager.DestroyEntity(Entity);
return;
}
else if (!isAlive && !currentlyAlive)
{
return;
}
var componentCount = reader.ReadInt32();
var componentsToRestore = new List<Ghost.Core.Identifier<IComponent>>();
var componentDataMap = new Dictionary<int, byte[]>();
EditorApplication.TryGetService<Services.SceneGraphSyncService>(out var syncService);
for (var i = 0; i < componentCount; i++)
{
var typeIdVal = reader.ReadInt32();
var size = reader.ReadInt32();
var typeId = new Ghost.Core.Identifier<IComponent>(typeIdVal);
componentsToRestore.Add(typeId);
var patchRecords = new List<(int offset, Guid guid)>();
while (reader.ReadBoolean())
{
var offset = reader.ReadInt32();
var guidBytes = reader.ReadBytes(16);
patchRecords.Add((offset, new Guid(guidBytes)));
}
var buffer = reader.ReadBytes(size);
// Apply patch records
foreach (var record in patchRecords)
{
Entity newEntity = Entity.Invalid;
if (record.guid != Guid.Empty)
{
if (GhostObject.Find(record.guid) is EntityNode targetNode)
{
newEntity = targetNode.Entity;
}
}
fixed (byte* pBuf = buffer)
{
*(Entity*)(pBuf + record.offset) = newEntity;
}
}
componentDataMap[typeIdVal] = buffer;
}
var hasSharedData = reader.ReadBoolean();
int sharedDataHash = 0;
byte[] sharedData = Array.Empty<byte>();
if (hasSharedData)
{
sharedDataHash = reader.ReadInt32();
var sharedSize = reader.ReadInt32();
sharedData = reader.ReadBytes(sharedSize);
}
// Migrate entity to match snapshot archetype
var view = new Ghost.Entities.ComponentSetView(componentsToRestore.ToArray(), sharedData);
World.EntityManager.MigrateEntity(Entity, view);
// Restore unmanaged data
var locRes = World.EntityManager.GetEntityLocation(Entity);
if (locRes.IsSuccess)
{
ref var archetype = ref World.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
for (var i = 0; i < archetype._layouts.Count; i++)
{
var layout = archetype._layouts[i];
if (componentDataMap.TryGetValue(layout.componentID, out var buffer))
{
var pDst = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
fixed (byte* pSrc = buffer)
{
Buffer.MemoryCopy(pSrc, pDst, layout.size, layout.size);
}
}
}
}
}
public void BuildComponents()
{
Components.Clear();

View File

@@ -0,0 +1,58 @@
using Ghost.Entities;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Services;
/// <summary>
/// Caches the memory offsets of all fields of type `Entity` within unmanaged ECS components.
/// Used by the Undo system to patch references during serialization/deserialization.
/// </summary>
internal static class EntityFieldTracker
{
private static readonly Dictionary<int, int[]> s_entityOffsets = new();
private static readonly Lock s_lock = new();
public static int[] GetEntityOffsets(int componentId)
{
lock (s_lock)
{
if (s_entityOffsets.TryGetValue(componentId, out var offsets))
{
return offsets;
}
if (!ComponentRegistry.s_runtimeIDToType.TryGetValue(componentId, out var type))
{
s_entityOffsets[componentId] = Array.Empty<int>();
return Array.Empty<int>();
}
var offsetList = new List<int>();
FindEntityFieldsRecursive(type, 0, offsetList);
offsets = offsetList.ToArray();
s_entityOffsets[componentId] = offsets;
return offsets;
}
}
private static void FindEntityFieldsRecursive(Type type, int currentOffset, List<int> offsetList)
{
var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach (var field in fields)
{
var fieldOffset = currentOffset + (int)Marshal.OffsetOf(type, field.Name);
if (field.FieldType == typeof(Entity))
{
offsetList.Add(fieldOffset);
}
else if (field.FieldType.IsValueType && !field.FieldType.IsPrimitive && !field.FieldType.IsEnum)
{
// Recursively check nested structs
FindEntityFieldsRecursive(field.FieldType, fieldOffset, offsetList);
}
}
}
}

View File

@@ -17,9 +17,7 @@ public interface IUndoService
event Action? UndoRedoPerformed;
void RecordObject(GhostObject obj, string actionName);
void RecordEntityComponent(ComponentNode node, string actionName);
void RecordEntityStructure(EntityNode node, string actionName);
void RecordEntityLifecycle(EntityNode node, LifecycleEvent type);
void RegisterCreatedObjectUndo(GhostObject obj, string actionName);
void BeginTransaction(string name);
void EndTransaction();
@@ -80,285 +78,6 @@ public class ObjectStateOperation : UndoOperation
}
}
public class EntityComponentOperation : UndoOperation
{
public Guid InstanceID { get; set; }
public Entity Entity { get; set; }
public int ComponentId { get; set; }
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
public unsafe override UndoOperation CreateReciprocal(IEditorWorldService worldService)
{
var node = GhostObject.Find(InstanceID) as EntityNode;
var targetEntity = node?.Entity ?? Entity;
var reciprocal = new EntityComponentOperation { GroupId = GroupId, ActionName = ActionName, Entity = targetEntity, InstanceID = InstanceID, ComponentId = ComponentId };
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(ComponentId));
if (pComp != null)
{
var size = ComponentRegistry.GetComponentInfo(new Identifier<IComponent>(ComponentId)).size;
var data = new byte[size];
fixed (byte* pDst = data)
{
Buffer.MemoryCopy(pComp, pDst, size, size);
}
reciprocal.ComponentData = data;
}
return reciprocal;
}
public override void Revert(IEditorWorldService worldService)
{
var cId = ComponentId;
var data = ComponentData;
var instId = InstanceID;
var fallbackEntity = Entity;
worldService.Defer(() =>
{
var node = GhostObject.Find(instId) as EntityNode;
var targetEntity = node?.Entity ?? fallbackEntity;
unsafe
{
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(cId));
if (pComp != null)
{
fixed (byte* pSrc = data)
{
Buffer.MemoryCopy(pSrc, pComp, data.Length, data.Length);
}
}
}
});
}
public override bool CanMerge(UndoOperation other)
{
if (other is EntityComponentOperation op)
{
if (op.Entity == Entity && op.ComponentId == ComponentId)
{
// Explicit transaction merge
if (op.GroupId != 0 && op.GroupId == GroupId)
{
return true;
}
// Time-based merge fallback for non-transactional continuous edits (e.g. 500ms)
if (op.GroupId == 0)
{
return Math.Abs((op.Timestamp - Timestamp).TotalMilliseconds) < 500;
}
}
}
return false;
}
}
public class EntityStructureOperation : UndoOperation
{
public Guid InstanceID { get; set; }
public Entity Entity { get; set; }
public int ArchetypeID { get; set; }
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
public byte[] SharedData { get; set; } = Array.Empty<byte>();
public int SharedDataHash { get; set; }
public unsafe static EntityStructureOperation Capture(IEditorWorldService worldService, EntityNode node)
{
var entity = node.Entity;
var op = new EntityStructureOperation { Entity = entity, InstanceID = node.InstanceID };
var locRes = worldService.EditorWorld.EntityManager.GetEntityLocation(entity);
if (locRes.IsSuccess)
{
op.ArchetypeID = locRes.Value.archetypeID;
ref var archetype = ref worldService.EditorWorld.ComponentManager.GetArchetypeReference(op.ArchetypeID);
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
// Compute size of all unmanaged components
var totalSize = 0;
for (var i = 0; i < archetype._layouts.Count; i++)
{
totalSize += archetype._layouts[i].size;
}
var data = new byte[totalSize];
fixed (byte* pDst = data)
{
var offset = 0;
for (var i = 0; i < archetype._layouts.Count; i++)
{
var layout = archetype._layouts[i];
var pSrc = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
Buffer.MemoryCopy(pSrc, pDst + offset, layout.size, layout.size);
offset += layout.size;
}
}
op.ComponentData = data;
if (chunk._groupIndex >= 0 && chunk._groupIndex < archetype._chunkGroups.Count)
{
var group = archetype._chunkGroups[chunk._groupIndex];
op.SharedData = group.sharedData.AsSpan().ToArray();
op.SharedDataHash = group.sharedDataHash;
}
}
return op;
}
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
{
if (GhostObject.Find(InstanceID) is not EntityNode node)
{
return this;
}
var reciprocal = Capture(worldService, node);
reciprocal.GroupId = GroupId;
reciprocal.ActionName = ActionName;
return reciprocal;
}
public unsafe override void Revert(IEditorWorldService worldService)
{
var instId = InstanceID;
var fallbackEntity = Entity;
var archId = ArchetypeID;
var compData = ComponentData;
var sharedData = SharedData;
var sharedHash = SharedDataHash;
worldService.Defer(() =>
{
var node = GhostObject.Find(instId) as EntityNode;
var targetEntity = node?.Entity ?? fallbackEntity;
var world = worldService.EditorWorld;
var locRes = world.EntityManager.GetEntityLocation(targetEntity);
if (!locRes.IsSuccess)
{
return; // Entity destroyed? Should use Lifecycle undo for that.
}
if (locRes.Value.archetypeID != archId)
{
ref var targetArchetype = ref world.ComponentManager.GetArchetypeReference(archId);
// Build ComponentSetView from the target archetype
var it = targetArchetype._signature.GetIterator();
var components = new List<Identifier<IComponent>>();
while (it.Next(out var compId))
{
components.Add(new Identifier<IComponent>(compId));
}
var set = new ComponentSetView(components.ToArray(), sharedData ?? Array.Empty<byte>());
world.EntityManager.MigrateEntity(targetEntity, set);
}
// Overwrite unmanaged memory
locRes = world.EntityManager.GetEntityLocation(targetEntity);
if (locRes.IsSuccess)
{
ref var archetype = ref world.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
fixed (byte* pSrcBase = compData)
{
var offset = 0;
for (var i = 0; i < archetype._layouts.Count; i++)
{
var layout = archetype._layouts[i];
var pDst = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
Buffer.MemoryCopy(pSrcBase + offset, pDst, layout.size, layout.size);
offset += layout.size;
}
}
}
});
}
public override bool CanMerge(UndoOperation other)
{
if (other is EntityStructureOperation op)
{
return op.Entity == Entity && op.GroupId == GroupId;
}
return false;
}
}
public class EntityLifecycleOperation : UndoOperation
{
public Entity Entity { get; set; }
public Guid InstanceID { get; set; }
public LifecycleEvent EventType { get; set; }
// State for destruction
public int ArchetypeID { get; set; }
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
public byte[] SharedData { get; set; } = Array.Empty<byte>();
public int SharedDataHash { get; set; }
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
{
var reciprocal = new EntityLifecycleOperation
{
GroupId = GroupId,
ActionName = ActionName,
Entity = Entity,
InstanceID = InstanceID,
EventType = EventType == LifecycleEvent.Created ? LifecycleEvent.Destroyed : LifecycleEvent.Created,
ArchetypeID = ArchetypeID,
ComponentData = ComponentData,
SharedData = SharedData,
SharedDataHash = SharedDataHash
};
return reciprocal;
}
public override void Revert(IEditorWorldService worldService)
{
worldService.Defer(() =>
{
if (EventType == LifecycleEvent.Created)
{
// Revert a Creation = Destroy
var node = GhostObject.Find(InstanceID) as EntityNode;
var targetEntity = node?.Entity ?? Entity;
worldService.EditorWorld.EntityManager.DestroyEntity(targetEntity);
// The InstanceID GhostObject will be naturally unlinked, handles become null
}
else
{
// Revert a Destruction = Recreate
var newEntity = worldService.EditorWorld.EntityManager.CreateEntity();
// TODO: Apply the ArchetypeID, ComponentData, SharedData to the newEntity.
// We'd add the components using the archetype signature, then memcopy the bytes.
// Fix the Node reference
if (GhostObject.Find(InstanceID) is not EntityNode node)
{
node = new EntityNode(worldService.EditorWorld, newEntity, "Resurrected", null);
// Force the InstanceID using backing field
var backingField = typeof(EntityNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?? typeof(SceneGraphNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
backingField?.SetValue(node, InstanceID);
}
else
{
// Update the entity property of the existing node (using reflection since it's init/readonly)
var entityField = typeof(EntityNode).GetField("<Entity>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
entityField?.SetValue(node, newEntity);
}
}
});
}
}
internal class UndoService : IUndoService
{
@@ -428,58 +147,30 @@ internal class UndoService : IUndoService
PushOperation(op);
}
// TODO: We may want to have unified api RecordObject that can handle everything.
public unsafe void RecordEntityComponent(ComponentNode node, string actionName)
public void RegisterCreatedObjectUndo(GhostObject obj, string actionName)
{
var op = new EntityComponentOperation
var op = new ObjectStateOperation()
{
ActionName = actionName,
Entity = node.EntityNode.Entity,
ComponentId = node.Descriptor.ComponentId,
InstanceID = node.EntityNode.InstanceID
InstanceID = obj.InstanceID
};
var pComp = node.GetComponentPointer();
var size = node.Descriptor.Size;
var data = new byte[size];
fixed (byte* pDst = data)
// The object is created, so before its creation, it did NOT exist.
// We write a manual state payload indicating it does not exist.
// During Undo, DeserializeState will read this and destroy the object.
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
if (obj is SceneGraph.SceneGraphNode sgn)
{
Buffer.MemoryCopy(pComp, pDst, size, size);
writer.Write(sgn.Name);
}
op.ComponentData = data;
PushOperation(op);
}
public void RecordEntityStructure(EntityNode node, string actionName)
{
var op = EntityStructureOperation.Capture(_worldService, node);
op.ActionName = actionName;
PushOperation(op);
}
public void RecordEntityLifecycle(EntityNode node, LifecycleEvent type)
{
var op = new EntityLifecycleOperation
else
{
ActionName = type == LifecycleEvent.Created ? "Create Entity" : "Destroy Entity",
Entity = node.Entity,
InstanceID = node.InstanceID,
EventType = type
};
if (type == LifecycleEvent.Destroyed)
{
// Capture state before destruction
var structure = EntityStructureOperation.Capture(_worldService, node);
op.ArchetypeID = structure.ArchetypeID;
op.ComponentData = structure.ComponentData;
op.SharedData = structure.SharedData;
op.SharedDataHash = structure.SharedDataHash;
writer.Write("");
}
writer.Write(false); // IsAlive = false
op.State = ms.ToArray();
PushOperation(op);
}

View File

@@ -23,6 +23,7 @@
<TreeViewItem.ContextFlyout>
<MenuFlyout>
<MenuFlyoutItem Click="OnCreateEntityClick" Text="Create Entity" />
<MenuFlyoutItem Click="OnSaveSceneClick" Text="Save Scene" />
</MenuFlyout>
</TreeViewItem.ContextFlyout>
<StackPanel Orientation="Horizontal">

View File

@@ -165,6 +165,12 @@ public sealed partial class Hierarchy : UserControl
}
}
private async void OnSaveSceneClick(object sender, RoutedEventArgs e)
{
var assetRegistry = App.GetService<IAssetRegistry>();
await assetRegistry.SaveDirtyAssetsAsync();
}
private void OnCreateChildClick(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is EntityNode entityNode)

View File

@@ -58,7 +58,7 @@ internal static class ComponentRegistry
// NOTE: Can we remove the lock? Ideally all the component registeration will happend during module init, way before the first get.
private static readonly Lock s_registerLock = new();
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
internal static readonly Dictionary<int, Type> s_runtimeIDToType = new();
#endif
@@ -91,7 +91,7 @@ internal static class ComponentRegistry
s_typeHandleToID[typeHandle] = newID;
s_nameToRuntimeID[stableName] = newID;
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
s_runtimeIDToType[newID.Value] = typeof(T);
#endif

View File

@@ -38,7 +38,7 @@ internal static class ManagedComponentRegistry
private static readonly List<ManagedComponentInfo> s_registeredComponents = new();
private static readonly Dictionary<IntPtr, int> s_typeHandleToID = new();
private static readonly Dictionary<string, int> s_nameToRuntimeID = new();
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
internal static readonly Dictionary<int, Type> s_runtimeIDToType = new();
#endif
@@ -66,7 +66,7 @@ internal static class ManagedComponentRegistry
s_typeHandleToID[typeHandle] = newID;
s_nameToRuntimeID[stableName] = newID;
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
s_runtimeIDToType[newID.Value] = typeof(T);
#endif

View File

@@ -40,7 +40,7 @@ public class UndoServiceEcsTests
var node = new EntityNode(world, e, "TestEntity", null);
_undoService.BeginTransaction("Add CompB");
_undoService.RecordEntityStructure(node, "Before Add CompB");
_undoService.RecordObject(node, "Before Add CompB");
// Modify structure
world.EntityManager.AddComponent<CompB>(e);
@@ -82,7 +82,7 @@ public class UndoServiceEcsTests
var node = new EntityNode(world, e, "TestEntity", null);
_undoService.BeginTransaction("Create Entity");
_undoService.RecordEntityLifecycle(node, LifecycleEvent.Created);
_undoService.RegisterCreatedObjectUndo(node, "Create Entity");
_undoService.EndTransaction();
// Undo Creation (Expect destruction)
@@ -100,7 +100,7 @@ public class UndoServiceEcsTests
// Step 2: Destroy Entity
_undoService.BeginTransaction("Destroy Entity");
_undoService.RecordEntityLifecycle(node, LifecycleEvent.Destroyed);
_undoService.RecordObject(node, "Destroy Entity");
world.EntityManager.DestroyEntity(resurrectedEntity);
_undoService.EndTransaction();