Completed a large amount of work relating to recording.

This was all done in about 4 hours of crunch. I was just in the groove.
Recording is pretty much completely working. You can record things in
realtime, you can pass that data onto a playback object (haven't written
that part yet but I've written the in-between), and you can save
recordings to a file, either as a binary file (efficient) or as an XML
file (definitely not).

There's more I might want to do with the encoding in the future, namely
some compression. Like if a value hasn't changed in a while, we don't
need to store it every tick. I believe this is called "temporal
compression."

The XML data actually compresses incredibly well (I guess I shouldn't be
surprised; the data is 90% the word "Property"). That might be worth
looking into.

Anyway, playback coming soon.
This commit is contained in:
That-One-Nerd 2025-08-31 23:18:47 -04:00
parent 1242aef875
commit fee5cf0efd
13 changed files with 549 additions and 10 deletions

View File

@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36212.18 d17.14
VisualStudioVersion = 17.14.36212.18
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionRecorder", "ActionRecorder\ActionRecorder.csproj", "{ADBD19FD-F099-4422-A96B-D2D3A025C31B}"
EndProject

View File

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Xml;
using UnityEngine;
using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute;
namespace ActionRecorder
{
public static class ActionManager
{
private static readonly List<ComponentRecorderInfo> recorderTypes;
private static readonly List<ComponentRecorderBase> activeRecorders;
private static readonly Dictionary<string, (ObjectRecorder recorder, object playback)> registeredIds;
private static readonly Dictionary<(string id, string recordname), RecordingContainer> recordingData;
static ActionManager()
{
Assembly[] toSearch = new Assembly[]
{
Assembly.GetEntryAssembly(),
Assembly.GetExecutingAssembly()
};
recorderTypes = new List<ComponentRecorderInfo>();
foreach (Assembly asm in toSearch)
{
if (asm is null) continue;
foreach (Type t in asm.GetTypes())
{
if (recorderTypes.Any(x => x.RecorderType == t)) continue;
ComponentRecorderAttribute att = t.GetCustomAttribute<ComponentRecorderAttribute>();
if (att is null || !t.HasBaseType<ComponentRecorderBase>()) continue;
// Try to get the instant name. If there isn't one, use the name
// of the recorder, minus the word "recorder"
string instantName;
DisplayNameAttribute instantNameAtt = att.instantType.GetCustomAttribute<DisplayNameAttribute>();
if (instantNameAtt is null)
{
instantName = t.Name.Replace("Recorder", "");
}
else instantName = instantNameAtt.DisplayName;
recorderTypes.Add(new ComponentRecorderInfo()
{
ComponentType = att.componentType,
RecorderType = t,
InstantType = att.instantType,
InstantDisplayName = instantName
});
}
}
activeRecorders = new List<ComponentRecorderBase>();
registeredIds = new Dictionary<string, (ObjectRecorder recorder, object playback)>();
recordingData = new Dictionary<(string, string), RecordingContainer>();
}
public static ComponentRecorderInfo GetRecorderInfo(Type recorderType)
{
return recorderTypes.SingleOrDefault(x => x.RecorderType == recorderType);
}
public static bool IdTaken(string id) => registeredIds.ContainsKey(id);
public static void RegisterController(ObjectRecorder recorder)
{
if (registeredIds.ContainsKey(recorder.Id))
{
(ObjectRecorder, object) current = registeredIds[recorder.Id];
current.Item1 = recorder;
registeredIds[recorder.Id] = current;
}
else registeredIds.Add(recorder.Id, (recorder, null));
recorder.containers = new RecordingContainer[recorder.ComponentsToRecord.Length];
for (int i = 0; i < recorder.containers.Length; i++)
{
ComponentRecorderInfo info = GetRecorderInfo(recorder.recorders[i].GetType());
if (!recordingData.TryGetValue((recorder.Id, info.InstantDisplayName), out RecordingContainer data))
{
data = new RecordingContainer(info.InstantDisplayName);
recordingData.Add((recorder.Id, info.InstantDisplayName), data);
}
recorder.containers[i] = data;
}
}
public static bool HasRecorderTypeFor(Component component)
{
return recorderTypes.Any(x => x.ComponentType == component.GetType());
}
public static ComponentRecorderBase GetRecorderFor(string id, Component component)
{
Type comType = component.GetType();
// First check if a recorder with this ID already exists.
ComponentRecorderBase result = activeRecorders.SingleOrDefault(x =>
x.Controller.Id == id &&
GetComponentTypeOfRecorder(x) == comType);
if (result != null) return result;
// Otherwise make a new instance.
ComponentRecorderInfo recInfo = recorderTypes.SingleOrDefault(x => x.ComponentType == comType);
if (recInfo is null) return null;
result = (ComponentRecorderBase)Activator.CreateInstance(recInfo.RecorderType);
activeRecorders.Add(result);
return result;
}
public static Stream GetRecordingAsBinary(string id)
{
MemoryStream ms = new MemoryStream();
BinaryWriter writer = new BinaryWriter(ms, Encoding.UTF8, true);
WriteRecordingAsBinary(id, writer);
writer.Close();
return ms;
}
public static void WriteRecordingAsBinary(string id, BinaryWriter writer)
{
writer.Write(id);
IEnumerable<RecordingContainer> data = from c in recordingData
where c.Key.id == id
select c.Value;
foreach (RecordingContainer container in data)
{
writer.Write(container.DisplayName);
container.EncodeBinary(writer);
}
}
public static string GetRecordingAsXml(string id, bool indent = true)
{
StringBuilder result = new StringBuilder();
XmlWriterSettings settings = new XmlWriterSettings();
if (indent)
{
settings.Indent = true;
settings.IndentChars = " ";
}
XmlWriter writer = XmlWriter.Create(result, settings);
WriteRecordingAsXml(id, writer);
writer.Close();
return result.ToString();
}
public static void WriteRecordingAsXml(string id, XmlWriter writer)
{
writer.WriteStartElement("Object");
writer.WriteAttributeString("Identifier", id);
IEnumerable<RecordingContainer> data = from c in recordingData
where c.Key.id == id
select c.Value;
foreach (RecordingContainer container in data)
{
writer.WriteStartElement("Component");
writer.WriteAttributeString("Identifier", container.DisplayName);
container.EncodeXml(writer);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
internal static Type GetComponentTypeOfRecorder(ComponentRecorderBase instance) =>
recorderTypes.Single(x => x.RecorderType == instance.GetType()).ComponentType;
}
}

View File

@ -4,6 +4,14 @@
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Reference Include="UnityEngine">
<HintPath>UnityEngine.dll</HintPath>

View File

@ -1,9 +0,0 @@
using System;
namespace ActionRecorder
{
public class Class1
{
}
}

View File

@ -0,0 +1,17 @@
using System;
namespace ActionRecorder
{
[AttributeUsage(AttributeTargets.Class)]
public class ComponentRecorderAttribute : Attribute
{
public readonly Type componentType;
public readonly Type instantType;
public ComponentRecorderAttribute(Type componentType, Type instantType)
{
this.componentType = componentType;
this.instantType = instantType;
}
}
}

View File

@ -0,0 +1,24 @@
using System;
using UnityEngine;
namespace ActionRecorder
{
public abstract class ComponentRecorderBase
{
public ObjectRecorder Controller { get; internal set; }
public Type ComponentType => ActionManager.GetComponentTypeOfRecorder(this);
public Component Component { get; internal set; }
public abstract FrequencyKind RecordFrequency { get; }
public virtual void Awake() { }
public virtual void Start() { }
public virtual void OnBeginRecording() { }
public abstract RecordingInstantBase RecordInstant();
public virtual void OnEndRecording() { }
// TODO: Return a type for containing recordings.
}
}

View File

@ -0,0 +1,14 @@
using System;
namespace ActionRecorder
{
public class ComponentRecorderInfo
{
public Type ComponentType { get; internal set; }
public Type RecorderType { get; internal set; }
public Type InstantType { get; internal set; }
public string InstantDisplayName { get; internal set; }
internal ComponentRecorderInfo() { }
}
}

View File

@ -0,0 +1,9 @@
namespace ActionRecorder
{
public enum FrequencyKind
{
Manual = 0,
Update = 1,
FixedUpdate = 2
}
}

View File

@ -0,0 +1,19 @@
using System;
namespace ActionRecorder
{
internal static class HelperExtensions
{
public static bool HasBaseType(this Type type, Type baseType)
{
Type activeType = type.BaseType;
while (activeType != null)
{
if (activeType == baseType) return true;
else activeType = activeType.BaseType;
}
return false;
}
public static bool HasBaseType<T>(this Type type) => HasBaseType(type, typeof(T));
}
}

View File

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using UnityEngine;
namespace ActionRecorder
{
public class ObjectRecorder : MonoBehaviour
{
public bool AutoRecord;
public string Id;
public Component[] ComponentsToRecord;
public bool Recording { get; private set; }
internal ComponentRecorderBase[] recorders;
internal RecordingContainer[] containers;
private void Awake()
{
if (string.IsNullOrEmpty(Id))
{
Debug.LogWarning("You should always give your recorders a unique identifier!");
Id = GetRandomId();
}
else if (ActionManager.IdTaken(Id))
{
Debug.LogWarning($"A recorder with the ID \"{Id}\" already exists! Please choose a different unique identifier.");
Id = GetRandomId();
}
// Go through the list of components to record, ensure they are
// components belonging to this object, and initialize recorders
// for those components.
List<Component> valid = new List<Component>();
foreach (Component c in ComponentsToRecord)
{
if (c is null) continue;
if (c.gameObject != gameObject)
{
Debug.LogWarning($"The component {c} does not belong to this game object and thus cannot be recorded!");
continue;
}
else if (!ActionManager.HasRecorderTypeFor(c))
{
Debug.LogError($"No recognized recorder type for {c}! This component cannot be recorded.");
continue;
}
valid.Add(c);
}
ComponentsToRecord = valid.ToArray();
recorders = new ComponentRecorderBase[ComponentsToRecord.Length];
for (int i = 0; i < recorders.Length; i++)
{
ComponentRecorderBase rec = ActionManager.GetRecorderFor(Id, ComponentsToRecord[i]);
rec.Controller = this;
rec.Component = ComponentsToRecord[i];
recorders[i] = rec;
}
ActionManager.RegisterController(this);
OnAwake();
for (int i = 0; i < recorders.Length; i++) recorders[i].Awake();
}
private void Start()
{
if (AutoRecord) RecordStart();
OnStart();
for (int i = 0; i < recorders.Length; i++) recorders[i].Start();
}
protected virtual void OnAwake() { }
protected virtual void OnStart() { }
protected virtual void OnUpdate() { }
protected virtual void OnFixedUpdate() { }
protected virtual void BeginRecording() { }
protected virtual void EndRecording() { }
public event Action OnBeginRecording = delegate { };
public event Action OnEndRecording = delegate { };
private void Update()
{
OnUpdate();
if (!Recording) return;
for (int i = 0; i < recorders.Length; i++)
{
ComponentRecorderBase rec = recorders[i];
if (rec.RecordFrequency != FrequencyKind.Update) continue;
RecordingInstantBase instant = rec.RecordInstant();
containers[i].Add(instant);
}
}
private void FixedUpdate()
{
OnFixedUpdate();
if (!Recording) return;
for (int i = 0; i < recorders.Length; i++)
{
ComponentRecorderBase rec = recorders[i];
if (rec.RecordFrequency != FrequencyKind.FixedUpdate) continue;
RecordingInstantBase instant = rec.RecordInstant();
containers[i].Add(instant);
}
}
public void RecordStart()
{
if (Recording) return;
Recording = true;
BeginRecording();
OnBeginRecording();
for (int i = 0; i < recorders.Length; i++) recorders[i].OnBeginRecording();
}
public void RecordStop()
{
if (!Recording) return;
Recording = false;
EndRecording();
OnEndRecording();
for (int i = 0; i < recorders.Length; i++) recorders[i].OnEndRecording();
}
private static string GetRandomId()
{
StringBuilder id = new StringBuilder("CHANGEME-");
System.Random rand = new System.Random();
for (int i = 0; i < 8; i++) id.Append((char)('A' + rand.Next(26)));
return id.ToString();
}
}
}

View File

@ -0,0 +1,48 @@
using System.ComponentModel;
using System.IO;
using System.Xml;
using UnityEngine;
namespace ActionRecorder.Recorders
{
[ComponentRecorder(typeof(Transform), typeof(TransformInstant))]
public class TransformRecorder : ComponentRecorderBase
{
public override FrequencyKind RecordFrequency { get; } = FrequencyKind.Update;
new public Transform Component => (Transform)base.Component;
public override RecordingInstantBase RecordInstant() => new TransformInstant()
{
Time = Time.time,
localPosition = Component.localPosition,
localRotation = Component.localRotation.eulerAngles,
localScale = Component.localScale
};
[DisplayName("Transform")]
public class TransformInstant : RecordingInstantBase
{
public Vector3 localPosition, localRotation, localScale;
public override void EncodeBinary(BinaryWriter writer)
{
writer.Write(localPosition.x);
writer.Write(localPosition.y);
writer.Write(localPosition.z);
writer.Write(localRotation.x);
writer.Write(localRotation.y);
writer.Write(localRotation.z);
writer.Write(localScale.x);
writer.Write(localScale.y);
writer.Write(localScale.z);
}
public override void EncodeXml(XmlWriter writer)
{
WriteProperty(writer, nameof(localPosition), localPosition);
WriteProperty(writer, nameof(localRotation), localRotation);
WriteProperty(writer, nameof(localScale), localScale);
}
}
}
}

View File

@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.IO;
using System.Xml;
namespace ActionRecorder
{
public class RecordingContainer
{
public string DisplayName { get; }
// TODO: Replace this with a more sophisticated method.
// Allocate using powers of 2, implement IList, etc.
public readonly List<RecordingInstantBase> instants;
internal RecordingContainer(string name)
{
DisplayName = name;
instants = new List<RecordingInstantBase>();
}
public void Add(RecordingInstantBase instant)
{
instants.Add(instant);
}
public void EncodeBinary(BinaryWriter writer)
{
foreach (RecordingInstantBase instant in instants)
{
writer.Write(instant.Time);
instant.EncodeBinary(writer);
}
}
public void EncodeXml(XmlWriter writer)
{
foreach (RecordingInstantBase instant in instants)
{
writer.WriteStartElement("Instant");
writer.WriteAttributeString("Time", instant.Time.ToString());
instant.EncodeXml(writer);
writer.WriteEndElement();
}
}
}
}

View File

@ -0,0 +1,55 @@
using System.IO;
using System.Xml;
using UnityEngine;
namespace ActionRecorder
{
public abstract class RecordingInstantBase
{
public float Time { get; set; }
public abstract void EncodeBinary(BinaryWriter writer);
public abstract void EncodeXml(XmlWriter writer);
protected void WriteProperty(XmlWriter writer, string name, object value)
{
writer.WriteStartElement("Property");
writer.WriteAttributeString("Name", name);
writer.WriteValue(value?.ToString() ?? "");
writer.WriteEndElement();
}
protected void WriteProperty(XmlWriter writer, string name, Vector2 value)
{
writer.WriteStartElement("Property");
writer.WriteAttributeString("Name", name);
WriteProperty(writer, "x", value.x);
WriteProperty(writer, "y", value.y);
writer.WriteEndElement();
}
protected void WriteProperty(XmlWriter writer, string name, Vector3 value)
{
writer.WriteStartElement("Property");
writer.WriteAttributeString("Name", name);
WriteProperty(writer, "x", value.x);
WriteProperty(writer, "y", value.y);
WriteProperty(writer, "z", value.z);
writer.WriteEndElement();
}
protected void WriteProperty(XmlWriter writer, string name, Vector4 value)
{
writer.WriteStartElement("Property");
writer.WriteAttributeString("Name", name);
WriteProperty(writer, "x", value.x);
WriteProperty(writer, "y", value.y);
WriteProperty(writer, "z", value.z);
WriteProperty(writer, "w", value.w);
writer.WriteEndElement();
}
}
}