From d8d6faf7fe03bbfe86f1f2fdf4bebbed1a675f95 Mon Sep 17 00:00:00 2001 From: messdp Date: Tue, 16 Jun 2026 16:41:34 -0700 Subject: [PATCH 01/19] first commit for 1.1.x --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c98de6c..e047faf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.0.1", + "version": "1.1.0", "displayName": "Spellbound Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From 8c125111c86c7abbae83e90d71dde33f267ce009 Mon Sep 17 00:00:00 2001 From: messdp Date: Wed, 17 Jun 2026 14:56:22 -0700 Subject: [PATCH 02/19] static is updated and bug-free --- Runtime/ModuleContracts/IDispatch.cs | 2 +- Runtime/ObjectData/IObjectDataAccess.cs | 2 +- Runtime/ObjectHandling/ObjectParent.cs | 10 +++--- Runtime/Objects/ObjectPreset.cs | 3 -- Runtime/Surfaces/IEventSurface.cs | 7 ++-- Runtime/Surfaces/StaticEventSurface.cs | 43 +++++++++++++------------ 6 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Runtime/ModuleContracts/IDispatch.cs b/Runtime/ModuleContracts/IDispatch.cs index 49ec3a2..98fedf9 100644 --- a/Runtime/ModuleContracts/IDispatch.cs +++ b/Runtime/ModuleContracts/IDispatch.cs @@ -13,6 +13,6 @@ namespace Spellbound.Core.ModuleContracts { /// public interface IDispatch where T : IPackerDispatch { bool OnDispatch( - T dispatchContext, IEventSurface eventSurface, IObjectParent parent = null, int instanceIndex = -1); + T dispatchContext, IEventSurface eventSurface); } } \ No newline at end of file diff --git a/Runtime/ObjectData/IObjectDataAccess.cs b/Runtime/ObjectData/IObjectDataAccess.cs index 5f7b62e..4f1be1a 100644 --- a/Runtime/ObjectData/IObjectDataAccess.cs +++ b/Runtime/ObjectData/IObjectDataAccess.cs @@ -43,6 +43,6 @@ void Delta(int instanceIndex, uint presetHash, int eventSurfac where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(); // Intended to be the implementation for deleting an instance with confirmation of deletion. - Task TryDeleteInstance(int instanceIndex); + void DeleteInstance(int instanceIndex); } } \ No newline at end of file diff --git a/Runtime/ObjectHandling/ObjectParent.cs b/Runtime/ObjectHandling/ObjectParent.cs index fb579ba..374a86b 100644 --- a/Runtime/ObjectHandling/ObjectParent.cs +++ b/Runtime/ObjectHandling/ObjectParent.cs @@ -144,7 +144,7 @@ public bool TryReadDataAllData( int instanceIndex, uint presetHash, int eventSurfaceIndex, out List results) => StaticDataAccess.TryReadAll(instanceIndex, eventSurfaceIndex, out results); - public bool TryWriteData( + public bool WriteData( int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte context = 0) where T : IPackerObjectData, new() { StaticDataAccess.Write(instanceIndex, presetHash, eventSurfaceIndex, newData, context); @@ -152,17 +152,15 @@ public bool TryWriteData( return true; } - public bool TryTransformData( + public void Delta( int instanceIndex, uint presetHash, int eventSurfaceIndex, TDispatch delta) where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(){ StaticDataAccess.Delta(instanceIndex, presetHash, eventSurfaceIndex, delta); - - return true; } - public async Task TryDeleteData(int instanceIndex) => - await StaticDataAccess.TryDeleteInstance(instanceIndex); + public void DeleteInstance(int instanceIndex) => + StaticDataAccess.DeleteInstance(instanceIndex); #endregion API diff --git a/Runtime/Objects/ObjectPreset.cs b/Runtime/Objects/ObjectPreset.cs index bfd0d5d..3b7807d 100644 --- a/Runtime/Objects/ObjectPreset.cs +++ b/Runtime/Objects/ObjectPreset.cs @@ -58,9 +58,6 @@ public bool TryGetModule(out T result, int surfaceIndex = 0) where T : class public bool TryGetModules(out IReadOnlyList results, int surfaceIndex = 0) where T : class { results = Array.Empty(); - if (surfaceIndex < 0 || surfaceIndex >= surfaceModules.Count) - return false; - var matches = new List(); foreach (var module in surfaceModules[surfaceIndex].presetModules) { if (module is T t) diff --git a/Runtime/Surfaces/IEventSurface.cs b/Runtime/Surfaces/IEventSurface.cs index aec1485..d1da0cb 100644 --- a/Runtime/Surfaces/IEventSurface.cs +++ b/Runtime/Surfaces/IEventSurface.cs @@ -26,8 +26,11 @@ int Initialize( void AlertChanged(); bool TryRead(out T data) where T : IPackerObjectData, new(); - bool TryWrite(T data, byte contextIn) where T : IPackerObjectData, new(); - bool TryDestroy(); + void Write(T data, byte contextIn) where T : IPackerObjectData, new(); + + void Delta(TDispatch dispatch) + where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(); + void Destroy(); bool TryGetEventSurfaceByIndex(int desiredSurfaceIndex, out IEventSurface surface); } diff --git a/Runtime/Surfaces/StaticEventSurface.cs b/Runtime/Surfaces/StaticEventSurface.cs index 83b4e28..2ccb88e 100644 --- a/Runtime/Surfaces/StaticEventSurface.cs +++ b/Runtime/Surfaces/StaticEventSurface.cs @@ -23,7 +23,7 @@ public class StaticEventSurface : MonoBehaviour, IEventSurface { public Transform Transform => transform; private IObjectParent _parent; - private int _entityIndex; + private int _instanceIndex; private Dictionary _childEventSurfaces = new(); @@ -33,7 +33,7 @@ public int Initialize( IObjectParent objectParent, int entityIndex, uint presetHash, Dictionary dataSlots = null) { _parent = objectParent; - _entityIndex = entityIndex; + _instanceIndex = entityIndex; Preset = presetHash.ResolvePreset(); var childSurfaces = GetComponentsInChildren(true); @@ -42,7 +42,7 @@ public int Initialize( if (childSurface == this) continue; - var childSurfaceIndex = childSurface.Initialize(_parent, _entityIndex, Preset.Hash, dataSlots); + var childSurfaceIndex = childSurface.Initialize(_parent, _instanceIndex, Preset.Hash, dataSlots); if (!_childEventSurfaces.TryAdd(childSurfaceIndex, childSurface)) Log.Error($"Duplicate surfaceIndex {childSurfaceIndex} on {childSurface.gameObject.name}"); @@ -53,24 +53,15 @@ public int Initialize( public void DebugQueryPing() => Debug.Log($"Pinging Event Surface for {Preset.name} " + - $"index {_entityIndex} " + + $"index {_instanceIndex} " + $"and surface index {surfaceIndex}"); - - // Declare a THandler type at runtime that will pass in a pointer of that type to THAT types implementation. + public bool Dispatch(TContext dispatch) where TContext : IPackerDispatch { if (Preset == null) return false; - - // If this event surface doesn't have children - early return. - if (surfaceIndex < 0 || surfaceIndex >= Preset.surfaceModules.Count) - return false; - - // If it does have children loop through them and invoke. - foreach (var module in Preset.surfaceModules[surfaceIndex].presetModules) { - if (module is IDispatch handler) - handler.OnDispatch(dispatch, this, _parent, _entityIndex); - } - return false; + + return Preset.TryGetModule(out IDispatch handler, surfaceIndex) + && handler.OnDispatch(dispatch, this); } public event Action OnChanged; @@ -87,11 +78,21 @@ public bool TryGetEventSurfaceByIndex(int desiredSurfaceIndex, out IEventSurface return _childEventSurfaces.TryGetValue(desiredSurfaceIndex, out surface); } - //TODO - public bool TryRead(out T data) where T : IPackerObjectData, new() => throw new NotImplementedException(); + public bool TryRead(out T data) where T : IPackerObjectData, new() { + return _parent.ObjectParent.TryReadData(_instanceIndex, Preset.Hash, surfaceIndex, out data); + } - public bool TryWrite(T data, byte contextIn) where T : IPackerObjectData, new() => throw new NotImplementedException(); + public void Write(T data, byte contextIn) where T : IPackerObjectData, new() { + _parent.ObjectParent.WriteData(_instanceIndex, Preset.Hash, surfaceIndex, data, contextIn); + } - public bool TryDestroy() => throw new NotImplementedException(); + public void Delta(TDispatch dispatch) where TData : IPackerObjectData, new() + where TDispatch : IPackerDispatch, new() { + _parent.ObjectParent.Delta(_instanceIndex, Preset.Hash, surfaceIndex, dispatch); + } + + public void Destroy() { + _parent.ObjectParent.DeleteInstance(_instanceIndex); + } } } \ No newline at end of file From 6fa2adb4ee04cb499624bc3007483af9caa58a69 Mon Sep 17 00:00:00 2001 From: messdp Date: Wed, 17 Jun 2026 16:30:07 -0700 Subject: [PATCH 03/19] done --- Runtime/ObjectData/IObjectDataAccess.cs | 4 +++- Runtime/ObjectData/ISingleObjectDataAccess.cs | 19 +++++++++++++++++++ .../ISingleObjectDataAccess.cs.meta | 3 +++ Runtime/ObjectHandling/ObjectParent.cs | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 Runtime/ObjectData/ISingleObjectDataAccess.cs create mode 100644 Runtime/ObjectData/ISingleObjectDataAccess.cs.meta diff --git a/Runtime/ObjectData/IObjectDataAccess.cs b/Runtime/ObjectData/IObjectDataAccess.cs index 4f1be1a..76e434a 100644 --- a/Runtime/ObjectData/IObjectDataAccess.cs +++ b/Runtime/ObjectData/IObjectDataAccess.cs @@ -32,7 +32,9 @@ public interface IObjectDataAccess { bool TryRead(int instanceIndex, int eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); - bool TryReadAll(int instanceIndex, int eventSurfaceIndex, out List data); + bool TryReadAllBySurface(int instanceIndex, int eventSurfaceIndex, out List data); + + bool TryReadAll(int instanceIndex, out List data); // Intended to be the implementation for writing over any data with new data on an object. void Write(int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte contextIn) diff --git a/Runtime/ObjectData/ISingleObjectDataAccess.cs b/Runtime/ObjectData/ISingleObjectDataAccess.cs new file mode 100644 index 0000000..abf6c60 --- /dev/null +++ b/Runtime/ObjectData/ISingleObjectDataAccess.cs @@ -0,0 +1,19 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; + +namespace Spellbound.Core.ObjectData { + public interface ISingleObjectDataAccess { + bool TryRead(int eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); + + void Write(int eventSurfaceIndex, T newData, byte contextIn) where T : IPackerObjectData, new(); + + public void Delta(int eventSurfaceIndex, TDispatch dispatch) + where TData : IPackerObjectData, new() + where TDispatch : IPackerDispatch, new(); + + bool TryReadAllBySurface(int eventSurfaceIndex, out List data); + + bool TryReadAll(out List allData); + } +} \ No newline at end of file diff --git a/Runtime/ObjectData/ISingleObjectDataAccess.cs.meta b/Runtime/ObjectData/ISingleObjectDataAccess.cs.meta new file mode 100644 index 0000000..c4f00a5 --- /dev/null +++ b/Runtime/ObjectData/ISingleObjectDataAccess.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e47b4aa4b6e14ba1b4568e94ef25b016 +timeCreated: 1781735327 \ No newline at end of file diff --git a/Runtime/ObjectHandling/ObjectParent.cs b/Runtime/ObjectHandling/ObjectParent.cs index 374a86b..813e9a1 100644 --- a/Runtime/ObjectHandling/ObjectParent.cs +++ b/Runtime/ObjectHandling/ObjectParent.cs @@ -142,7 +142,7 @@ public bool TryReadData(int instanceIndex, uint presetHash, int eventSurfaceI public bool TryReadDataAllData( int instanceIndex, uint presetHash, int eventSurfaceIndex, out List results) => - StaticDataAccess.TryReadAll(instanceIndex, eventSurfaceIndex, out results); + StaticDataAccess.TryReadAllBySurface(instanceIndex, eventSurfaceIndex, out results); public bool WriteData( int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte context = 0) From fd87dff96851b058bc2bdd4b1796c68239335762 Mon Sep 17 00:00:00 2001 From: messdp Date: Wed, 17 Jun 2026 17:21:59 -0700 Subject: [PATCH 04/19] done --- Runtime/ModuleContracts/IApplyDelta.cs | 2 +- Runtime/ModuleContracts/ITimerModule.cs | 2 +- Runtime/Modules/PresetModule.cs | 6 +++--- Runtime/ObjectData/DeltaResolver.cs | 6 +++--- Runtime/ObjectData/DynamicInstanceEntry.cs | 2 +- Runtime/ObjectData/IObjectDataAccess.cs | 8 ++++---- Runtime/ObjectData/IPackerObjectData.cs | 4 ++-- Runtime/ObjectData/ISingleObjectDataAccess.cs | 8 ++++---- Runtime/ObjectData/InstanceDataKey.cs | 4 ++-- Runtime/ObjectData/ObjectDataExtensions.cs | 8 ++++---- Runtime/ObjectHandling/ObjectParent.cs | 8 ++++---- Runtime/Objects/ObjectPreset.cs | 6 +++--- Runtime/Surfaces/StaticEventSurface.cs | 2 +- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Runtime/ModuleContracts/IApplyDelta.cs b/Runtime/ModuleContracts/IApplyDelta.cs index 8a02025..3c8ac8d 100644 --- a/Runtime/ModuleContracts/IApplyDelta.cs +++ b/Runtime/ModuleContracts/IApplyDelta.cs @@ -17,6 +17,6 @@ namespace Spellbound.Core.ModuleContracts { public interface IApplyDelta where TData : IPackerObjectData where TDelta : ISmartPacker { - TData ApplyDelta(TData data, TDelta delta, ObjectPreset preset, int surfaceIndex, out byte context, out ISmartPacker consequence); + TData ApplyDelta(TData data, TDelta delta, ObjectPreset preset, byte surfaceIndex, out byte context, out ISmartPacker consequence); } } \ No newline at end of file diff --git a/Runtime/ModuleContracts/ITimerModule.cs b/Runtime/ModuleContracts/ITimerModule.cs index f5168d0..ff71423 100644 --- a/Runtime/ModuleContracts/ITimerModule.cs +++ b/Runtime/ModuleContracts/ITimerModule.cs @@ -13,6 +13,6 @@ void OnTimerUpdate( public interface ITimerModule : ITimerModule where T : IPackerObjectData { void OnTimerUpdate( T data, uint time, IObjectDataAccess dataAccess, - int instanceIndex, ObjectPreset preset, int eventSurfaceIndex = 0); + int instanceIndex, ObjectPreset preset, byte eventSurfaceIndex = 0); } } \ No newline at end of file diff --git a/Runtime/Modules/PresetModule.cs b/Runtime/Modules/PresetModule.cs index b8e2d60..4b43689 100644 --- a/Runtime/Modules/PresetModule.cs +++ b/Runtime/Modules/PresetModule.cs @@ -10,17 +10,17 @@ namespace Spellbound.Core.Modules { [Serializable] public abstract class PresetModule { [NonSerialized] private ObjectPreset _preset; - [NonSerialized] private int _surfaceIndex; + [NonSerialized] private byte _surfaceIndex; protected ObjectPreset Preset => _preset; - protected int SurfaceIndex => _surfaceIndex; + protected byte SurfaceIndex => _surfaceIndex; /// /// Called when the owning ObjectPreset is loaded into memory. /// Caches the preset and surface index so modules don't need them passed per-call. /// Override to do additional setup — always call base first. /// - public virtual void OnPresetLoaded(ObjectPreset preset, int surfaceIndex) { + public virtual void OnPresetLoaded(ObjectPreset preset, byte surfaceIndex) { _preset = preset; _surfaceIndex = surfaceIndex; } diff --git a/Runtime/ObjectData/DeltaResolver.cs b/Runtime/ObjectData/DeltaResolver.cs index e3481ad..cc40712 100644 --- a/Runtime/ObjectData/DeltaResolver.cs +++ b/Runtime/ObjectData/DeltaResolver.cs @@ -26,7 +26,7 @@ public static class DeltaResolver { /// consequence to award. /// public delegate byte[] DeltaApply( - byte[] currentBytes, byte[] deltaBytes, ObjectPreset preset, int surfaceIndex, + byte[] currentBytes, byte[] deltaBytes, ObjectPreset preset, byte surfaceIndex, out IPackerObjectData result, out byte context, out ISmartPacker consequence); private static readonly Dictionary<(uint dataHash, uint deltaHash), DeltaApply> Table = new(); @@ -50,7 +50,7 @@ public static bool TryGet(uint dataHash, uint deltaHash, out DeltaApply apply) { /// module default), applies it, and repacks the result. Shared with the reflection path below. /// public static byte[] ApplyTyped( - byte[] currentBytes, TDelta delta, ObjectPreset preset, int surfaceIndex, + byte[] currentBytes, TDelta delta, ObjectPreset preset, byte surfaceIndex, out IPackerObjectData result, out byte context, out ISmartPacker consequence) where TData : IPackerObjectData, new() where TDelta : ISmartPacker, new() { @@ -76,7 +76,7 @@ public static byte[] ApplyTyped( /// . /// private static byte[] Run( - byte[] currentBytes, byte[] deltaBytes, ObjectPreset preset, int surfaceIndex, + byte[] currentBytes, byte[] deltaBytes, ObjectPreset preset, byte surfaceIndex, out IPackerObjectData result, out byte context, out ISmartPacker consequence) where TData : IPackerObjectData, new() where TDelta : ISmartPacker, new() { diff --git a/Runtime/ObjectData/DynamicInstanceEntry.cs b/Runtime/ObjectData/DynamicInstanceEntry.cs index c1153a4..08dad30 100644 --- a/Runtime/ObjectData/DynamicInstanceEntry.cs +++ b/Runtime/ObjectData/DynamicInstanceEntry.cs @@ -50,7 +50,7 @@ public void Unpack(ref ReadOnlySpan buffer) { for (var i = 0; i < count; i++) { var packerHash = Packer.ReadUInt(ref buffer); - var surfaceIndex = Packer.ReadInt(ref buffer); + var surfaceIndex = Packer.ReadByte(ref buffer); var bytes = Packer.ReadBytes(ref buffer); DataSlots[new InstanceDataKey(packerHash, surfaceIndex)] = bytes; } diff --git a/Runtime/ObjectData/IObjectDataAccess.cs b/Runtime/ObjectData/IObjectDataAccess.cs index 76e434a..80505ea 100644 --- a/Runtime/ObjectData/IObjectDataAccess.cs +++ b/Runtime/ObjectData/IObjectDataAccess.cs @@ -29,19 +29,19 @@ public interface IObjectDataAccess { bool IsDeleted(int instanceIndex); // Intended to be the implementation for simply reading data on an instance. - bool TryRead(int instanceIndex, int eventSurfaceIndex, out T data) + bool TryRead(int instanceIndex, byte eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); - bool TryReadAllBySurface(int instanceIndex, int eventSurfaceIndex, out List data); + bool TryReadAllBySurface(int instanceIndex, byte eventSurfaceIndex, out List data); bool TryReadAll(int instanceIndex, out List data); // Intended to be the implementation for writing over any data with new data on an object. - void Write(int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte contextIn) + void Write(int instanceIndex, uint presetHash, byte eventSurfaceIndex, T newData, byte contextIn) where T : IPackerObjectData, new(); // Intended to be the implementation for transforming current object data with incoming data. - void Delta(int instanceIndex, uint presetHash, int eventSurfaceIndex, TDispatch dispatch) + void Delta(int instanceIndex, uint presetHash, byte eventSurfaceIndex, TDispatch dispatch) where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(); // Intended to be the implementation for deleting an instance with confirmation of deletion. diff --git a/Runtime/ObjectData/IPackerObjectData.cs b/Runtime/ObjectData/IPackerObjectData.cs index 05fc3c9..bae8f77 100644 --- a/Runtime/ObjectData/IPackerObjectData.cs +++ b/Runtime/ObjectData/IPackerObjectData.cs @@ -15,11 +15,11 @@ public interface IPackerObjectData : ISmartPacker { void InvokeChangeCallback( byte context, ObjectParent parent, int instanceIndex, - ObjectPreset preset, int surfaceIndex, TransformData transformData); + ObjectPreset preset, byte surfaceIndex, TransformData transformData); void InvokeResolveCallback( byte context, ObjectParent parent, int instanceIndex, - ObjectPreset preset, int surfaceIndex, TransformData transformData); + ObjectPreset preset, byte surfaceIndex, TransformData transformData); } } \ No newline at end of file diff --git a/Runtime/ObjectData/ISingleObjectDataAccess.cs b/Runtime/ObjectData/ISingleObjectDataAccess.cs index abf6c60..b7ec909 100644 --- a/Runtime/ObjectData/ISingleObjectDataAccess.cs +++ b/Runtime/ObjectData/ISingleObjectDataAccess.cs @@ -4,15 +4,15 @@ namespace Spellbound.Core.ObjectData { public interface ISingleObjectDataAccess { - bool TryRead(int eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); + bool TryRead(byte eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); - void Write(int eventSurfaceIndex, T newData, byte contextIn) where T : IPackerObjectData, new(); + void Write(byte eventSurfaceIndex, T newData, byte contextIn) where T : IPackerObjectData, new(); - public void Delta(int eventSurfaceIndex, TDispatch dispatch) + public void Delta(byte eventSurfaceIndex, TDispatch dispatch) where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(); - bool TryReadAllBySurface(int eventSurfaceIndex, out List data); + bool TryReadAllBySurface(byte eventSurfaceIndex, out List data); bool TryReadAll(out List allData); } diff --git a/Runtime/ObjectData/InstanceDataKey.cs b/Runtime/ObjectData/InstanceDataKey.cs index 988b735..b1eed28 100644 --- a/Runtime/ObjectData/InstanceDataKey.cs +++ b/Runtime/ObjectData/InstanceDataKey.cs @@ -5,9 +5,9 @@ namespace Spellbound.Core.ObjectData { public readonly struct InstanceDataKey : IEquatable { public readonly uint PackerHash; - public readonly int SurfaceIndex; + public readonly byte SurfaceIndex; - public InstanceDataKey(uint packerHash, int surfaceIndex) { + public InstanceDataKey(uint packerHash, byte surfaceIndex) { PackerHash = packerHash; SurfaceIndex = surfaceIndex; } diff --git a/Runtime/ObjectData/ObjectDataExtensions.cs b/Runtime/ObjectData/ObjectDataExtensions.cs index 171ecb2..de8000b 100644 --- a/Runtime/ObjectData/ObjectDataExtensions.cs +++ b/Runtime/ObjectData/ObjectDataExtensions.cs @@ -9,7 +9,7 @@ namespace Spellbound.Core.ObjectData { public static class ObjectDataExtensions { public static IPackerObjectData GetDefaultData( - this T data, ObjectPreset preset, int surfaceIndex, byte level = 1) + this T data, ObjectPreset preset, byte surfaceIndex, byte level = 1) where T : IPackerObjectData { if (preset.TryGetModule>(out var provider, surfaceIndex)) return provider.GetDefaultData(preset, level); @@ -19,7 +19,7 @@ public static IPackerObjectData GetDefaultData( public static T ApplyDelta( - this T data, TDelta delta, ObjectPreset preset, int surfaceIndex, out byte context, out ISmartPacker consequence) + this T data, TDelta delta, ObjectPreset preset, byte surfaceIndex, out byte context, out ISmartPacker consequence) where T : IPackerObjectData where TDelta : ISmartPacker { if (!preset.TryGetModule>(out var module, surfaceIndex)) { @@ -34,7 +34,7 @@ public static T ApplyDelta( public static void ChangeCallback( this T data, byte context, ObjectParent parent, - int instanceIndex, ObjectPreset preset, int surfaceIndex, TransformData transformData) + int instanceIndex, ObjectPreset preset, byte surfaceIndex, TransformData transformData) where T : IPackerObjectData { if (!preset.TryGetModules>(out var modules, surfaceIndex)) return; @@ -45,7 +45,7 @@ public static void ChangeCallback( public static void ResolveCallback( this T data, byte context, ObjectParent parent, - int instanceIndex, ObjectPreset preset, int surfaceIndex, TransformData transformData) + int instanceIndex, ObjectPreset preset, byte surfaceIndex, TransformData transformData) where T : IPackerObjectData { if (!preset.TryGetModules>(out var modules, surfaceIndex)) return; diff --git a/Runtime/ObjectHandling/ObjectParent.cs b/Runtime/ObjectHandling/ObjectParent.cs index 813e9a1..c82b1af 100644 --- a/Runtime/ObjectHandling/ObjectParent.cs +++ b/Runtime/ObjectHandling/ObjectParent.cs @@ -127,7 +127,7 @@ public void CreateNewInstance(ObjectPreset preset, Vector3 position, Vector3 rot DynamicDataAccess.CreateRuntimeObject(preset.Hash, position, rotation, scale); } - public bool TryReadData(int instanceIndex, uint presetHash, int eventSurfaceIndex, out T result) + public bool TryReadData(int instanceIndex, uint presetHash, byte eventSurfaceIndex, out T result) where T : IPackerObjectData, new() { if (StaticDataAccess.TryRead(instanceIndex, eventSurfaceIndex, out var data)) { result = data; @@ -141,11 +141,11 @@ public bool TryReadData(int instanceIndex, uint presetHash, int eventSurfaceI } public bool TryReadDataAllData( - int instanceIndex, uint presetHash, int eventSurfaceIndex, out List results) => + int instanceIndex, uint presetHash, byte eventSurfaceIndex, out List results) => StaticDataAccess.TryReadAllBySurface(instanceIndex, eventSurfaceIndex, out results); public bool WriteData( - int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte context = 0) + int instanceIndex, uint presetHash, byte eventSurfaceIndex, T newData, byte context = 0) where T : IPackerObjectData, new() { StaticDataAccess.Write(instanceIndex, presetHash, eventSurfaceIndex, newData, context); @@ -153,7 +153,7 @@ public bool WriteData( } public void Delta( - int instanceIndex, uint presetHash, int eventSurfaceIndex, TDispatch delta) + int instanceIndex, uint presetHash, byte eventSurfaceIndex, TDispatch delta) where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new(){ StaticDataAccess.Delta(instanceIndex, presetHash, eventSurfaceIndex, delta); diff --git a/Runtime/Objects/ObjectPreset.cs b/Runtime/Objects/ObjectPreset.cs index 3b7807d..b50d8d8 100644 --- a/Runtime/Objects/ObjectPreset.cs +++ b/Runtime/Objects/ObjectPreset.cs @@ -34,7 +34,7 @@ private void RewireModules() { if (surfaceModules == null) return; - for (var i = 0; i < surfaceModules.Count; i++) { + for (byte i = 0; i < surfaceModules.Count; i++) { var surface = surfaceModules[i]; if (surface?.presetModules == null) @@ -45,7 +45,7 @@ private void RewireModules() { } } - public bool TryGetModule(out T result, int surfaceIndex = 0) where T : class { + public bool TryGetModule(out T result, byte surfaceIndex = 0) where T : class { if (TryGetModules(out var results, surfaceIndex)) { result = results[0]; return true; @@ -55,7 +55,7 @@ public bool TryGetModule(out T result, int surfaceIndex = 0) where T : class return false; } - public bool TryGetModules(out IReadOnlyList results, int surfaceIndex = 0) where T : class { + public bool TryGetModules(out IReadOnlyList results, byte surfaceIndex = 0) where T : class { results = Array.Empty(); var matches = new List(); diff --git a/Runtime/Surfaces/StaticEventSurface.cs b/Runtime/Surfaces/StaticEventSurface.cs index 2ccb88e..1e63671 100644 --- a/Runtime/Surfaces/StaticEventSurface.cs +++ b/Runtime/Surfaces/StaticEventSurface.cs @@ -14,7 +14,7 @@ namespace Spellbound.Core.Surfaces { [RequireComponent(typeof(Collider))] public class StaticEventSurface : MonoBehaviour, IEventSurface { [SerializeField, Tooltip("Decide your own surface index schema.")] - private int surfaceIndex = -1; + private byte surfaceIndex; public Vector3 Position => transform.position; From 2329585bbd3aca1e1f147d73843ac7d668a16b5a Mon Sep 17 00:00:00 2001 From: Judsin Date: Mon, 6 Jul 2026 20:13:01 -0500 Subject: [PATCH 05/19] added a serialize ref dropdown attribute for inspector capability --- Editor/SerializeReferenceDropdownDrawer.cs | 99 +++++++++++++++++++ .../SerializeReferenceDropdownAttribute.cs | 11 +++ ...erializeReferenceDropdownAttribute.cs.meta | 2 + 3 files changed, 112 insertions(+) create mode 100644 Editor/SerializeReferenceDropdownDrawer.cs create mode 100644 Runtime/Tooling/SerializeReferenceDropdownAttribute.cs create mode 100644 Runtime/Tooling/SerializeReferenceDropdownAttribute.cs.meta diff --git a/Editor/SerializeReferenceDropdownDrawer.cs b/Editor/SerializeReferenceDropdownDrawer.cs new file mode 100644 index 0000000..4273b69 --- /dev/null +++ b/Editor/SerializeReferenceDropdownDrawer.cs @@ -0,0 +1,99 @@ +// Copyright 2026 Spellbound Studio Inc. + +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using Spellbound.Core.Tooling; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace Spellbound.Core { + /// + /// UI Toolkit drawer for : a dropdown of every + /// concrete type derived from the field's declared base, with the chosen instance's serialized + /// fields rendered beneath it. + /// + [CustomPropertyDrawer(typeof(SerializeReferenceDropdownAttribute))] + public sealed class SerializeReferenceDropdownDrawer : PropertyDrawer { + private const string NoneChoice = "None"; + + public override VisualElement CreatePropertyGUI(SerializedProperty property) { + var root = new VisualElement(); + var typesByName = new Dictionary(); + var choices = new List { NoneChoice }; + var baseType = ResolveBaseType(property); + + if (baseType != null) { + foreach (var type in TypeCache.GetTypesDerivedFrom(baseType)) { + if (type.IsAbstract || type.IsGenericTypeDefinition) + continue; + + typesByName[type.Name] = type; + choices.Add(type.Name); + } + } + + var dropdown = new DropdownField(property.displayName, choices, CurrentChoice(property)); + var fields = new VisualElement { style = { marginLeft = 14 } }; + root.Add(dropdown); + root.Add(fields); + + dropdown.RegisterValueChangedCallback(changed => { + property.serializedObject.Update(); + property.managedReferenceValue = + changed.newValue != NoneChoice && typesByName.TryGetValue(changed.newValue, out var picked) + ? Activator.CreateInstance(picked) + : null; + property.serializedObject.ApplyModifiedProperties(); + RebuildFields(property, fields); + }); + + root.TrackPropertyValue(property, tracked => { + var choice = CurrentChoice(tracked); + + if (dropdown.value != choice) { + dropdown.SetValueWithoutNotify(choice); + RebuildFields(tracked, fields); + } + }); + + RebuildFields(property, fields); + + return root; + } + + private static string CurrentChoice(SerializedProperty property) => + property.managedReferenceValue?.GetType().Name ?? NoneChoice; + + private static void RebuildFields(SerializedProperty property, VisualElement fields) { + fields.Clear(); + + if (property.managedReferenceValue == null) + return; + + var end = property.GetEndProperty(); + var child = property.Copy(); + var enterChildren = true; + + while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) { + enterChildren = false; + fields.Add(new PropertyField(child.Copy())); + } + + fields.Bind(property.serializedObject); + } + + private static Type ResolveBaseType(SerializedProperty property) { + var typename = property.managedReferenceFieldTypename; + + if (string.IsNullOrEmpty(typename)) + return null; + + var parts = typename.Split(' '); + + return parts.Length == 2 ? Type.GetType($"{parts[1]}, {parts[0]}") : null; + } + } +} +#endif diff --git a/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs b/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs new file mode 100644 index 0000000..dbd347d --- /dev/null +++ b/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs @@ -0,0 +1,11 @@ +// Copyright 2026 Spellbound Studio Inc. + +using UnityEngine; + +namespace Spellbound.Core.Tooling { + /// + /// Draws a SerializeReference field as a concrete-type dropdown with the chosen instance's fields + /// below it. Apply alongside SerializeReference; on a list it applies to each element. + /// + public sealed class SerializeReferenceDropdownAttribute : PropertyAttribute { } +} diff --git a/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs.meta b/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs.meta new file mode 100644 index 0000000..3476548 --- /dev/null +++ b/Runtime/Tooling/SerializeReferenceDropdownAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 08e9ae3c40ad8404cb84b913c2b926da \ No newline at end of file From eef1d23a637e7f14fc688c584a06756869e0e7ee Mon Sep 17 00:00:00 2001 From: Judsin Date: Mon, 6 Jul 2026 20:38:31 -0500 Subject: [PATCH 06/19] ready for merge --- Editor/SerializeReferenceDropdownDrawer.cs.meta | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Editor/SerializeReferenceDropdownDrawer.cs.meta diff --git a/Editor/SerializeReferenceDropdownDrawer.cs.meta b/Editor/SerializeReferenceDropdownDrawer.cs.meta new file mode 100644 index 0000000..d6db454 --- /dev/null +++ b/Editor/SerializeReferenceDropdownDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64d707124c918aa43871cca940eae715 \ No newline at end of file From 31fb207ae03e41b0b34c5529415890a36598f368 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 10:40:46 -0500 Subject: [PATCH 07/19] adding assembly defs and clean up for packing --- Editor/LogConfigEditor.cs | 594 +++++++++--------- Editor/ObjectPresetEditor.cs | 686 ++++++++++----------- Editor/SerializeReferenceDropdownDrawer.cs | 196 +++--- Editor/Spellbound.Core.Editor.asmdef | 18 + Editor/Spellbound.Core.Editor.asmdef.meta | 7 + Runtime/Packing/Packer.cs | 51 +- package.json | 1 - 7 files changed, 792 insertions(+), 761 deletions(-) create mode 100644 Editor/Spellbound.Core.Editor.asmdef create mode 100644 Editor/Spellbound.Core.Editor.asmdef.meta diff --git a/Editor/LogConfigEditor.cs b/Editor/LogConfigEditor.cs index 0395ced..261933e 100644 --- a/Editor/LogConfigEditor.cs +++ b/Editor/LogConfigEditor.cs @@ -1,298 +1,296 @@ -// Copyright 2026 Spellbound Studio Inc. - -// === LogConfigEditor.cs === -// Copyright 2026 Spellbound Studio Inc. - -#if UNITY_EDITOR -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEditor; -using UnityEditor.Build; -using UnityEngine; - -namespace Spellbound.Core.Logging.Editor { - [CustomEditor(typeof(LogConfig))] - public class LogConfigEditor : UnityEditor.Editor { - private const string DefinePrefix = "SPELLBOUND_LOG_"; - private const string DefineVerbose = "SPELLBOUND_LOG_VERBOSE"; - private const string DefineDebug = "SPELLBOUND_LOG_DEBUG"; - private const string DefineInfo = "SPELLBOUND_LOG_INFO"; - private const string DefineWarning = "SPELLBOUND_LOG_WARNING"; - - private const string GlobalLabel = "Log Level"; - private const string FileLabel = "File Output"; - private const string FileNameLabel = "Log File Name"; - private const string UndoFileNameLabel = "Change Log File Name"; - private const string HeaderLabel = "Spellbound Log Configuration"; - private const string SinksLabel = "Sinks"; - private const string UndoLevelLabel = "Change Global Log Level"; - private const string UndoSinkLabel = "Toggle Log Sink"; - private const string ApplyLabel = "Apply"; - private const string LevelSubtitle = "Minimum severity compiled into all Spellbound packages."; - private const string FileSubtitle = "Name of the log file written to the persistent data path."; - private const string SinksSubtitle = "Toggle where log output is routed."; - private const string UndoSinkFilterLabel = "Change Sink Filter Level"; - private const string SinkFilterLabel = " Filter Level"; - - private const string LevelDescVerbose = "Compiles all log calls: Verbose, Debug, Info, Warning, and Error."; - private const string LevelDescDebug = "Compiles Debug, Info, Warning, and Error. Strips Verbose."; - private const string LevelDescInfo = "Compiles Info, Warning, and Error. Strips Debug and below."; - private const string LevelDescWarning = "Compiles Warning and Error only. Strips Info and below."; - private const string LevelDescError = "Error only. All other log calls are stripped from the build."; - private const string LevelDescNone = "Strips all log calls except Error. Equivalent to Error."; - - private static GUIStyle _headerStyle; - private static GUIStyle _subtitleStyle; - private static GUIStyle _sectionStyle; - - private static void InitStyles() { - if (_headerStyle != null) - return; - - _headerStyle = new GUIStyle(EditorStyles.boldLabel) { - fontSize = 14, - padding = new RectOffset(0, 0, 4, 4) - }; - - _subtitleStyle = new GUIStyle(EditorStyles.miniLabel) { - wordWrap = true, - padding = new RectOffset(2, 0, 0, 4) - }; - - _sectionStyle = new GUIStyle("box") { - padding = new RectOffset(10, 10, 8, 8), - margin = new RectOffset(0, 0, 4, 4) - }; - } - - public override void OnInspectorGUI() { - InitStyles(); - var config = (LogConfig)target; - - EditorGUILayout.LabelField(HeaderLabel, _headerStyle); - EditorGUILayout.Space(4); - - DrawLevelSection(config); - EditorGUILayout.Space(4); - DrawFileSection(config); - EditorGUILayout.Space(4); - DrawSinksSection(config); - EditorGUILayout.Space(8); - - if (!GUILayout.Button(ApplyLabel, GUILayout.Height(28))) - return; - - EditorUtility.SetDirty(config); - ApplyDefines(config); - } - - private static void DrawLevelSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(GlobalLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(LevelSubtitle, _subtitleStyle); - - var newLevel = (LogLevel)EditorGUILayout.EnumPopup(config.globalLevel); - - if (newLevel != config.globalLevel) { - Undo.RecordObject(config, UndoLevelLabel); - config.globalLevel = newLevel; - } - - EditorGUILayout.LabelField(GetLevelDescription(config.globalLevel), _subtitleStyle); - - EditorGUILayout.EndVertical(); - } - - private static string GetLevelDescription(LogLevel level) => - level switch { - LogLevel.Verbose => LevelDescVerbose, - LogLevel.Debug => LevelDescDebug, - LogLevel.Info => LevelDescInfo, - LogLevel.Warning => LevelDescWarning, - LogLevel.Error => LevelDescError, - LogLevel.None => LevelDescNone, - _ => "" - }; - - private static void DrawFileSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(FileLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(FileSubtitle, _subtitleStyle); - - var newFileName = EditorGUILayout.TextField(FileNameLabel, config.logFileName); - - if (newFileName != config.logFileName) { - Undo.RecordObject(config, UndoFileNameLabel); - config.logFileName = newFileName; - } - - EditorGUILayout.EndVertical(); - } - - private static void DrawSinksSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(SinksLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(SinksSubtitle, _subtitleStyle); - - SyncSinkEntries(config, DiscoverSinks()); - - for (var i = 0; i < config.sinks.Length; i++) { - var entry = config.sinks[i]; - - var newEnabled = EditorGUILayout.Toggle(entry.displayName, entry.enabled); - - if (newEnabled != entry.enabled) { - Undo.RecordObject(config, UndoSinkLabel); - config.sinks[i].enabled = newEnabled; - } - - var newFilter = DrawFilteredLevelPopup(SinkFilterLabel, entry.filterLevel, config.globalLevel); - - if (newFilter != entry.filterLevel) { - Undo.RecordObject(config, UndoSinkFilterLabel); - config.sinks[i].filterLevel = newFilter; - } - - if (i < config.sinks.Length - 1) - EditorGUILayout.Space(4); - } - - EditorGUILayout.EndVertical(); - } - - private static List DiscoverSinks() { - var sinks = new List(); - - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - foreach (var type in asm.GetTypes()) { - if (!typeof(ILogSink).IsAssignableFrom(type)) - continue; - - if (type.IsAbstract || type.IsInterface) - continue; - - if (type.GetConstructor(Type.EmptyTypes) == null) - continue; - - var instance = (ILogSink)Activator.CreateInstance(type); - - sinks.Add(new DiscoveredSink { - QualifiedTypeName = type.AssemblyQualifiedName, - DisplayName = instance.DisplayName - }); - } - } - - sinks.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.Ordinal)); - - return sinks; - } - - private static void SyncSinkEntries(LogConfig config, List discovered) { - var existing = new HashSet(config.sinks.Select(s => s.qualifiedTypeName)); - var updated = new List(config.sinks); - - foreach (var sink in discovered) { - if (existing.Contains(sink.QualifiedTypeName)) - continue; - - updated.Add(new SinkEntry { - qualifiedTypeName = sink.QualifiedTypeName, - displayName = sink.DisplayName, - enabled = false - }); - } - - config.sinks = updated.ToArray(); - } - - private static void ApplyDefines(LogConfig config) { - var defines = new HashSet(); - AddDefinesForLevel(defines, config.globalLevel); - - var target = NamedBuildTarget.FromBuildTargetGroup( - EditorUserBuildSettings.selectedBuildTargetGroup - ); - - PlayerSettings.GetScriptingDefineSymbols(target, out var current); - var preserved = current.Where(d => !d.StartsWith(DefinePrefix)).ToList(); - preserved.AddRange(defines); - - PlayerSettings.SetScriptingDefineSymbols(target, preserved.ToArray()); - Debug.Log($"[LogConfig] Applied log level: {config.globalLevel}"); - } - - // Each case adds defines for its level and everything above it. - // Error has no [Conditional] attribute so it always compiles — no define needed. - private static void AddDefinesForLevel(HashSet defines, LogLevel level) { - switch (level) { - case LogLevel.Verbose: - defines.Add(DefineVerbose); - defines.Add(DefineDebug); - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Debug: - defines.Add(DefineDebug); - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Info: - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Warning: - defines.Add(DefineWarning); - - break; - case LogLevel.Error: - case LogLevel.None: - break; - default: - Debug.LogError($"[LogConfigEditor] Unhandled log level: {level}"); - - break; - } - } - - private static LogLevel DrawFilteredLevelPopup(string label, LogLevel current, LogLevel floor) { - // Build arrays of only valid options (at or above the global level) - var validLevels = new List(); - var validNames = new List(); - - foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) { - if (level < floor) - continue; - - if (level == LogLevel.None) - continue; - - validLevels.Add(level); - validNames.Add(level.ToString()); - } - - // Find current selection index, clamp to floor if invalid - var currentIndex = validLevels.IndexOf(current); - - if (currentIndex < 0) - currentIndex = 0; - - var newIndex = EditorGUILayout.Popup(label, currentIndex, validNames.ToArray()); - - return validLevels[newIndex]; - } - - private struct DiscoveredSink { - public string QualifiedTypeName; - public string DisplayName; - } - } -} -#endif \ No newline at end of file +// Copyright 2026 Spellbound Studio Inc. + +// === LogConfigEditor.cs === +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEngine; + +namespace Spellbound.Core.Logging.Editor { + [CustomEditor(typeof(LogConfig))] + public class LogConfigEditor : UnityEditor.Editor { + private const string DefinePrefix = "SPELLBOUND_LOG_"; + private const string DefineVerbose = "SPELLBOUND_LOG_VERBOSE"; + private const string DefineDebug = "SPELLBOUND_LOG_DEBUG"; + private const string DefineInfo = "SPELLBOUND_LOG_INFO"; + private const string DefineWarning = "SPELLBOUND_LOG_WARNING"; + + private const string GlobalLabel = "Log Level"; + private const string FileLabel = "File Output"; + private const string FileNameLabel = "Log File Name"; + private const string UndoFileNameLabel = "Change Log File Name"; + private const string HeaderLabel = "Spellbound Log Configuration"; + private const string SinksLabel = "Sinks"; + private const string UndoLevelLabel = "Change Global Log Level"; + private const string UndoSinkLabel = "Toggle Log Sink"; + private const string ApplyLabel = "Apply"; + private const string LevelSubtitle = "Minimum severity compiled into all Spellbound packages."; + private const string FileSubtitle = "Name of the log file written to the persistent data path."; + private const string SinksSubtitle = "Toggle where log output is routed."; + private const string UndoSinkFilterLabel = "Change Sink Filter Level"; + private const string SinkFilterLabel = " Filter Level"; + + private const string LevelDescVerbose = "Compiles all log calls: Verbose, Debug, Info, Warning, and Error."; + private const string LevelDescDebug = "Compiles Debug, Info, Warning, and Error. Strips Verbose."; + private const string LevelDescInfo = "Compiles Info, Warning, and Error. Strips Debug and below."; + private const string LevelDescWarning = "Compiles Warning and Error only. Strips Info and below."; + private const string LevelDescError = "Error only. All other log calls are stripped from the build."; + private const string LevelDescNone = "Strips all log calls except Error. Equivalent to Error."; + + private static GUIStyle _headerStyle; + private static GUIStyle _subtitleStyle; + private static GUIStyle _sectionStyle; + + private static void InitStyles() { + if (_headerStyle != null) + return; + + _headerStyle = new GUIStyle(EditorStyles.boldLabel) { + fontSize = 14, + padding = new RectOffset(0, 0, 4, 4) + }; + + _subtitleStyle = new GUIStyle(EditorStyles.miniLabel) { + wordWrap = true, + padding = new RectOffset(2, 0, 0, 4) + }; + + _sectionStyle = new GUIStyle("box") { + padding = new RectOffset(10, 10, 8, 8), + margin = new RectOffset(0, 0, 4, 4) + }; + } + + public override void OnInspectorGUI() { + InitStyles(); + var config = (LogConfig)target; + + EditorGUILayout.LabelField(HeaderLabel, _headerStyle); + EditorGUILayout.Space(4); + + DrawLevelSection(config); + EditorGUILayout.Space(4); + DrawFileSection(config); + EditorGUILayout.Space(4); + DrawSinksSection(config); + EditorGUILayout.Space(8); + + if (!GUILayout.Button(ApplyLabel, GUILayout.Height(28))) + return; + + EditorUtility.SetDirty(config); + ApplyDefines(config); + } + + private static void DrawLevelSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(GlobalLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(LevelSubtitle, _subtitleStyle); + + var newLevel = (LogLevel)EditorGUILayout.EnumPopup(config.globalLevel); + + if (newLevel != config.globalLevel) { + Undo.RecordObject(config, UndoLevelLabel); + config.globalLevel = newLevel; + } + + EditorGUILayout.LabelField(GetLevelDescription(config.globalLevel), _subtitleStyle); + + EditorGUILayout.EndVertical(); + } + + private static string GetLevelDescription(LogLevel level) => + level switch { + LogLevel.Verbose => LevelDescVerbose, + LogLevel.Debug => LevelDescDebug, + LogLevel.Info => LevelDescInfo, + LogLevel.Warning => LevelDescWarning, + LogLevel.Error => LevelDescError, + LogLevel.None => LevelDescNone, + _ => "" + }; + + private static void DrawFileSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(FileLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(FileSubtitle, _subtitleStyle); + + var newFileName = EditorGUILayout.TextField(FileNameLabel, config.logFileName); + + if (newFileName != config.logFileName) { + Undo.RecordObject(config, UndoFileNameLabel); + config.logFileName = newFileName; + } + + EditorGUILayout.EndVertical(); + } + + private static void DrawSinksSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(SinksLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(SinksSubtitle, _subtitleStyle); + + SyncSinkEntries(config, DiscoverSinks()); + + for (var i = 0; i < config.sinks.Length; i++) { + var entry = config.sinks[i]; + + var newEnabled = EditorGUILayout.Toggle(entry.displayName, entry.enabled); + + if (newEnabled != entry.enabled) { + Undo.RecordObject(config, UndoSinkLabel); + config.sinks[i].enabled = newEnabled; + } + + var newFilter = DrawFilteredLevelPopup(SinkFilterLabel, entry.filterLevel, config.globalLevel); + + if (newFilter != entry.filterLevel) { + Undo.RecordObject(config, UndoSinkFilterLabel); + config.sinks[i].filterLevel = newFilter; + } + + if (i < config.sinks.Length - 1) + EditorGUILayout.Space(4); + } + + EditorGUILayout.EndVertical(); + } + + private static List DiscoverSinks() { + var sinks = new List(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { + foreach (var type in asm.GetTypes()) { + if (!typeof(ILogSink).IsAssignableFrom(type)) + continue; + + if (type.IsAbstract || type.IsInterface) + continue; + + if (type.GetConstructor(Type.EmptyTypes) == null) + continue; + + var instance = (ILogSink)Activator.CreateInstance(type); + + sinks.Add(new DiscoveredSink { + QualifiedTypeName = type.AssemblyQualifiedName, + DisplayName = instance.DisplayName + }); + } + } + + sinks.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.Ordinal)); + + return sinks; + } + + private static void SyncSinkEntries(LogConfig config, List discovered) { + var existing = new HashSet(config.sinks.Select(s => s.qualifiedTypeName)); + var updated = new List(config.sinks); + + foreach (var sink in discovered) { + if (existing.Contains(sink.QualifiedTypeName)) + continue; + + updated.Add(new SinkEntry { + qualifiedTypeName = sink.QualifiedTypeName, + displayName = sink.DisplayName, + enabled = false + }); + } + + config.sinks = updated.ToArray(); + } + + private static void ApplyDefines(LogConfig config) { + var defines = new HashSet(); + AddDefinesForLevel(defines, config.globalLevel); + + var target = NamedBuildTarget.FromBuildTargetGroup( + EditorUserBuildSettings.selectedBuildTargetGroup + ); + + PlayerSettings.GetScriptingDefineSymbols(target, out var current); + var preserved = current.Where(d => !d.StartsWith(DefinePrefix)).ToList(); + preserved.AddRange(defines); + + PlayerSettings.SetScriptingDefineSymbols(target, preserved.ToArray()); + Debug.Log($"[LogConfig] Applied log level: {config.globalLevel}"); + } + + // Each case adds defines for its level and everything above it. + // Error has no [Conditional] attribute so it always compiles — no define needed. + private static void AddDefinesForLevel(HashSet defines, LogLevel level) { + switch (level) { + case LogLevel.Verbose: + defines.Add(DefineVerbose); + defines.Add(DefineDebug); + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Debug: + defines.Add(DefineDebug); + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Info: + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Warning: + defines.Add(DefineWarning); + + break; + case LogLevel.Error: + case LogLevel.None: + break; + default: + Debug.LogError($"[LogConfigEditor] Unhandled log level: {level}"); + + break; + } + } + + private static LogLevel DrawFilteredLevelPopup(string label, LogLevel current, LogLevel floor) { + // Build arrays of only valid options (at or above the global level) + var validLevels = new List(); + var validNames = new List(); + + foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) { + if (level < floor) + continue; + + if (level == LogLevel.None) + continue; + + validLevels.Add(level); + validNames.Add(level.ToString()); + } + + // Find current selection index, clamp to floor if invalid + var currentIndex = validLevels.IndexOf(current); + + if (currentIndex < 0) + currentIndex = 0; + + var newIndex = EditorGUILayout.Popup(label, currentIndex, validNames.ToArray()); + + return validLevels[newIndex]; + } + + private struct DiscoveredSink { + public string QualifiedTypeName; + public string DisplayName; + } + } +} diff --git a/Editor/ObjectPresetEditor.cs b/Editor/ObjectPresetEditor.cs index e25d3a2..c64c611 100644 --- a/Editor/ObjectPresetEditor.cs +++ b/Editor/ObjectPresetEditor.cs @@ -1,344 +1,342 @@ -// Copyright 2026 Spellbound Studio Inc. - -#if UNITY_EDITOR -using System; -using System.Collections.Generic; -using Spellbound.Core.Modules; -using Spellbound.Core.Objects; -using UnityEditor; -using UnityEditor.UIElements; -using UnityEngine; -using UnityEngine.UIElements; - -namespace Spellbound.Core { - /// - /// UI Toolkit inspector for . Renders the default fields, then a custom block of - /// surface modules where each surface is a card containing a header (name + remove button) and a list of - /// s. Module rows route to whatever CustomPropertyDrawer is registered for - /// the concrete module type — that's how DamageableModule's bespoke layout shows up. - /// - [CustomEditor(typeof(ObjectPreset))] - public sealed class ObjectPresetEditor : Editor { - private const string FieldSurfaceModules = "surfaceModules"; - private const string FieldPresetModules = "presetModules"; - private const string FieldSurfaceName = "surfaceName"; - - public override VisualElement CreateInspectorGUI() { - var root = new VisualElement(); - root.style.marginTop = 4; - - BuildDefaultFields(root); - root.Add(Spacer(8)); - - var header = new Label("Surface Modules") { - style = { - unityFontStyleAndWeight = FontStyle.Bold, - fontSize = 14, - marginBottom = 4 - } - }; - - root.Add(header); - - var surfacesProp = serializedObject.FindProperty(FieldSurfaceModules); - - if (surfacesProp == null) { - root.Add(new HelpBox( - $"ObjectPreset has no '{FieldSurfaceModules}' field — editor field name has drifted.", - HelpBoxMessageType.Error)); - - return root; - } - - var surfacesContainer = new VisualElement(); - root.Add(surfacesContainer); - - void RenderAllSurfaces() { - serializedObject.Update(); - surfacesContainer.Clear(); - - for (var i = 0; i < surfacesProp.arraySize; i++) - surfacesContainer.Add(BuildSurfaceCard(surfacesProp, i, RenderAllSurfaces)); - } - - RenderAllSurfaces(); - - var addSurfaceBtn = new Button(() => { - surfacesProp.InsertArrayElementAtIndex(surfacesProp.arraySize); - var newSurface = surfacesProp.GetArrayElementAtIndex(surfacesProp.arraySize - 1); - newSurface.FindPropertyRelative(FieldSurfaceName).stringValue = "New Surface"; - newSurface.FindPropertyRelative(FieldPresetModules).ClearArray(); - serializedObject.ApplyModifiedProperties(); - PersistStructuralChange(); - RenderAllSurfaces(); - }) { - text = "Add Surface", - style = { marginTop = 6 } - }; - - root.Add(addSurfaceBtn); - - return root; - } - - private void BuildDefaultFields(VisualElement root) { - var iterator = serializedObject.GetIterator(); - - if (!iterator.NextVisible(true)) - return; - - do { - if (iterator.name == "m_Script") - continue; - - if (iterator.name == FieldSurfaceModules) - continue; - - var field = new PropertyField(iterator.Copy()); - field.Bind(serializedObject); - root.Add(field); - } while (iterator.NextVisible(false)); - } - - private VisualElement BuildSurfaceCard(SerializedProperty surfacesProp, int index, Action onChanged) { - var capturedIndex = index; - var surfaceProp = surfacesProp.GetArrayElementAtIndex(index); - var nameProp = surfaceProp.FindPropertyRelative(FieldSurfaceName); - var modulesProp = surfaceProp.FindPropertyRelative(FieldPresetModules); - - var surfaceAccent = new Color(0.4f, 0.6f, 0.9f); - - var card = new VisualElement { - style = { - borderLeftWidth = 4, - borderLeftColor = surfaceAccent, - borderTopWidth = 1, - borderRightWidth = 1, - borderBottomWidth = 1, - borderTopColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), - borderRightColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), - borderBottomColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), - backgroundColor = new Color(0f, 0f, 0f, 0.04f), - paddingTop = 8, - paddingBottom = 8, - paddingLeft = 10, - paddingRight = 10, - marginBottom = 12 - } - }; - - var header = new VisualElement { - style = { - flexDirection = FlexDirection.Row, - alignItems = Align.Center, - marginBottom = 4 - } - }; - - var nameField = new PropertyField(nameProp, $"Surface {index}"); - nameField.style.flexGrow = 1; - nameField.Bind(serializedObject); - header.Add(nameField); - - var removeSurfaceBtn = new Button(() => { - surfacesProp.DeleteArrayElementAtIndex(capturedIndex); - serializedObject.ApplyModifiedProperties(); - PersistStructuralChange(); - onChanged(); - }) { - text = "Remove Surface", - style = { - marginLeft = 6 - } - }; - - header.Add(removeSurfaceBtn); - card.Add(header); - - var modulesContainer = new VisualElement { - style = { - marginLeft = 4, - marginTop = 2 - } - }; - - card.Add(modulesContainer); - - void RenderAllModules() { - serializedObject.Update(); - modulesContainer.Clear(); - - for (var j = 0; j < modulesProp.arraySize; j++) - modulesContainer.Add(BuildModuleRow(modulesProp, j, RenderAllModules)); - } - - RenderAllModules(); - - var addModuleBtn = new Button(() => ShowModuleAddMenu(modulesProp, RenderAllModules)) { - text = "Add Module", - style = { marginTop = 4 } - }; - - card.Add(addModuleBtn); - - return card; - } - - private VisualElement BuildModuleRow(SerializedProperty modulesProp, int index, Action onChanged) { - var capturedIndex = index; - var moduleProp = modulesProp.GetArrayElementAtIndex(index); - - var moduleAccent = new Color(0.3f, 0.7f, 0.4f); - - var row = new VisualElement { - style = { - marginTop = 8, - marginBottom = 4, - paddingTop = 8, - paddingBottom = 10, - paddingLeft = 10, - paddingRight = 10, - borderLeftWidth = 4, - borderLeftColor = moduleAccent, - borderTopWidth = 1, - borderRightWidth = 1, - borderBottomWidth = 1, - borderTopColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), - borderRightColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), - borderBottomColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), - backgroundColor = new Color(0f, 0f, 0f, 0.12f) - } - }; - - var header = new VisualElement { - style = { - flexDirection = FlexDirection.Row, - alignItems = Align.Center, - marginBottom = 6, - paddingBottom = 6, - borderBottomWidth = 1, - borderBottomColor = new Color(1f, 1f, 1f, 0.08f) - } - }; - - var typeLabel = new Label(GetNiceTypeName(moduleProp)) { - style = { - flexGrow = 1, - unityFontStyleAndWeight = FontStyle.Bold, - fontSize = 14, - color = new Color(0.86f, 0.96f, 0.86f) - } - }; - - header.Add(typeLabel); - - var removeBtn = new Button(() => { - modulesProp.DeleteArrayElementAtIndex(capturedIndex); - serializedObject.ApplyModifiedProperties(); - PersistStructuralChange(); - onChanged(); - }) { - text = "Remove", - style = { marginLeft = 4 } - }; - - header.Add(removeBtn); - row.Add(header); - - if (moduleProp.managedReferenceValue == null) { - var emptyLabel = new Label("(null managed reference)") { - style = { - color = new Color(0.85f, 0.5f, 0.3f), - unityFontStyleAndWeight = FontStyle.Italic - } - }; - - row.Add(emptyLabel); - - return row; - } - - // PropertyField for the managed-reference element. If a CustomPropertyDrawer is registered for the - // concrete module type, its CreatePropertyGUI runs here. Otherwise Unity's default UI Toolkit - // rendering shows the fields. - var moduleField = new PropertyField(moduleProp, string.Empty); - moduleField.Bind(serializedObject); - row.Add(moduleField); - - return row; - } - - private void ShowModuleAddMenu(SerializedProperty modulesProp, Action onChanged) { - var existing = new HashSet(); - - for (var i = 0; i < modulesProp.arraySize; i++) { - var el = modulesProp.GetArrayElementAtIndex(i).managedReferenceValue; - - if (el != null) - existing.Add(el.GetType()); - } - - var menu = new GenericMenu(); - var anyAvailable = false; - - foreach (var type in TypeCache.GetTypesDerivedFrom()) { - if (type.IsAbstract) - continue; - - anyAvailable = true; - var capturedType = type; - var label = new GUIContent(type.Name); - - if (existing.Contains(type)) { - menu.AddDisabledItem(label); - - continue; - } - - menu.AddItem(label, false, () => { - modulesProp.arraySize++; - var element = modulesProp.GetArrayElementAtIndex(modulesProp.arraySize - 1); - element.managedReferenceValue = Activator.CreateInstance(capturedType); - serializedObject.ApplyModifiedProperties(); - PersistStructuralChange(); - onChanged(); - }); - } - - if (!anyAvailable) - menu.AddDisabledItem(new GUIContent("No PresetModule types found")); - - menu.ShowAsContext(); - } - - private void PersistStructuralChange() { - EditorUtility.SetDirty(target); - AssetDatabase.SaveAssetIfDirty(target); - } - - private static string GetNiceTypeName(SerializedProperty prop) { - if (prop.managedReferenceValue == null) - return "Missing"; - - var full = prop.managedReferenceFullTypename; - var lastSpace = full.LastIndexOf(' '); - - if (lastSpace >= 0) - full = full[(lastSpace + 1)..]; - - var lastDot = full.LastIndexOf('.'); - - return lastDot >= 0 - ? full[(lastDot + 1)..] - : full; - } - - private static VisualElement Spacer(float height) { - var v = new VisualElement(); - v.style.height = height; - - return v; - } - } -} -#endif \ No newline at end of file +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using Spellbound.Core.Modules; +using Spellbound.Core.Objects; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Spellbound.Core { + /// + /// UI Toolkit inspector for . Renders the default fields, then a custom block of + /// surface modules where each surface is a card containing a header (name + remove button) and a list of + /// s. Module rows route to whatever CustomPropertyDrawer is registered for + /// the concrete module type — that's how DamageableModule's bespoke layout shows up. + /// + [CustomEditor(typeof(ObjectPreset))] + public sealed class ObjectPresetEditor : Editor { + private const string FieldSurfaceModules = "surfaceModules"; + private const string FieldPresetModules = "presetModules"; + private const string FieldSurfaceName = "surfaceName"; + + public override VisualElement CreateInspectorGUI() { + var root = new VisualElement(); + root.style.marginTop = 4; + + BuildDefaultFields(root); + root.Add(Spacer(8)); + + var header = new Label("Surface Modules") { + style = { + unityFontStyleAndWeight = FontStyle.Bold, + fontSize = 14, + marginBottom = 4 + } + }; + + root.Add(header); + + var surfacesProp = serializedObject.FindProperty(FieldSurfaceModules); + + if (surfacesProp == null) { + root.Add(new HelpBox( + $"ObjectPreset has no '{FieldSurfaceModules}' field — editor field name has drifted.", + HelpBoxMessageType.Error)); + + return root; + } + + var surfacesContainer = new VisualElement(); + root.Add(surfacesContainer); + + void RenderAllSurfaces() { + serializedObject.Update(); + surfacesContainer.Clear(); + + for (var i = 0; i < surfacesProp.arraySize; i++) + surfacesContainer.Add(BuildSurfaceCard(surfacesProp, i, RenderAllSurfaces)); + } + + RenderAllSurfaces(); + + var addSurfaceBtn = new Button(() => { + surfacesProp.InsertArrayElementAtIndex(surfacesProp.arraySize); + var newSurface = surfacesProp.GetArrayElementAtIndex(surfacesProp.arraySize - 1); + newSurface.FindPropertyRelative(FieldSurfaceName).stringValue = "New Surface"; + newSurface.FindPropertyRelative(FieldPresetModules).ClearArray(); + serializedObject.ApplyModifiedProperties(); + PersistStructuralChange(); + RenderAllSurfaces(); + }) { + text = "Add Surface", + style = { marginTop = 6 } + }; + + root.Add(addSurfaceBtn); + + return root; + } + + private void BuildDefaultFields(VisualElement root) { + var iterator = serializedObject.GetIterator(); + + if (!iterator.NextVisible(true)) + return; + + do { + if (iterator.name == "m_Script") + continue; + + if (iterator.name == FieldSurfaceModules) + continue; + + var field = new PropertyField(iterator.Copy()); + field.Bind(serializedObject); + root.Add(field); + } while (iterator.NextVisible(false)); + } + + private VisualElement BuildSurfaceCard(SerializedProperty surfacesProp, int index, Action onChanged) { + var capturedIndex = index; + var surfaceProp = surfacesProp.GetArrayElementAtIndex(index); + var nameProp = surfaceProp.FindPropertyRelative(FieldSurfaceName); + var modulesProp = surfaceProp.FindPropertyRelative(FieldPresetModules); + + var surfaceAccent = new Color(0.4f, 0.6f, 0.9f); + + var card = new VisualElement { + style = { + borderLeftWidth = 4, + borderLeftColor = surfaceAccent, + borderTopWidth = 1, + borderRightWidth = 1, + borderBottomWidth = 1, + borderTopColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), + borderRightColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), + borderBottomColor = new Color(surfaceAccent.r, surfaceAccent.g, surfaceAccent.b, 0.3f), + backgroundColor = new Color(0f, 0f, 0f, 0.04f), + paddingTop = 8, + paddingBottom = 8, + paddingLeft = 10, + paddingRight = 10, + marginBottom = 12 + } + }; + + var header = new VisualElement { + style = { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + marginBottom = 4 + } + }; + + var nameField = new PropertyField(nameProp, $"Surface {index}"); + nameField.style.flexGrow = 1; + nameField.Bind(serializedObject); + header.Add(nameField); + + var removeSurfaceBtn = new Button(() => { + surfacesProp.DeleteArrayElementAtIndex(capturedIndex); + serializedObject.ApplyModifiedProperties(); + PersistStructuralChange(); + onChanged(); + }) { + text = "Remove Surface", + style = { + marginLeft = 6 + } + }; + + header.Add(removeSurfaceBtn); + card.Add(header); + + var modulesContainer = new VisualElement { + style = { + marginLeft = 4, + marginTop = 2 + } + }; + + card.Add(modulesContainer); + + void RenderAllModules() { + serializedObject.Update(); + modulesContainer.Clear(); + + for (var j = 0; j < modulesProp.arraySize; j++) + modulesContainer.Add(BuildModuleRow(modulesProp, j, RenderAllModules)); + } + + RenderAllModules(); + + var addModuleBtn = new Button(() => ShowModuleAddMenu(modulesProp, RenderAllModules)) { + text = "Add Module", + style = { marginTop = 4 } + }; + + card.Add(addModuleBtn); + + return card; + } + + private VisualElement BuildModuleRow(SerializedProperty modulesProp, int index, Action onChanged) { + var capturedIndex = index; + var moduleProp = modulesProp.GetArrayElementAtIndex(index); + + var moduleAccent = new Color(0.3f, 0.7f, 0.4f); + + var row = new VisualElement { + style = { + marginTop = 8, + marginBottom = 4, + paddingTop = 8, + paddingBottom = 10, + paddingLeft = 10, + paddingRight = 10, + borderLeftWidth = 4, + borderLeftColor = moduleAccent, + borderTopWidth = 1, + borderRightWidth = 1, + borderBottomWidth = 1, + borderTopColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), + borderRightColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), + borderBottomColor = new Color(moduleAccent.r, moduleAccent.g, moduleAccent.b, 0.25f), + backgroundColor = new Color(0f, 0f, 0f, 0.12f) + } + }; + + var header = new VisualElement { + style = { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + marginBottom = 6, + paddingBottom = 6, + borderBottomWidth = 1, + borderBottomColor = new Color(1f, 1f, 1f, 0.08f) + } + }; + + var typeLabel = new Label(GetNiceTypeName(moduleProp)) { + style = { + flexGrow = 1, + unityFontStyleAndWeight = FontStyle.Bold, + fontSize = 14, + color = new Color(0.86f, 0.96f, 0.86f) + } + }; + + header.Add(typeLabel); + + var removeBtn = new Button(() => { + modulesProp.DeleteArrayElementAtIndex(capturedIndex); + serializedObject.ApplyModifiedProperties(); + PersistStructuralChange(); + onChanged(); + }) { + text = "Remove", + style = { marginLeft = 4 } + }; + + header.Add(removeBtn); + row.Add(header); + + if (moduleProp.managedReferenceValue == null) { + var emptyLabel = new Label("(null managed reference)") { + style = { + color = new Color(0.85f, 0.5f, 0.3f), + unityFontStyleAndWeight = FontStyle.Italic + } + }; + + row.Add(emptyLabel); + + return row; + } + + // PropertyField for the managed-reference element. If a CustomPropertyDrawer is registered for the + // concrete module type, its CreatePropertyGUI runs here. Otherwise Unity's default UI Toolkit + // rendering shows the fields. + var moduleField = new PropertyField(moduleProp, string.Empty); + moduleField.Bind(serializedObject); + row.Add(moduleField); + + return row; + } + + private void ShowModuleAddMenu(SerializedProperty modulesProp, Action onChanged) { + var existing = new HashSet(); + + for (var i = 0; i < modulesProp.arraySize; i++) { + var el = modulesProp.GetArrayElementAtIndex(i).managedReferenceValue; + + if (el != null) + existing.Add(el.GetType()); + } + + var menu = new GenericMenu(); + var anyAvailable = false; + + foreach (var type in TypeCache.GetTypesDerivedFrom()) { + if (type.IsAbstract) + continue; + + anyAvailable = true; + var capturedType = type; + var label = new GUIContent(type.Name); + + if (existing.Contains(type)) { + menu.AddDisabledItem(label); + + continue; + } + + menu.AddItem(label, false, () => { + modulesProp.arraySize++; + var element = modulesProp.GetArrayElementAtIndex(modulesProp.arraySize - 1); + element.managedReferenceValue = Activator.CreateInstance(capturedType); + serializedObject.ApplyModifiedProperties(); + PersistStructuralChange(); + onChanged(); + }); + } + + if (!anyAvailable) + menu.AddDisabledItem(new GUIContent("No PresetModule types found")); + + menu.ShowAsContext(); + } + + private void PersistStructuralChange() { + EditorUtility.SetDirty(target); + AssetDatabase.SaveAssetIfDirty(target); + } + + private static string GetNiceTypeName(SerializedProperty prop) { + if (prop.managedReferenceValue == null) + return "Missing"; + + var full = prop.managedReferenceFullTypename; + var lastSpace = full.LastIndexOf(' '); + + if (lastSpace >= 0) + full = full[(lastSpace + 1)..]; + + var lastDot = full.LastIndexOf('.'); + + return lastDot >= 0 + ? full[(lastDot + 1)..] + : full; + } + + private static VisualElement Spacer(float height) { + var v = new VisualElement(); + v.style.height = height; + + return v; + } + } +} diff --git a/Editor/SerializeReferenceDropdownDrawer.cs b/Editor/SerializeReferenceDropdownDrawer.cs index 4273b69..5517937 100644 --- a/Editor/SerializeReferenceDropdownDrawer.cs +++ b/Editor/SerializeReferenceDropdownDrawer.cs @@ -1,99 +1,97 @@ -// Copyright 2026 Spellbound Studio Inc. - -#if UNITY_EDITOR -using System; -using System.Collections.Generic; -using Spellbound.Core.Tooling; -using UnityEditor; -using UnityEditor.UIElements; -using UnityEngine.UIElements; - -namespace Spellbound.Core { - /// - /// UI Toolkit drawer for : a dropdown of every - /// concrete type derived from the field's declared base, with the chosen instance's serialized - /// fields rendered beneath it. - /// - [CustomPropertyDrawer(typeof(SerializeReferenceDropdownAttribute))] - public sealed class SerializeReferenceDropdownDrawer : PropertyDrawer { - private const string NoneChoice = "None"; - - public override VisualElement CreatePropertyGUI(SerializedProperty property) { - var root = new VisualElement(); - var typesByName = new Dictionary(); - var choices = new List { NoneChoice }; - var baseType = ResolveBaseType(property); - - if (baseType != null) { - foreach (var type in TypeCache.GetTypesDerivedFrom(baseType)) { - if (type.IsAbstract || type.IsGenericTypeDefinition) - continue; - - typesByName[type.Name] = type; - choices.Add(type.Name); - } - } - - var dropdown = new DropdownField(property.displayName, choices, CurrentChoice(property)); - var fields = new VisualElement { style = { marginLeft = 14 } }; - root.Add(dropdown); - root.Add(fields); - - dropdown.RegisterValueChangedCallback(changed => { - property.serializedObject.Update(); - property.managedReferenceValue = - changed.newValue != NoneChoice && typesByName.TryGetValue(changed.newValue, out var picked) - ? Activator.CreateInstance(picked) - : null; - property.serializedObject.ApplyModifiedProperties(); - RebuildFields(property, fields); - }); - - root.TrackPropertyValue(property, tracked => { - var choice = CurrentChoice(tracked); - - if (dropdown.value != choice) { - dropdown.SetValueWithoutNotify(choice); - RebuildFields(tracked, fields); - } - }); - - RebuildFields(property, fields); - - return root; - } - - private static string CurrentChoice(SerializedProperty property) => - property.managedReferenceValue?.GetType().Name ?? NoneChoice; - - private static void RebuildFields(SerializedProperty property, VisualElement fields) { - fields.Clear(); - - if (property.managedReferenceValue == null) - return; - - var end = property.GetEndProperty(); - var child = property.Copy(); - var enterChildren = true; - - while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) { - enterChildren = false; - fields.Add(new PropertyField(child.Copy())); - } - - fields.Bind(property.serializedObject); - } - - private static Type ResolveBaseType(SerializedProperty property) { - var typename = property.managedReferenceFieldTypename; - - if (string.IsNullOrEmpty(typename)) - return null; - - var parts = typename.Split(' '); - - return parts.Length == 2 ? Type.GetType($"{parts[1]}, {parts[0]}") : null; - } - } -} -#endif +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using Spellbound.Core.Tooling; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace Spellbound.Core { + /// + /// UI Toolkit drawer for : a dropdown of every + /// concrete type derived from the field's declared base, with the chosen instance's serialized + /// fields rendered beneath it. + /// + [CustomPropertyDrawer(typeof(SerializeReferenceDropdownAttribute))] + public sealed class SerializeReferenceDropdownDrawer : PropertyDrawer { + private const string NoneChoice = "None"; + + public override VisualElement CreatePropertyGUI(SerializedProperty property) { + var root = new VisualElement(); + var typesByName = new Dictionary(); + var choices = new List { NoneChoice }; + var baseType = ResolveBaseType(property); + + if (baseType != null) { + foreach (var type in TypeCache.GetTypesDerivedFrom(baseType)) { + if (type.IsAbstract || type.IsGenericTypeDefinition) + continue; + + typesByName[type.Name] = type; + choices.Add(type.Name); + } + } + + var dropdown = new DropdownField(property.displayName, choices, CurrentChoice(property)); + var fields = new VisualElement { style = { marginLeft = 14 } }; + root.Add(dropdown); + root.Add(fields); + + dropdown.RegisterValueChangedCallback(changed => { + property.serializedObject.Update(); + property.managedReferenceValue = + changed.newValue != NoneChoice && typesByName.TryGetValue(changed.newValue, out var picked) + ? Activator.CreateInstance(picked) + : null; + property.serializedObject.ApplyModifiedProperties(); + RebuildFields(property, fields); + }); + + root.TrackPropertyValue(property, tracked => { + var choice = CurrentChoice(tracked); + + if (dropdown.value != choice) { + dropdown.SetValueWithoutNotify(choice); + RebuildFields(tracked, fields); + } + }); + + RebuildFields(property, fields); + + return root; + } + + private static string CurrentChoice(SerializedProperty property) => + property.managedReferenceValue?.GetType().Name ?? NoneChoice; + + private static void RebuildFields(SerializedProperty property, VisualElement fields) { + fields.Clear(); + + if (property.managedReferenceValue == null) + return; + + var end = property.GetEndProperty(); + var child = property.Copy(); + var enterChildren = true; + + while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) { + enterChildren = false; + fields.Add(new PropertyField(child.Copy())); + } + + fields.Bind(property.serializedObject); + } + + private static Type ResolveBaseType(SerializedProperty property) { + var typename = property.managedReferenceFieldTypename; + + if (string.IsNullOrEmpty(typename)) + return null; + + var parts = typename.Split(' '); + + return parts.Length == 2 ? Type.GetType($"{parts[1]}, {parts[0]}") : null; + } + } +} diff --git a/Editor/Spellbound.Core.Editor.asmdef b/Editor/Spellbound.Core.Editor.asmdef new file mode 100644 index 0000000..7289375 --- /dev/null +++ b/Editor/Spellbound.Core.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Spellbound.Core.Editor", + "rootNamespace": "", + "references": [ + "GUID:c14a5db03514b8d4ba10b621ed3627d5" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/Spellbound.Core.Editor.asmdef.meta b/Editor/Spellbound.Core.Editor.asmdef.meta new file mode 100644 index 0000000..c7034ed --- /dev/null +++ b/Editor/Spellbound.Core.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1f4239feb2806a34fbb5696f11af43f5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Packing/Packer.cs b/Runtime/Packing/Packer.cs index 269ea71..7896305 100644 --- a/Runtime/Packing/Packer.cs +++ b/Runtime/Packing/Packer.cs @@ -23,6 +23,11 @@ public static class Packer { // Max heap buffer allowed: 64 MB - tune as needed private const int MaxRentedBuffer = 1024 * 1024 * 64; + private static int _payloadSizeHint = StackBufferSize; + + private static void RecordPayloadSize(int written) => + _payloadSizeHint = written > StackBufferSize ? written : StackBufferSize; + #region Byte [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -325,11 +330,16 @@ public static ushort ReadUShortBitwise(ref ReadOnlySpan buffer) { #region Strings public static void WriteString(ref Span buffer, string value) { - value ??= string.Empty; - var bytes = Encoding.UTF8.GetBytes(value); - WriteInt(ref buffer, bytes.Length); - bytes.AsSpan().CopyTo(buffer); - buffer = buffer[bytes.Length..]; + if (string.IsNullOrEmpty(value)) { + WriteInt(ref buffer, 0); + + return; + } + + var byteCount = Encoding.UTF8.GetByteCount(value); + WriteInt(ref buffer, byteCount); + Encoding.UTF8.GetBytes(value.AsSpan(), buffer); + buffer = buffer[byteCount..]; } public static string ReadString(ref ReadOnlySpan buffer) { @@ -853,21 +863,23 @@ public static List SmartListFromBytes(byte[] bytes) where T : ISmartPacker /// public static byte[] ToBytes(in T obj) where T : IPacker { // Try stack buffer first for small payloads - Span stackBuf = stackalloc byte[StackBufferSize]; - var span = stackBuf; + if (_payloadSizeHint <= StackBufferSize) { + Span stackBuf = stackalloc byte[StackBufferSize]; + var span = stackBuf; - try { - obj.Pack(ref span); - var written = stackBuf.Length - span.Length; + try { + obj.Pack(ref span); + var written = stackBuf.Length - span.Length; - return stackBuf[..written].ToArray(); + return stackBuf[..written].ToArray(); + } + catch (ArgumentOutOfRangeException) { } } - catch (ArgumentOutOfRangeException) { } // Payload too large, use ArrayPool with exponential growth - var size = Math.Max(StackBufferSize * 2, 8192); + var size = Math.Max(_payloadSizeHint, 8192); - while (size <= MaxRentedBuffer) { + while (true) { var rented = ArrayPool.Shared.Rent(size); try { @@ -880,21 +892,22 @@ public static byte[] ToBytes(in T obj) where T : IPacker { var result = new byte[written]; Buffer.BlockCopy(rented, 0, result, 0, written); + RecordPayloadSize(written); return result; } - catch (ArgumentOutOfRangeException) { - // Need a bigger buffer... We can tackle this later. - } + catch (ArgumentOutOfRangeException) { } } finally { ArrayPool.Shared.Return(rented); } + if (size >= MaxRentedBuffer) + throw new InvalidOperationException( + $"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); + size = Math.Min(size * 2, MaxRentedBuffer); } - - throw new InvalidOperationException($"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); } /// diff --git a/package.json b/package.json index e047faf..403db1a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "com.unity.mathematics": "1.3.2", "com.unity.burst": "1.8.19", "com.unity.collections": "2.5.7", - "com.unity.physics": "1.3.14", "com.unity.inputsystem": "1.11.2", "com.unity.ugui": "2.0.0" }, From 802fa5226bbf13bd595aa375f097e8b451eec251 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 11:15:28 -0500 Subject: [PATCH 08/19] added tests and bug fixes --- Editor/LogConfigEditor.cs | 589 +++++++++--------- Runtime/Console/ClearCommand.cs | 2 +- Runtime/Console/CommandParameterParser.cs | 2 +- Runtime/Console/CommandResult.cs | 2 +- .../Console/ConsoleCommandClassAttribute.cs | 2 +- Runtime/Console/ConsoleController.cs | 2 +- Runtime/Console/ConsoleLogger.cs | 2 +- .../Console/ConsolePresetCommandAttribute.cs | 2 +- .../Console/ConsoleUtilityCommandAttribute.cs | 2 +- Runtime/Console/HelpCommand.cs | 2 +- Runtime/Console/ICommand.cs | 2 +- Runtime/Console/MethodCommandInfo.cs | 2 +- Runtime/Console/PresetResolver.cs | 5 +- Runtime/Logger/Log.cs | 7 +- Runtime/Logger/Sinks/FileSink.cs | 3 - Runtime/ObjectData/DynamicInstanceEntry.cs | 2 +- Runtime/ObjectHandling/ObjectParent.cs | 5 +- Runtime/ObjectHandling/TransformData.cs | 1 + Runtime/Objects/ObjectPreset.cs | 74 ++- Runtime/Packing/IPacker.cs | 2 - Runtime/Packing/Packer.cs | 130 ++-- Samples.meta | 8 + .../FastPackerBenchmark.cs | 0 .../FastPackerBenchmark.cs.meta | 0 Samples/Prefabs.meta | 8 + .../Prefabs}/PackingBenchmark.prefab | 0 .../Prefabs}/PackingBenchmark.prefab.meta | 0 Tests.meta | 8 + Tests/Spellbound.Core.Tests.asmdef | 24 + Tests/Spellbound.Core.Tests.asmdef.meta | 7 + Tests/StableHashTests.cs | 15 + Tests/StableHashTests.cs.meta | 2 + 32 files changed, 504 insertions(+), 408 deletions(-) create mode 100644 Samples.meta rename {Runtime/Packing => Samples}/FastPackerBenchmark.cs (100%) rename {Runtime/Packing => Samples}/FastPackerBenchmark.cs.meta (100%) create mode 100644 Samples/Prefabs.meta rename {Prefabs => Samples/Prefabs}/PackingBenchmark.prefab (100%) rename {Prefabs => Samples/Prefabs}/PackingBenchmark.prefab.meta (100%) create mode 100644 Tests.meta create mode 100644 Tests/Spellbound.Core.Tests.asmdef create mode 100644 Tests/Spellbound.Core.Tests.asmdef.meta create mode 100644 Tests/StableHashTests.cs create mode 100644 Tests/StableHashTests.cs.meta diff --git a/Editor/LogConfigEditor.cs b/Editor/LogConfigEditor.cs index 261933e..247f40d 100644 --- a/Editor/LogConfigEditor.cs +++ b/Editor/LogConfigEditor.cs @@ -1,296 +1,293 @@ -// Copyright 2026 Spellbound Studio Inc. - -// === LogConfigEditor.cs === -// Copyright 2026 Spellbound Studio Inc. - -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEditor; -using UnityEditor.Build; -using UnityEngine; - -namespace Spellbound.Core.Logging.Editor { - [CustomEditor(typeof(LogConfig))] - public class LogConfigEditor : UnityEditor.Editor { - private const string DefinePrefix = "SPELLBOUND_LOG_"; - private const string DefineVerbose = "SPELLBOUND_LOG_VERBOSE"; - private const string DefineDebug = "SPELLBOUND_LOG_DEBUG"; - private const string DefineInfo = "SPELLBOUND_LOG_INFO"; - private const string DefineWarning = "SPELLBOUND_LOG_WARNING"; - - private const string GlobalLabel = "Log Level"; - private const string FileLabel = "File Output"; - private const string FileNameLabel = "Log File Name"; - private const string UndoFileNameLabel = "Change Log File Name"; - private const string HeaderLabel = "Spellbound Log Configuration"; - private const string SinksLabel = "Sinks"; - private const string UndoLevelLabel = "Change Global Log Level"; - private const string UndoSinkLabel = "Toggle Log Sink"; - private const string ApplyLabel = "Apply"; - private const string LevelSubtitle = "Minimum severity compiled into all Spellbound packages."; - private const string FileSubtitle = "Name of the log file written to the persistent data path."; - private const string SinksSubtitle = "Toggle where log output is routed."; - private const string UndoSinkFilterLabel = "Change Sink Filter Level"; - private const string SinkFilterLabel = " Filter Level"; - - private const string LevelDescVerbose = "Compiles all log calls: Verbose, Debug, Info, Warning, and Error."; - private const string LevelDescDebug = "Compiles Debug, Info, Warning, and Error. Strips Verbose."; - private const string LevelDescInfo = "Compiles Info, Warning, and Error. Strips Debug and below."; - private const string LevelDescWarning = "Compiles Warning and Error only. Strips Info and below."; - private const string LevelDescError = "Error only. All other log calls are stripped from the build."; - private const string LevelDescNone = "Strips all log calls except Error. Equivalent to Error."; - - private static GUIStyle _headerStyle; - private static GUIStyle _subtitleStyle; - private static GUIStyle _sectionStyle; - - private static void InitStyles() { - if (_headerStyle != null) - return; - - _headerStyle = new GUIStyle(EditorStyles.boldLabel) { - fontSize = 14, - padding = new RectOffset(0, 0, 4, 4) - }; - - _subtitleStyle = new GUIStyle(EditorStyles.miniLabel) { - wordWrap = true, - padding = new RectOffset(2, 0, 0, 4) - }; - - _sectionStyle = new GUIStyle("box") { - padding = new RectOffset(10, 10, 8, 8), - margin = new RectOffset(0, 0, 4, 4) - }; - } - - public override void OnInspectorGUI() { - InitStyles(); - var config = (LogConfig)target; - - EditorGUILayout.LabelField(HeaderLabel, _headerStyle); - EditorGUILayout.Space(4); - - DrawLevelSection(config); - EditorGUILayout.Space(4); - DrawFileSection(config); - EditorGUILayout.Space(4); - DrawSinksSection(config); - EditorGUILayout.Space(8); - - if (!GUILayout.Button(ApplyLabel, GUILayout.Height(28))) - return; - - EditorUtility.SetDirty(config); - ApplyDefines(config); - } - - private static void DrawLevelSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(GlobalLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(LevelSubtitle, _subtitleStyle); - - var newLevel = (LogLevel)EditorGUILayout.EnumPopup(config.globalLevel); - - if (newLevel != config.globalLevel) { - Undo.RecordObject(config, UndoLevelLabel); - config.globalLevel = newLevel; - } - - EditorGUILayout.LabelField(GetLevelDescription(config.globalLevel), _subtitleStyle); - - EditorGUILayout.EndVertical(); - } - - private static string GetLevelDescription(LogLevel level) => - level switch { - LogLevel.Verbose => LevelDescVerbose, - LogLevel.Debug => LevelDescDebug, - LogLevel.Info => LevelDescInfo, - LogLevel.Warning => LevelDescWarning, - LogLevel.Error => LevelDescError, - LogLevel.None => LevelDescNone, - _ => "" - }; - - private static void DrawFileSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(FileLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(FileSubtitle, _subtitleStyle); - - var newFileName = EditorGUILayout.TextField(FileNameLabel, config.logFileName); - - if (newFileName != config.logFileName) { - Undo.RecordObject(config, UndoFileNameLabel); - config.logFileName = newFileName; - } - - EditorGUILayout.EndVertical(); - } - - private static void DrawSinksSection(LogConfig config) { - EditorGUILayout.BeginVertical(_sectionStyle); - - EditorGUILayout.LabelField(SinksLabel, EditorStyles.boldLabel); - EditorGUILayout.LabelField(SinksSubtitle, _subtitleStyle); - - SyncSinkEntries(config, DiscoverSinks()); - - for (var i = 0; i < config.sinks.Length; i++) { - var entry = config.sinks[i]; - - var newEnabled = EditorGUILayout.Toggle(entry.displayName, entry.enabled); - - if (newEnabled != entry.enabled) { - Undo.RecordObject(config, UndoSinkLabel); - config.sinks[i].enabled = newEnabled; - } - - var newFilter = DrawFilteredLevelPopup(SinkFilterLabel, entry.filterLevel, config.globalLevel); - - if (newFilter != entry.filterLevel) { - Undo.RecordObject(config, UndoSinkFilterLabel); - config.sinks[i].filterLevel = newFilter; - } - - if (i < config.sinks.Length - 1) - EditorGUILayout.Space(4); - } - - EditorGUILayout.EndVertical(); - } - - private static List DiscoverSinks() { - var sinks = new List(); - - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - foreach (var type in asm.GetTypes()) { - if (!typeof(ILogSink).IsAssignableFrom(type)) - continue; - - if (type.IsAbstract || type.IsInterface) - continue; - - if (type.GetConstructor(Type.EmptyTypes) == null) - continue; - - var instance = (ILogSink)Activator.CreateInstance(type); - - sinks.Add(new DiscoveredSink { - QualifiedTypeName = type.AssemblyQualifiedName, - DisplayName = instance.DisplayName - }); - } - } - - sinks.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.Ordinal)); - - return sinks; - } - - private static void SyncSinkEntries(LogConfig config, List discovered) { - var existing = new HashSet(config.sinks.Select(s => s.qualifiedTypeName)); - var updated = new List(config.sinks); - - foreach (var sink in discovered) { - if (existing.Contains(sink.QualifiedTypeName)) - continue; - - updated.Add(new SinkEntry { - qualifiedTypeName = sink.QualifiedTypeName, - displayName = sink.DisplayName, - enabled = false - }); - } - - config.sinks = updated.ToArray(); - } - - private static void ApplyDefines(LogConfig config) { - var defines = new HashSet(); - AddDefinesForLevel(defines, config.globalLevel); - - var target = NamedBuildTarget.FromBuildTargetGroup( - EditorUserBuildSettings.selectedBuildTargetGroup - ); - - PlayerSettings.GetScriptingDefineSymbols(target, out var current); - var preserved = current.Where(d => !d.StartsWith(DefinePrefix)).ToList(); - preserved.AddRange(defines); - - PlayerSettings.SetScriptingDefineSymbols(target, preserved.ToArray()); - Debug.Log($"[LogConfig] Applied log level: {config.globalLevel}"); - } - - // Each case adds defines for its level and everything above it. - // Error has no [Conditional] attribute so it always compiles — no define needed. - private static void AddDefinesForLevel(HashSet defines, LogLevel level) { - switch (level) { - case LogLevel.Verbose: - defines.Add(DefineVerbose); - defines.Add(DefineDebug); - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Debug: - defines.Add(DefineDebug); - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Info: - defines.Add(DefineInfo); - defines.Add(DefineWarning); - - break; - case LogLevel.Warning: - defines.Add(DefineWarning); - - break; - case LogLevel.Error: - case LogLevel.None: - break; - default: - Debug.LogError($"[LogConfigEditor] Unhandled log level: {level}"); - - break; - } - } - - private static LogLevel DrawFilteredLevelPopup(string label, LogLevel current, LogLevel floor) { - // Build arrays of only valid options (at or above the global level) - var validLevels = new List(); - var validNames = new List(); - - foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) { - if (level < floor) - continue; - - if (level == LogLevel.None) - continue; - - validLevels.Add(level); - validNames.Add(level.ToString()); - } - - // Find current selection index, clamp to floor if invalid - var currentIndex = validLevels.IndexOf(current); - - if (currentIndex < 0) - currentIndex = 0; - - var newIndex = EditorGUILayout.Popup(label, currentIndex, validNames.ToArray()); - - return validLevels[newIndex]; - } - - private struct DiscoveredSink { - public string QualifiedTypeName; - public string DisplayName; - } - } -} +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEngine; + +namespace Spellbound.Core.Logging.Editor { + [CustomEditor(typeof(LogConfig))] + public class LogConfigEditor : UnityEditor.Editor { + private const string DefinePrefix = "SPELLBOUND_LOG_"; + private const string DefineVerbose = "SPELLBOUND_LOG_VERBOSE"; + private const string DefineDebug = "SPELLBOUND_LOG_DEBUG"; + private const string DefineInfo = "SPELLBOUND_LOG_INFO"; + private const string DefineWarning = "SPELLBOUND_LOG_WARNING"; + + private const string GlobalLabel = "Log Level"; + private const string FileLabel = "File Output"; + private const string FileNameLabel = "Log File Name"; + private const string UndoFileNameLabel = "Change Log File Name"; + private const string HeaderLabel = "Spellbound Log Configuration"; + private const string SinksLabel = "Sinks"; + private const string UndoLevelLabel = "Change Global Log Level"; + private const string UndoSinkLabel = "Toggle Log Sink"; + private const string ApplyLabel = "Apply"; + private const string LevelSubtitle = "Minimum severity compiled into all Spellbound packages."; + private const string FileSubtitle = "Name of the log file written to the persistent data path."; + private const string SinksSubtitle = "Toggle where log output is routed."; + private const string UndoSinkFilterLabel = "Change Sink Filter Level"; + private const string SinkFilterLabel = " Filter Level"; + + private const string LevelDescVerbose = "Compiles all log calls: Verbose, Debug, Info, Warning, and Error."; + private const string LevelDescDebug = "Compiles Debug, Info, Warning, and Error. Strips Verbose."; + private const string LevelDescInfo = "Compiles Info, Warning, and Error. Strips Debug and below."; + private const string LevelDescWarning = "Compiles Warning and Error only. Strips Info and below."; + private const string LevelDescError = "Error only. All other log calls are stripped from the build."; + private const string LevelDescNone = "Strips all log calls except Error. Equivalent to Error."; + + private static GUIStyle _headerStyle; + private static GUIStyle _subtitleStyle; + private static GUIStyle _sectionStyle; + + private static void InitStyles() { + if (_headerStyle != null) + return; + + _headerStyle = new GUIStyle(EditorStyles.boldLabel) { + fontSize = 14, + padding = new RectOffset(0, 0, 4, 4) + }; + + _subtitleStyle = new GUIStyle(EditorStyles.miniLabel) { + wordWrap = true, + padding = new RectOffset(2, 0, 0, 4) + }; + + _sectionStyle = new GUIStyle("box") { + padding = new RectOffset(10, 10, 8, 8), + margin = new RectOffset(0, 0, 4, 4) + }; + } + + public override void OnInspectorGUI() { + InitStyles(); + var config = (LogConfig)target; + + EditorGUILayout.LabelField(HeaderLabel, _headerStyle); + EditorGUILayout.Space(4); + + DrawLevelSection(config); + EditorGUILayout.Space(4); + DrawFileSection(config); + EditorGUILayout.Space(4); + DrawSinksSection(config); + EditorGUILayout.Space(8); + + if (!GUILayout.Button(ApplyLabel, GUILayout.Height(28))) + return; + + EditorUtility.SetDirty(config); + ApplyDefines(config); + } + + private static void DrawLevelSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(GlobalLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(LevelSubtitle, _subtitleStyle); + + var newLevel = (LogLevel)EditorGUILayout.EnumPopup(config.globalLevel); + + if (newLevel != config.globalLevel) { + Undo.RecordObject(config, UndoLevelLabel); + config.globalLevel = newLevel; + } + + EditorGUILayout.LabelField(GetLevelDescription(config.globalLevel), _subtitleStyle); + + EditorGUILayout.EndVertical(); + } + + private static string GetLevelDescription(LogLevel level) => + level switch { + LogLevel.Verbose => LevelDescVerbose, + LogLevel.Debug => LevelDescDebug, + LogLevel.Info => LevelDescInfo, + LogLevel.Warning => LevelDescWarning, + LogLevel.Error => LevelDescError, + LogLevel.None => LevelDescNone, + _ => "" + }; + + private static void DrawFileSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(FileLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(FileSubtitle, _subtitleStyle); + + var newFileName = EditorGUILayout.TextField(FileNameLabel, config.logFileName); + + if (newFileName != config.logFileName) { + Undo.RecordObject(config, UndoFileNameLabel); + config.logFileName = newFileName; + } + + EditorGUILayout.EndVertical(); + } + + private static void DrawSinksSection(LogConfig config) { + EditorGUILayout.BeginVertical(_sectionStyle); + + EditorGUILayout.LabelField(SinksLabel, EditorStyles.boldLabel); + EditorGUILayout.LabelField(SinksSubtitle, _subtitleStyle); + + SyncSinkEntries(config, DiscoverSinks()); + + for (var i = 0; i < config.sinks.Length; i++) { + var entry = config.sinks[i]; + + var newEnabled = EditorGUILayout.Toggle(entry.displayName, entry.enabled); + + if (newEnabled != entry.enabled) { + Undo.RecordObject(config, UndoSinkLabel); + config.sinks[i].enabled = newEnabled; + } + + var newFilter = DrawFilteredLevelPopup(SinkFilterLabel, entry.filterLevel, config.globalLevel); + + if (newFilter != entry.filterLevel) { + Undo.RecordObject(config, UndoSinkFilterLabel); + config.sinks[i].filterLevel = newFilter; + } + + if (i < config.sinks.Length - 1) + EditorGUILayout.Space(4); + } + + EditorGUILayout.EndVertical(); + } + + private static List DiscoverSinks() { + var sinks = new List(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { + foreach (var type in asm.GetTypes()) { + if (!typeof(ILogSink).IsAssignableFrom(type)) + continue; + + if (type.IsAbstract || type.IsInterface) + continue; + + if (type.GetConstructor(Type.EmptyTypes) == null) + continue; + + var instance = (ILogSink)Activator.CreateInstance(type); + + sinks.Add(new DiscoveredSink { + QualifiedTypeName = type.AssemblyQualifiedName, + DisplayName = instance.DisplayName + }); + } + } + + sinks.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.Ordinal)); + + return sinks; + } + + private static void SyncSinkEntries(LogConfig config, List discovered) { + var existing = new HashSet(config.sinks.Select(s => s.qualifiedTypeName)); + var updated = new List(config.sinks); + + foreach (var sink in discovered) { + if (existing.Contains(sink.QualifiedTypeName)) + continue; + + updated.Add(new SinkEntry { + qualifiedTypeName = sink.QualifiedTypeName, + displayName = sink.DisplayName, + enabled = false + }); + } + + config.sinks = updated.ToArray(); + } + + private static void ApplyDefines(LogConfig config) { + var defines = new HashSet(); + AddDefinesForLevel(defines, config.globalLevel); + + var target = NamedBuildTarget.FromBuildTargetGroup( + EditorUserBuildSettings.selectedBuildTargetGroup + ); + + PlayerSettings.GetScriptingDefineSymbols(target, out var current); + var preserved = current.Where(d => !d.StartsWith(DefinePrefix)).ToList(); + preserved.AddRange(defines); + + PlayerSettings.SetScriptingDefineSymbols(target, preserved.ToArray()); + Debug.Log($"[LogConfig] Applied log level: {config.globalLevel}"); + } + + // Each case adds defines for its level and everything above it. + // Error has no [Conditional] attribute so it always compiles — no define needed. + private static void AddDefinesForLevel(HashSet defines, LogLevel level) { + switch (level) { + case LogLevel.Verbose: + defines.Add(DefineVerbose); + defines.Add(DefineDebug); + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Debug: + defines.Add(DefineDebug); + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Info: + defines.Add(DefineInfo); + defines.Add(DefineWarning); + + break; + case LogLevel.Warning: + defines.Add(DefineWarning); + + break; + case LogLevel.Error: + case LogLevel.None: + break; + default: + Debug.LogError($"[LogConfigEditor] Unhandled log level: {level}"); + + break; + } + } + + private static LogLevel DrawFilteredLevelPopup(string label, LogLevel current, LogLevel floor) { + // Build arrays of only valid options (at or above the global level) + var validLevels = new List(); + var validNames = new List(); + + foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) { + if (level < floor) + continue; + + if (level == LogLevel.None) + continue; + + validLevels.Add(level); + validNames.Add(level.ToString()); + } + + // Find current selection index, clamp to floor if invalid + var currentIndex = validLevels.IndexOf(current); + + if (currentIndex < 0) + currentIndex = 0; + + var newIndex = EditorGUILayout.Popup(label, currentIndex, validNames.ToArray()); + + return validLevels[newIndex]; + } + + private struct DiscoveredSink { + public string QualifiedTypeName; + public string DisplayName; + } + } +} diff --git a/Runtime/Console/ClearCommand.cs b/Runtime/Console/ClearCommand.cs index 4ffc74f..88cfff6 100644 --- a/Runtime/Console/ClearCommand.cs +++ b/Runtime/Console/ClearCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using UnityEngine; diff --git a/Runtime/Console/CommandParameterParser.cs b/Runtime/Console/CommandParameterParser.cs index 2f96698..90a8e2a 100644 --- a/Runtime/Console/CommandParameterParser.cs +++ b/Runtime/Console/CommandParameterParser.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; using System.Globalization; diff --git a/Runtime/Console/CommandResult.cs b/Runtime/Console/CommandResult.cs index ba524d7..a781f0b 100644 --- a/Runtime/Console/CommandResult.cs +++ b/Runtime/Console/CommandResult.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. namespace Spellbound.Core.Console { /// diff --git a/Runtime/Console/ConsoleCommandClassAttribute.cs b/Runtime/Console/ConsoleCommandClassAttribute.cs index 224bbc2..89dd285 100644 --- a/Runtime/Console/ConsoleCommandClassAttribute.cs +++ b/Runtime/Console/ConsoleCommandClassAttribute.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; diff --git a/Runtime/Console/ConsoleController.cs b/Runtime/Console/ConsoleController.cs index d938de1..4d8897e 100644 --- a/Runtime/Console/ConsoleController.cs +++ b/Runtime/Console/ConsoleController.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; using System.Collections.Generic; diff --git a/Runtime/Console/ConsoleLogger.cs b/Runtime/Console/ConsoleLogger.cs index 3b0fe5a..1297bb6 100644 --- a/Runtime/Console/ConsoleLogger.cs +++ b/Runtime/Console/ConsoleLogger.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. namespace Spellbound.Core.Console { /// diff --git a/Runtime/Console/ConsolePresetCommandAttribute.cs b/Runtime/Console/ConsolePresetCommandAttribute.cs index 00f0adc..d4d6bb2 100644 --- a/Runtime/Console/ConsolePresetCommandAttribute.cs +++ b/Runtime/Console/ConsolePresetCommandAttribute.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; diff --git a/Runtime/Console/ConsoleUtilityCommandAttribute.cs b/Runtime/Console/ConsoleUtilityCommandAttribute.cs index 6bfa9de..285343e 100644 --- a/Runtime/Console/ConsoleUtilityCommandAttribute.cs +++ b/Runtime/Console/ConsoleUtilityCommandAttribute.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; diff --git a/Runtime/Console/HelpCommand.cs b/Runtime/Console/HelpCommand.cs index 2d867ba..443ca02 100644 --- a/Runtime/Console/HelpCommand.cs +++ b/Runtime/Console/HelpCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. namespace Spellbound.Core.Console { [ConsoleCommandClass("help", "?", "h")] diff --git a/Runtime/Console/ICommand.cs b/Runtime/Console/ICommand.cs index e9cfeaf..30700eb 100644 --- a/Runtime/Console/ICommand.cs +++ b/Runtime/Console/ICommand.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. namespace Spellbound.Core.Console { /// diff --git a/Runtime/Console/MethodCommandInfo.cs b/Runtime/Console/MethodCommandInfo.cs index 7b5d82c..067b365 100644 --- a/Runtime/Console/MethodCommandInfo.cs +++ b/Runtime/Console/MethodCommandInfo.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. using System; using System.Reflection; diff --git a/Runtime/Console/PresetResolver.cs b/Runtime/Console/PresetResolver.cs index 2239126..f05ab2c 100644 --- a/Runtime/Console/PresetResolver.cs +++ b/Runtime/Console/PresetResolver.cs @@ -29,11 +29,10 @@ public static void Initialize() { } /// - /// Scans all ObjectPresets in Resources and registers any with ConsoleModules. - /// This likely needs to become more flexible, but I think it is a good working prototype. + /// Registers every preset already discovered by PresetRegistry, avoiding a second Resources scan. /// private static void RegisterAllPresets() { - var allPresets = Resources.LoadAll(""); + var allPresets = PresetRegistry.All; var registeredCount = 0; foreach (var preset in allPresets) { diff --git a/Runtime/Logger/Log.cs b/Runtime/Logger/Log.cs index ac22ec9..2d67c5f 100644 --- a/Runtime/Logger/Log.cs +++ b/Runtime/Logger/Log.cs @@ -68,13 +68,18 @@ public static void Error( Emit(LogLevel.Error, message, file, member, line); public static void Emit(LogLevel level, string message, string file, string member, int line) { - var source = Path.GetFileNameWithoutExtension(file); var sinks = _sinks; + if (sinks.Length == 0) + return; + + string source = null; + for (var i = 0; i < sinks.Length; i++) { if (level < sinks[i].FilterLevel) continue; + source ??= Path.GetFileNameWithoutExtension(file); sinks[i].Sink.Emit(level, source, message, member, line); } } diff --git a/Runtime/Logger/Sinks/FileSink.cs b/Runtime/Logger/Sinks/FileSink.cs index a92a96f..f16a032 100644 --- a/Runtime/Logger/Sinks/FileSink.cs +++ b/Runtime/Logger/Sinks/FileSink.cs @@ -1,8 +1,5 @@ // Copyright 2026 Spellbound Studio Inc. -// === FileSink.cs === -// Copyright 2026 Spellbound Studio Inc. - using System; using System.Collections.Concurrent; using System.IO; diff --git a/Runtime/ObjectData/DynamicInstanceEntry.cs b/Runtime/ObjectData/DynamicInstanceEntry.cs index 08dad30..e209b88 100644 --- a/Runtime/ObjectData/DynamicInstanceEntry.cs +++ b/Runtime/ObjectData/DynamicInstanceEntry.cs @@ -34,7 +34,7 @@ public void Pack(ref Span buffer) { foreach (var (key, bytes) in DataSlots) { Packer.WriteUInt(ref buffer, key.PackerHash); - Packer.WriteInt(ref buffer, key.SurfaceIndex); + Packer.WriteByte(ref buffer, key.SurfaceIndex); Packer.WriteBytes(ref buffer, bytes); } } diff --git a/Runtime/ObjectHandling/ObjectParent.cs b/Runtime/ObjectHandling/ObjectParent.cs index c82b1af..0b4b129 100644 --- a/Runtime/ObjectHandling/ObjectParent.cs +++ b/Runtime/ObjectHandling/ObjectParent.cs @@ -538,9 +538,12 @@ private bool TryGetCallbackParamsFromEventSurface( if (!_eventSurfaces.TryGetValue(instanceIndex, out var mainSurface)) return false; - if (!mainSurface.TryGetEventSurfaceByIndex(surfaceIndex, out surface)) + if (!mainSurface.TryGetEventSurfaceByIndex(surfaceIndex, out surface)) { Log.Error($"Surface not found for instanceIndex {instanceIndex} and surfaceIndex {surfaceIndex}"); + return false; + } + transformData = new TransformData(surface.Transform); preset = surface.Preset; diff --git a/Runtime/ObjectHandling/TransformData.cs b/Runtime/ObjectHandling/TransformData.cs index ef62e8d..3d38438 100644 --- a/Runtime/ObjectHandling/TransformData.cs +++ b/Runtime/ObjectHandling/TransformData.cs @@ -35,6 +35,7 @@ public TransformData(LocalTransform localTransform) { public TransformData(Transform transform) { Position = transform.position; Rotation = math.degrees(math.EulerXYZ(transform.rotation)); + // TODO: Tinker it looks like we are silently dropping non-uniform scale please investigate Scale = transform.localScale.x; } diff --git a/Runtime/Objects/ObjectPreset.cs b/Runtime/Objects/ObjectPreset.cs index b50d8d8..f62d5f8 100644 --- a/Runtime/Objects/ObjectPreset.cs +++ b/Runtime/Objects/ObjectPreset.cs @@ -26,11 +26,16 @@ public class ObjectPreset : HashedScriptableObject { public Vector2 interactionDistance = new(50, 70); [SerializeField] public List surfaceModules = new(); - - // In ObjectPreset + + private const int AllSurfaces = -1; + + private readonly Dictionary<(int surfaceIndex, Type moduleType), object> _moduleCache = new(); + private void OnEnable() => RewireModules(); private void RewireModules() { + _moduleCache.Clear(); + if (surfaceModules == null) return; @@ -46,56 +51,65 @@ private void RewireModules() { } public bool TryGetModule(out T result, byte surfaceIndex = 0) where T : class { - if (TryGetModules(out var results, surfaceIndex)) { - result = results[0]; + var modules = LookupModules(surfaceIndex); + + if (modules.Count > 0) { + result = modules[0]; + return true; } result = null; + return false; } public bool TryGetModules(out IReadOnlyList results, byte surfaceIndex = 0) where T : class { - results = Array.Empty(); - - var matches = new List(); - foreach (var module in surfaceModules[surfaceIndex].presetModules) { - if (module is T t) - matches.Add(t); - } + results = LookupModules(surfaceIndex); - if (matches.Count == 0) - return false; + return results.Count > 0; + } - results = matches; + public bool TryGetModulesAcrossSurfaces(out IReadOnlyList results) where T : class { + results = LookupModules(AllSurfaces); - return true; + return results.Count > 0; } - - public bool TryGetModulesAcrossSurfaces(out IReadOnlyList results) where T : class { - results = Array.Empty(); + + private IReadOnlyList LookupModules(int surfaceIndex) where T : class { + var key = (surfaceIndex, typeof(T)); + + if (_moduleCache.TryGetValue(key, out var cached)) + return (IReadOnlyList)cached; + List matches = null; - for (var i = 0; i < surfaceModules.Count; i++) { - var surface = surfaceModules[i]; + if (surfaceModules != null) { + if (surfaceIndex == AllSurfaces) { + foreach (var surface in surfaceModules) + CollectModules(surface, ref matches); + } + else if (surfaceIndex >= 0 && surfaceIndex < surfaceModules.Count) + CollectModules(surfaceModules[surfaceIndex], ref matches); + } + IReadOnlyList results = matches ?? (IReadOnlyList)Array.Empty(); + _moduleCache[key] = results; + + return results; + + static void CollectModules(PresetSurface surface, ref List collected) { if (surface?.presetModules == null) - continue; + return; foreach (var module in surface.presetModules) { - if (module is not T t) + if (module is not T match) continue; - matches ??= new List(); - matches.Add(t); + collected ??= new List(); + collected.Add(match); } } - - if (matches == null) - return false; - - results = matches; - return true; } /// diff --git a/Runtime/Packing/IPacker.cs b/Runtime/Packing/IPacker.cs index 986703e..bf225f1 100644 --- a/Runtime/Packing/IPacker.cs +++ b/Runtime/Packing/IPacker.cs @@ -10,7 +10,5 @@ namespace Spellbound.Core.Packing { public interface IPacker { public void Pack(ref Span buffer); public void Unpack(ref ReadOnlySpan buffer); - - public string ToString(); } } \ No newline at end of file diff --git a/Runtime/Packing/Packer.cs b/Runtime/Packing/Packer.cs index 7896305..b7c0669 100644 --- a/Runtime/Packing/Packer.cs +++ b/Runtime/Packing/Packer.cs @@ -622,21 +622,23 @@ public static byte[] PackListToBytes(List items) where T : IPacker { return emptyBuf.ToArray(); } - Span stackBuf = stackalloc byte[StackBufferSize]; - var span = stackBuf; + if (_payloadSizeHint <= StackBufferSize) { + Span stackBuf = stackalloc byte[StackBufferSize]; + var span = stackBuf; - try { - PackList(ref span, items); - var written = stackBuf.Length - span.Length; + try { + PackList(ref span, items); + var written = stackBuf.Length - span.Length; - return stackBuf[..written].ToArray(); + return stackBuf[..written].ToArray(); + } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } - catch (ArgumentOutOfRangeException) { } // Need larger buffer - var size = Math.Max(StackBufferSize * 2, 8192); + var size = Math.Max(_payloadSizeHint, 8192); - while (size <= MaxRentedBuffer) { + while (true) { var rented = ArrayPool.Shared.Rent(size); try { @@ -649,20 +651,22 @@ public static byte[] PackListToBytes(List items) where T : IPacker { var result = new byte[written]; Buffer.BlockCopy(rented, 0, result, 0, written); + RecordPayloadSize(written); return result; } - catch (ArgumentOutOfRangeException) { } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } finally { ArrayPool.Shared.Return(rented); } + if (size >= MaxRentedBuffer) + throw new InvalidOperationException( + $"List payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); + size = Math.Min(size * 2, MaxRentedBuffer); } - - throw new InvalidOperationException( - $"List payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); } /// @@ -699,21 +703,23 @@ public static byte[] SmartToBytes(in T obj) where T : ISmartPacker { tag = SmartPackerRegistry.GetHash(obj.GetType()); } - Span stackBuf = stackalloc byte[StackBufferSize]; - var span = stackBuf; + if (_payloadSizeHint <= StackBufferSize) { + Span stackBuf = stackalloc byte[StackBufferSize]; + var span = stackBuf; - try { - WriteUInt(ref span, tag); - obj.Pack(ref span); - var written = stackBuf.Length - span.Length; + try { + WriteUInt(ref span, tag); + obj.Pack(ref span); + var written = stackBuf.Length - span.Length; - return stackBuf[..written].ToArray(); + return stackBuf[..written].ToArray(); + } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } - catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } - var size = Math.Max(StackBufferSize * 2, 8192); + var size = Math.Max(_payloadSizeHint, 8192); - while (size <= MaxRentedBuffer) { + while (true) { var rented = ArrayPool.Shared.Rent(size); try { @@ -726,6 +732,7 @@ public static byte[] SmartToBytes(in T obj) where T : ISmartPacker { var written = size - working.Length; var result = new byte[written]; Buffer.BlockCopy(rented, 0, result, 0, written); + RecordPayloadSize(written); return result; } @@ -735,10 +742,12 @@ public static byte[] SmartToBytes(in T obj) where T : ISmartPacker { ArrayPool.Shared.Return(rented); } + if (size >= MaxRentedBuffer) + throw new InvalidOperationException( + $"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); + size = Math.Min(size * 2, MaxRentedBuffer); } - - throw new InvalidOperationException($"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); } /// @@ -873,7 +882,7 @@ public static byte[] ToBytes(in T obj) where T : IPacker { return stackBuf[..written].ToArray(); } - catch (ArgumentOutOfRangeException) { } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } // Payload too large, use ArrayPool with exponential growth @@ -896,7 +905,7 @@ public static byte[] ToBytes(in T obj) where T : IPacker { return result; } - catch (ArgumentOutOfRangeException) { } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } finally { ArrayPool.Shared.Return(rented); @@ -936,21 +945,23 @@ public static byte[] PackArrayToBytes(T[] items) where T : IPacker { return emptyBuf.ToArray(); } - Span stackBuf = stackalloc byte[StackBufferSize]; - var span = stackBuf; + if (_payloadSizeHint <= StackBufferSize) { + Span stackBuf = stackalloc byte[StackBufferSize]; + var span = stackBuf; - try { - PackArray(ref span, items); - var written = stackBuf.Length - span.Length; + try { + PackArray(ref span, items); + var written = stackBuf.Length - span.Length; - return stackBuf[..written].ToArray(); + return stackBuf[..written].ToArray(); + } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } - catch (ArgumentOutOfRangeException) { } // Need larger buffer - var size = Math.Max(StackBufferSize * 2, 8192); + var size = Math.Max(_payloadSizeHint, 8192); - while (size <= MaxRentedBuffer) { + while (true) { var rented = ArrayPool.Shared.Rent(size); try { @@ -963,20 +974,22 @@ public static byte[] PackArrayToBytes(T[] items) where T : IPacker { var result = new byte[written]; Buffer.BlockCopy(rented, 0, result, 0, written); + RecordPayloadSize(written); return result; } - catch (ArgumentOutOfRangeException) { } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } finally { ArrayPool.Shared.Return(rented); } + if (size >= MaxRentedBuffer) + throw new InvalidOperationException( + $"Array payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); + size = Math.Min(size * 2, MaxRentedBuffer); } - - throw new InvalidOperationException( - $"Array payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); } /// @@ -1006,15 +1019,7 @@ public static bool AreBytesEqual(byte[] a, byte[] b) { if (a == null || b == null) return false; - if (a.Length != b.Length) - return false; - - for (var i = 0; i < a.Length; ++i) { - if (a[i] != b[i]) - return false; - } - - return true; + return a.AsSpan().SequenceEqual(b); // Helper method that returns a bool if the byte array is null or empty. static bool NoData(byte[] x) => x == null || x.Length == 0; @@ -1025,20 +1030,22 @@ public static bool AreBytesEqual(byte[] a, byte[] b) { /// Caller provides only the write logic. /// public static byte[] BuildPayload(PackWriter writer) { - Span stackBuf = stackalloc byte[StackBufferSize]; - var span = stackBuf; + if (_payloadSizeHint <= StackBufferSize) { + Span stackBuf = stackalloc byte[StackBufferSize]; + var span = stackBuf; - try { - writer(ref span); - var written = stackBuf.Length - span.Length; + try { + writer(ref span); + var written = stackBuf.Length - span.Length; - return stackBuf[..written].ToArray(); + return stackBuf[..written].ToArray(); + } + catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } - catch (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } - var size = Math.Max(StackBufferSize * 2, 8192); + var size = Math.Max(_payloadSizeHint, 8192); - while (size <= MaxRentedBuffer) { + while (true) { var rented = ArrayPool.Shared.Rent(size); try { @@ -1050,6 +1057,7 @@ public static byte[] BuildPayload(PackWriter writer) { var written = size - working.Length; var result = new byte[written]; Buffer.BlockCopy(rented, 0, result, 0, written); + RecordPayloadSize(written); return result; } @@ -1059,10 +1067,12 @@ public static byte[] BuildPayload(PackWriter writer) { ArrayPool.Shared.Return(rented); } + if (size >= MaxRentedBuffer) + throw new InvalidOperationException( + $"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); + size = Math.Min(size * 2, MaxRentedBuffer); } - - throw new InvalidOperationException($"Payload exceeds maximum buffer size of {MaxRentedBuffer} bytes"); } #endregion diff --git a/Samples.meta b/Samples.meta new file mode 100644 index 0000000..686d6b8 --- /dev/null +++ b/Samples.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6c6ff44e8a6ced244993d51dcc834231 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Packing/FastPackerBenchmark.cs b/Samples/FastPackerBenchmark.cs similarity index 100% rename from Runtime/Packing/FastPackerBenchmark.cs rename to Samples/FastPackerBenchmark.cs diff --git a/Runtime/Packing/FastPackerBenchmark.cs.meta b/Samples/FastPackerBenchmark.cs.meta similarity index 100% rename from Runtime/Packing/FastPackerBenchmark.cs.meta rename to Samples/FastPackerBenchmark.cs.meta diff --git a/Samples/Prefabs.meta b/Samples/Prefabs.meta new file mode 100644 index 0000000..b8f8a3b --- /dev/null +++ b/Samples/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cad6dcb296169ec4d92e534575e9407b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Prefabs/PackingBenchmark.prefab b/Samples/Prefabs/PackingBenchmark.prefab similarity index 100% rename from Prefabs/PackingBenchmark.prefab rename to Samples/Prefabs/PackingBenchmark.prefab diff --git a/Prefabs/PackingBenchmark.prefab.meta b/Samples/Prefabs/PackingBenchmark.prefab.meta similarity index 100% rename from Prefabs/PackingBenchmark.prefab.meta rename to Samples/Prefabs/PackingBenchmark.prefab.meta diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..46ddb0f --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 29c19687394fe4248b271cb95158c08d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Spellbound.Core.Tests.asmdef b/Tests/Spellbound.Core.Tests.asmdef new file mode 100644 index 0000000..0fbaa7f --- /dev/null +++ b/Tests/Spellbound.Core.Tests.asmdef @@ -0,0 +1,24 @@ +{ + "name": "Spellbound.Core.Tests", + "rootNamespace": "", + "references": [ + "Spellbound.Core", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Spellbound.Core.Tests.asmdef.meta b/Tests/Spellbound.Core.Tests.asmdef.meta new file mode 100644 index 0000000..a1fca47 --- /dev/null +++ b/Tests/Spellbound.Core.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9d12f97bbaf0f6d409469db334d55b02 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/StableHashTests.cs b/Tests/StableHashTests.cs new file mode 100644 index 0000000..b6192d2 --- /dev/null +++ b/Tests/StableHashTests.cs @@ -0,0 +1,15 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using Spellbound.Core.Hashing; + +namespace Spellbound.Core.Tests { + public class StableHashTests { + [Test] + public void Fnv1A32MatchesCanonicalVectorAndReservesZeroForNullOrEmpty() { + Assert.AreEqual(0xE40C292Cu, StableHash.Fnv1A32("a")); + Assert.AreEqual(0u, StableHash.Fnv1A32(null)); + Assert.AreEqual(0u, StableHash.Fnv1A32(string.Empty)); + } + } +} diff --git a/Tests/StableHashTests.cs.meta b/Tests/StableHashTests.cs.meta new file mode 100644 index 0000000..7486f03 --- /dev/null +++ b/Tests/StableHashTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc613c1e63401dd41ac7f94d85dae911 \ No newline at end of file From d9da12d279df0026a0598934173b5d20702f0d84 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 12:05:50 -0500 Subject: [PATCH 09/19] added unit tests --- Runtime/Console/AttributeCommandRegistry.cs | 14 +- Runtime/Console/CommandRegistry.cs | 9 +- Runtime/Console/CommandRegistryUtilities.cs | 28 +-- Runtime/Console/PresetResolver.cs | 3 + Runtime/ObjectData/DeltaResolver.cs | 21 +- Runtime/Packing/SmartPackerRegistry.cs | 5 +- Runtime/Tooling/AssemblyScanning.cs | 43 ++++ Runtime/Tooling/AssemblyScanning.cs.meta | 2 + Tests/DynamicInstanceEntryTests.cs | 47 +++++ Tests/DynamicInstanceEntryTests.cs.meta | 2 + Tests/HashRegistryTests.cs | 72 +++++++ Tests/HashRegistryTests.cs.meta | 2 + Tests/ObjectPoolTests.cs | 45 +++++ Tests/ObjectPoolTests.cs.meta | 2 + Tests/ObservableArrayTests.cs | 87 ++++++++ Tests/ObservableArrayTests.cs.meta | 2 + Tests/PackerTests.cs | 212 ++++++++++++++++++++ Tests/PackerTests.cs.meta | 2 + Tests/SingletonManagerTests.cs | 39 ++++ Tests/SingletonManagerTests.cs.meta | 2 + 20 files changed, 587 insertions(+), 52 deletions(-) create mode 100644 Runtime/Tooling/AssemblyScanning.cs create mode 100644 Runtime/Tooling/AssemblyScanning.cs.meta create mode 100644 Tests/DynamicInstanceEntryTests.cs create mode 100644 Tests/DynamicInstanceEntryTests.cs.meta create mode 100644 Tests/HashRegistryTests.cs create mode 100644 Tests/HashRegistryTests.cs.meta create mode 100644 Tests/ObjectPoolTests.cs create mode 100644 Tests/ObjectPoolTests.cs.meta create mode 100644 Tests/ObservableArrayTests.cs create mode 100644 Tests/ObservableArrayTests.cs.meta create mode 100644 Tests/PackerTests.cs create mode 100644 Tests/PackerTests.cs.meta create mode 100644 Tests/SingletonManagerTests.cs create mode 100644 Tests/SingletonManagerTests.cs.meta diff --git a/Runtime/Console/AttributeCommandRegistry.cs b/Runtime/Console/AttributeCommandRegistry.cs index 7b6a537..a4c353a 100644 --- a/Runtime/Console/AttributeCommandRegistry.cs +++ b/Runtime/Console/AttributeCommandRegistry.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Spellbound.Core.Tooling; using UnityEngine; namespace Spellbound.Core.Console { @@ -36,6 +37,9 @@ public static class AttributeCommandRegistry { private static bool _isInitialized; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetForPlaySession() => Clear(); + /// /// Initializes the method registry by scanning all assemblies for [ConsolePresetCommand] and [ConsoleUtilityCommand]. /// @@ -58,7 +62,7 @@ private static void DiscoverAllMethods() { foreach (var assembly in assemblies) { try { - var types = assembly.GetTypes(); + var types = AssemblyScanning.LoadableTypes(assembly); foreach (var type in types) { var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | @@ -456,8 +460,12 @@ public static object GetMethodInstance(MethodInfo method) { if (method.IsStatic) return null; - if (MethodInstances.TryGetValue(method, out var cached)) - return cached; + if (MethodInstances.TryGetValue(method, out var cached)) { + if (cached is not UnityEngine.Object unityObject || unityObject) + return cached; + + MethodInstances.Remove(method); + } // I really dislike this right now, but I'm not sure how to improve it just yet. if (typeof(MonoBehaviour).IsAssignableFrom(method.DeclaringType)) { diff --git a/Runtime/Console/CommandRegistry.cs b/Runtime/Console/CommandRegistry.cs index 8742318..bbbd47d 100644 --- a/Runtime/Console/CommandRegistry.cs +++ b/Runtime/Console/CommandRegistry.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Spellbound.Core.Tooling; using UnityEngine; namespace Spellbound.Core.Console { @@ -14,6 +15,9 @@ public class CommandRegistry { private static CommandRegistry _instance; public static CommandRegistry Instance => _instance ??= new CommandRegistry(); + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetForPlaySession() => _instance = null; + // Stores all of our commands that implement the interface ICommand. private readonly Dictionary _commands = CommandRegistryUtilities.CreateCaseInsensitiveDictionary(); @@ -30,8 +34,7 @@ private CommandRegistry() { } /// Call this during initialization. /// public void AutoRegisterCommands() { - // https://learn.microsoft.com/en-us/dotnet/api/system.appdomain.getassemblies?view=net-9.0 - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var assemblies = CommandRegistryUtilities.GetScannableAssemblies(); foreach (var assembly in assemblies) { try { @@ -47,7 +50,7 @@ public void AutoRegisterCommands() { /// Auto-register commands from a specific assembly. /// public void AutoRegisterCommandsFromAssembly(Assembly assembly) { - var commandTypes = assembly.GetTypes() + var commandTypes = AssemblyScanning.LoadableTypes(assembly) .Where(t => t.IsClass && !t.IsAbstract && typeof(ICommand).IsAssignableFrom(t)) .Where(t => t.GetCustomAttribute() != null); diff --git a/Runtime/Console/CommandRegistryUtilities.cs b/Runtime/Console/CommandRegistryUtilities.cs index 8419e66..40c12cf 100644 --- a/Runtime/Console/CommandRegistryUtilities.cs +++ b/Runtime/Console/CommandRegistryUtilities.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Spellbound.Core.Tooling; using UnityEngine; namespace Spellbound.Core.Console { @@ -20,33 +21,8 @@ internal static class CommandRegistryUtilities { /// /// Gets all assemblies that should be scanned for commands. - /// Filters out Unity editor assemblies and third-party editor plugins. /// - public static IEnumerable GetScannableAssemblies() { - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - return assemblies.Where(assembly => { - var assemblyName = assembly.GetName().Name; - - return !ShouldSkipAssembly(assemblyName); - }); - } - - /// - /// Determines if an assembly should be skipped during command discovery. - /// - public static bool ShouldSkipAssembly(string assemblyName) { - if (string.IsNullOrEmpty(assemblyName)) - return true; - - if (assemblyName.StartsWith("UnityEditor")) - return true; - - return assemblyName.Contains("Editor") && - (assemblyName.StartsWith("JetBrains") || - assemblyName.StartsWith("Unity.") || - assemblyName.Contains(".Editor.")); - } + public static IEnumerable GetScannableAssemblies() => AssemblyScanning.ScannableAssemblies(); /// /// Normalizes a command name to lowercase for consistent lookups. diff --git a/Runtime/Console/PresetResolver.cs b/Runtime/Console/PresetResolver.cs index f05ab2c..3c207cf 100644 --- a/Runtime/Console/PresetResolver.cs +++ b/Runtime/Console/PresetResolver.cs @@ -16,6 +16,9 @@ public static class PresetResolver { private static bool _isInitialized; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetForPlaySession() => Clear(); + /// /// Initializes the preset registry by scanning all ObjectPresets with ConsoleModules. /// diff --git a/Runtime/ObjectData/DeltaResolver.cs b/Runtime/ObjectData/DeltaResolver.cs index cc40712..49b034f 100644 --- a/Runtime/ObjectData/DeltaResolver.cs +++ b/Runtime/ObjectData/DeltaResolver.cs @@ -6,6 +6,7 @@ using Spellbound.Core.ModuleContracts; using Spellbound.Core.Objects; using Spellbound.Core.Packing; +using Spellbound.Core.Tooling; using UnityEngine; namespace Spellbound.Core.ObjectData { @@ -94,8 +95,8 @@ private static void EnsureLoaded() { var open = typeof(DeltaResolver).GetMethod(nameof(Run), BindingFlags.NonPublic | BindingFlags.Static); - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { - foreach (var type in GetTypesSafe(assembly)) { + foreach (var assembly in AssemblyScanning.ScannableAssemblies()) { + foreach (var type in AssemblyScanning.LoadableTypes(assembly)) { if (!type.IsClass || type.IsAbstract) continue; @@ -115,21 +116,5 @@ private static void EnsureLoaded() { } } } - - private static IEnumerable GetTypesSafe(Assembly assembly) { - try { - return assembly.GetTypes(); - } - catch (ReflectionTypeLoadException e) { - var loaded = new List(); - - foreach (var type in e.Types) { - if (type != null) - loaded.Add(type); - } - - return loaded; - } - } } } diff --git a/Runtime/Packing/SmartPackerRegistry.cs b/Runtime/Packing/SmartPackerRegistry.cs index ff97ecf..9b7ee3a 100644 --- a/Runtime/Packing/SmartPackerRegistry.cs +++ b/Runtime/Packing/SmartPackerRegistry.cs @@ -6,6 +6,7 @@ using Spellbound.Core.Hashing; using Spellbound.Core.Logging; using Spellbound.Core.Registries; +using Spellbound.Core.Tooling; using UnityEngine; namespace Spellbound.Core.Packing { @@ -35,8 +36,8 @@ private static void EnsureLoaded() { if (_isLoaded) return; _isLoaded = true; - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { - foreach (var type in assembly.GetTypes()) { + foreach (var assembly in AssemblyScanning.ScannableAssemblies()) { + foreach (var type in AssemblyScanning.LoadableTypes(assembly)) { if (!typeof(ISmartPacker).IsAssignableFrom(type)) continue; if (type.IsAbstract || type.IsInterface) continue; diff --git a/Runtime/Tooling/AssemblyScanning.cs b/Runtime/Tooling/AssemblyScanning.cs new file mode 100644 index 0000000..ba24977 --- /dev/null +++ b/Runtime/Tooling/AssemblyScanning.cs @@ -0,0 +1,43 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Spellbound.Core.Tooling { + public static class AssemblyScanning { + public static IEnumerable ScannableAssemblies() { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { + if (!ShouldSkip(assembly.GetName().Name)) + yield return assembly; + } + } + + public static IReadOnlyList LoadableTypes(Assembly assembly) { + try { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) { + var loaded = new List(); + + foreach (var type in e.Types) { + if (type != null) + loaded.Add(type); + } + + return loaded; + } + } + + private static bool ShouldSkip(string name) => + string.IsNullOrEmpty(name) + || name.StartsWith("System") + || name.StartsWith("mscorlib") + || name.StartsWith("netstandard") + || name.StartsWith("Mono.") + || name.StartsWith("nunit") + || name.StartsWith("UnityEngine") + || name.StartsWith("UnityEditor") + || name.StartsWith("Unity."); + } +} diff --git a/Runtime/Tooling/AssemblyScanning.cs.meta b/Runtime/Tooling/AssemblyScanning.cs.meta new file mode 100644 index 0000000..464182d --- /dev/null +++ b/Runtime/Tooling/AssemblyScanning.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c86601e087e54546ad1453deac03c37 \ No newline at end of file diff --git a/Tests/DynamicInstanceEntryTests.cs b/Tests/DynamicInstanceEntryTests.cs new file mode 100644 index 0000000..2e7dd4f --- /dev/null +++ b/Tests/DynamicInstanceEntryTests.cs @@ -0,0 +1,47 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using Spellbound.Core.ObjectData; +using Spellbound.Core.Packing; +using UnityEngine; + +namespace Spellbound.Core.Tests { + public class DynamicInstanceEntryTests { + [Test] + public void RoundTripsWithPopulatedDataSlots() { + var entry = new DynamicInstanceEntry( + 0xABCD1234u, + new TransformData(new Vector3(1f, 2f, 3f), new Vector3(10f, 20f, 30f), 2f), + true); + + entry.DataSlots[new InstanceDataKey(111u, 0)] = new byte[] { 1, 2, 3 }; + entry.DataSlots[new InstanceDataKey(222u, 5)] = new byte[] { 9, 8, 7, 6 }; + + var result = Packer.FromBytes(Packer.ToBytes(entry)); + + Assert.AreEqual(entry.PresetHash, result.PresetHash); + Assert.AreEqual(entry.WasMovingAtSave, result.WasMovingAtSave); + Assert.AreEqual(entry.Transform.Position, result.Transform.Position); + Assert.AreEqual(entry.Transform.Rotation, result.Transform.Rotation); + Assert.AreEqual(entry.Transform.Scale, result.Transform.Scale); + + Assert.AreEqual(2, result.DataSlots.Count); + Assert.AreEqual(new byte[] { 1, 2, 3 }, result.DataSlots[new InstanceDataKey(111u, 0)]); + Assert.AreEqual(new byte[] { 9, 8, 7, 6 }, result.DataSlots[new InstanceDataKey(222u, 5)]); + } + + [Test] + public void RoundTripsWithEmptyDataSlots() { + var entry = new DynamicInstanceEntry( + 42u, + new TransformData(Vector3.zero, Vector3.zero, 1f), + false); + + var result = Packer.FromBytes(Packer.ToBytes(entry)); + + Assert.AreEqual(42u, result.PresetHash); + Assert.IsFalse(result.WasMovingAtSave); + Assert.IsEmpty(result.DataSlots); + } + } +} diff --git a/Tests/DynamicInstanceEntryTests.cs.meta b/Tests/DynamicInstanceEntryTests.cs.meta new file mode 100644 index 0000000..f8c663b --- /dev/null +++ b/Tests/DynamicInstanceEntryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df5780e74c4442f49af84e37b6e5dd16 \ No newline at end of file diff --git a/Tests/HashRegistryTests.cs b/Tests/HashRegistryTests.cs new file mode 100644 index 0000000..743fe23 --- /dev/null +++ b/Tests/HashRegistryTests.cs @@ -0,0 +1,72 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using Spellbound.Core.Registries; +using UnityEngine.TestTools; + +namespace Spellbound.Core.Tests { + public class HashRegistryTests { + private class Entry : IRegistryEntry { + public uint Hash { get; set; } + public string Name { get; set; } + } + + [Test] + public void AddAndTryGetResolvesByHash() { + var registry = new HashRegistry(); + var entry = new Entry { Hash = 42u, Name = "answer" }; + + registry.Add(entry); + + Assert.AreEqual(1, registry.Count); + Assert.IsTrue(registry.TryGet(42u, out var resolved)); + Assert.AreSame(entry, resolved); + Assert.IsTrue(registry.Contains(42u)); + } + + [Test] + public void ZeroHashIsRejectedAsReservedNull() { + LogAssert.ignoreFailingMessages = true; + var registry = new HashRegistry(); + + registry.Add(new Entry { Hash = 0u }); + + Assert.AreEqual(0, registry.Count); + Assert.IsFalse(registry.TryGet(0u, out _)); + Assert.IsFalse(registry.Contains(0u)); + } + + [Test] + public void CollisionKeepsFirstEntry() { + LogAssert.ignoreFailingMessages = true; + var registry = new HashRegistry(); + var first = new Entry { Hash = 7u, Name = "first" }; + + registry.Add(first); + registry.Add(new Entry { Hash = 7u, Name = "second" }); + + Assert.AreEqual(1, registry.Count); + Assert.AreSame(first, registry.Get(7u)); + } + + [Test] + public void GetMissingHashReturnsNull() { + LogAssert.ignoreFailingMessages = true; + var registry = new HashRegistry(); + + Assert.IsNull(registry.Get(99u)); + } + + [Test] + public void ClearEmptiesRegistry() { + var registry = new HashRegistry(); + registry.Add(new Entry { Hash = 1u }); + registry.Add(new Entry { Hash = 2u }); + + registry.Clear(); + + Assert.AreEqual(0, registry.Count); + Assert.IsEmpty(registry.All); + } + } +} diff --git a/Tests/HashRegistryTests.cs.meta b/Tests/HashRegistryTests.cs.meta new file mode 100644 index 0000000..31314f6 --- /dev/null +++ b/Tests/HashRegistryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b5c78cf39c6daa54a889e093df58eafd \ No newline at end of file diff --git a/Tests/ObjectPoolTests.cs b/Tests/ObjectPoolTests.cs new file mode 100644 index 0000000..5eb5ebe --- /dev/null +++ b/Tests/ObjectPoolTests.cs @@ -0,0 +1,45 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using Spellbound.Core.ObjectPooling; + +namespace Spellbound.Core.Tests { + public class ObjectPoolTests { + private class Item { + public int ResetCount; + } + + private class ItemPool : ObjectPool { + public int Created; + + protected override Item Create() { + Created++; + + return new Item(); + } + + protected override void Reset(Item item) => item.ResetCount++; + } + + [Test] + public void RentCreatesWhenEmptyAndReusesAfterReturn() { + var pool = new ItemPool(); + + var first = pool.Rent(); + + Assert.AreEqual(1, pool.Created); + Assert.AreEqual(0, pool.Available); + + pool.Return(first); + + Assert.AreEqual(1, pool.Available); + Assert.AreEqual(1, first.ResetCount); + + var second = pool.Rent(); + + Assert.AreSame(first, second); + Assert.AreEqual(1, pool.Created); + Assert.AreEqual(0, pool.Available); + } + } +} diff --git a/Tests/ObjectPoolTests.cs.meta b/Tests/ObjectPoolTests.cs.meta new file mode 100644 index 0000000..f15f147 --- /dev/null +++ b/Tests/ObjectPoolTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc2b6ebe04cef0e42bc0b15eb645bac5 \ No newline at end of file diff --git a/Tests/ObservableArrayTests.cs b/Tests/ObservableArrayTests.cs new file mode 100644 index 0000000..fa2a313 --- /dev/null +++ b/Tests/ObservableArrayTests.cs @@ -0,0 +1,87 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using NUnit.Framework; +using Spellbound.Core.Tooling; + +namespace Spellbound.Core.Tests { + public class ObservableArrayTests { + [Test] + public void SetFiresChangeWithValueAndIndex() { + var array = new ObservableArray(3); + ObservableArrayChange received = default; + var fired = 0; + array.onChanged += change => { + received = change; + fired++; + }; + + array[1] = 42; + + Assert.AreEqual(1, fired); + Assert.AreEqual(ObservableArrayOperation.Set, received.Operation); + Assert.AreEqual(42, received.NewValue); + Assert.AreEqual(1, received.Index); + Assert.AreEqual(42, array[1]); + } + + [Test] + public void SettingEqualValueDoesNotFire() { + var array = new ObservableArray(2); + array[0] = 5; + var fired = 0; + array.onChanged += _ => fired++; + + array[0] = 5; + + Assert.AreEqual(0, fired); + } + + [Test] + public void ClearZeroesSlotsAndFiresOnce() { + var array = new ObservableArray(2); + array[0] = 1; + array[1] = 2; + var fired = 0; + array.onChanged += _ => fired++; + + array.Clear(); + + Assert.AreEqual(1, fired); + Assert.AreEqual(0, array[0]); + Assert.AreEqual(0, array[1]); + } + + [Test] + public void ResizePreservesExistingElements() { + var array = new ObservableArray(2); + array[0] = 10; + array[1] = 20; + + array.Resize(4); + + Assert.AreEqual(4, array.Count); + Assert.AreEqual(10, array[0]); + Assert.AreEqual(20, array[1]); + Assert.AreEqual(0, array[3]); + } + + [Test] + public void OutOfRangeAccessThrows() { + var array = new ObservableArray(2); + + Assert.Throws(() => _ = array[2]); + Assert.Throws(() => array[-1] = 1); + } + + [Test] + public void InsertionOperationsAreNotSupported() { + var array = new ObservableArray(1); + + Assert.Throws(() => array.Add(1)); + Assert.Throws(() => array.Insert(0, 1)); + Assert.Throws(() => array.Remove(1)); + Assert.Throws(() => array.RemoveAt(0)); + } + } +} diff --git a/Tests/ObservableArrayTests.cs.meta b/Tests/ObservableArrayTests.cs.meta new file mode 100644 index 0000000..546fdf4 --- /dev/null +++ b/Tests/ObservableArrayTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 728c96a68e2256c46aee1b5bb669b608 \ No newline at end of file diff --git a/Tests/PackerTests.cs b/Tests/PackerTests.cs new file mode 100644 index 0000000..0099bc7 --- /dev/null +++ b/Tests/PackerTests.cs @@ -0,0 +1,212 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Spellbound.Core.Packing; +using UnityEngine; + +namespace Spellbound.Core.Tests { + public class PackerTests { + private struct NumericPayload : IPacker { + public byte ByteValue; + public bool BoolValue; + public short ShortValue; + public ushort UShortValue; + public int IntValue; + public uint UIntValue; + public long LongValue; + public ulong ULongValue; + public float FloatValue; + + public void Pack(ref Span buffer) { + Packer.WriteByte(ref buffer, ByteValue); + Packer.WriteBool(ref buffer, BoolValue); + Packer.WriteShort(ref buffer, ShortValue); + Packer.WriteUShort(ref buffer, UShortValue); + Packer.WriteInt(ref buffer, IntValue); + Packer.WriteUInt(ref buffer, UIntValue); + Packer.WriteLong(ref buffer, LongValue); + Packer.WriteULong(ref buffer, ULongValue); + Packer.WriteFloat(ref buffer, FloatValue); + } + + public void Unpack(ref ReadOnlySpan buffer) { + ByteValue = Packer.ReadByte(ref buffer); + BoolValue = Packer.ReadBool(ref buffer); + ShortValue = Packer.ReadShort(ref buffer); + UShortValue = Packer.ReadUShort(ref buffer); + IntValue = Packer.ReadInt(ref buffer); + UIntValue = Packer.ReadUInt(ref buffer); + LongValue = Packer.ReadLong(ref buffer); + ULongValue = Packer.ReadULong(ref buffer); + FloatValue = Packer.ReadFloat(ref buffer); + } + } + + private struct TextPayload : IPacker { + public string Text; + + public void Pack(ref Span buffer) => Packer.WriteString(ref buffer, Text); + public void Unpack(ref ReadOnlySpan buffer) => Text = Packer.ReadString(ref buffer); + } + + [Test] + public void NumericPrimitivesRoundTrip() { + var payload = new NumericPayload { + ByteValue = 200, + BoolValue = true, + ShortValue = -12345, + UShortValue = 54321, + IntValue = int.MinValue, + UIntValue = uint.MaxValue, + LongValue = long.MinValue, + ULongValue = ulong.MaxValue, + FloatValue = 3.14159f + }; + + var result = Packer.FromBytes(Packer.ToBytes(payload)); + + Assert.AreEqual(payload, result); + } + + [Test] + public void BitwiseVariantsMatchBitConverterLayout() { + var standard = new byte[32]; + var bitwise = new byte[32]; + var standardSpan = standard.AsSpan(); + var bitwiseSpan = bitwise.AsSpan(); + + Packer.WriteShort(ref standardSpan, -1234); + Packer.WriteUShort(ref standardSpan, 65000); + Packer.WriteInt(ref standardSpan, -123456789); + Packer.WriteUInt(ref standardSpan, 3123456789u); + Packer.WriteLong(ref standardSpan, -1234567890123L); + Packer.WriteULong(ref standardSpan, 12345678901234567890ul); + Packer.WriteFloat(ref standardSpan, 1.5f); + + Packer.WriteShortBitwise(ref bitwiseSpan, -1234); + Packer.WriteUShortBitwise(ref bitwiseSpan, 65000); + Packer.WriteIntBitwise(ref bitwiseSpan, -123456789); + Packer.WriteUIntBitwise(ref bitwiseSpan, 3123456789u); + Packer.WriteLongBitwise(ref bitwiseSpan, -1234567890123L); + Packer.WriteULongBitwise(ref bitwiseSpan, 12345678901234567890ul); + Packer.WriteFloatBitwise(ref bitwiseSpan, 1.5f); + + Assert.AreEqual(standard, bitwise); + } + + [Test] + public void VectorAndQuaternionRoundTrip() { + var buffer = new byte[36]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteVector3(ref writeSpan, new Vector3(1.5f, -2.5f, 3.5f)); + Packer.WriteVector2(ref writeSpan, new Vector2(-4.5f, 5.5f)); + Packer.WriteQuaternion(ref writeSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new Vector3(1.5f, -2.5f, 3.5f), Packer.ReadVector3(ref readSpan)); + Assert.AreEqual(new Vector2(-4.5f, 5.5f), Packer.ReadVector2(ref readSpan)); + Assert.AreEqual(new Quaternion(0.1f, 0.2f, 0.3f, 0.4f), Packer.ReadQuaternion(ref readSpan)); + } + + [Test] + public void StringsRoundTripIncludingUnicode() { + var unicode = Packer.FromBytes(Packer.ToBytes(new TextPayload { Text = "Zażółć 🚀 jaźń" })); + var empty = Packer.FromBytes(Packer.ToBytes(new TextPayload { Text = string.Empty })); + var nullText = Packer.FromBytes(Packer.ToBytes(new TextPayload { Text = null })); + + Assert.AreEqual("Zażółć 🚀 jaźń", unicode.Text); + Assert.AreEqual(string.Empty, empty.Text); + Assert.AreEqual(string.Empty, nullText.Text); + } + + [Test] + public void PayloadLargerThanStackBufferRoundTrips() { + var large = new string('x', 12000); + + var result = Packer.FromBytes(Packer.ToBytes(new TextPayload { Text = large })); + + Assert.AreEqual(large, result.Text); + + var small = Packer.FromBytes(Packer.ToBytes(new TextPayload { Text = "small" })); + + Assert.AreEqual("small", small.Text); + } + + [Test] + public void BytesRoundTripAndNullBecomesEmpty() { + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteBytes(ref writeSpan, new byte[] { 1, 2, 3 }); + Packer.WriteBytes(ref writeSpan, (byte[])null); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new byte[] { 1, 2, 3 }, Packer.ReadBytes(ref readSpan)); + Assert.AreEqual(Array.Empty(), Packer.ReadBytes(ref readSpan)); + } + + [Test] + public void IntListRoundTripAndNullBecomesEmpty() { + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.PackIntList(ref writeSpan, new List { 7, -8, 9 }); + Packer.PackIntList(ref writeSpan, null); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new List { 7, -8, 9 }, Packer.UnpackIntList(ref readSpan)); + Assert.AreEqual(new List(), Packer.UnpackIntList(ref readSpan)); + } + + [Test] + public void PackerListAndArrayRoundTrip() { + var items = new List { + new() { Text = "first" }, + new() { Text = "second" } + }; + + var listResult = Packer.UnpackListFromBytes(Packer.PackListToBytes(items)); + + Assert.AreEqual(2, listResult.Count); + Assert.AreEqual("first", listResult[0].Text); + Assert.AreEqual("second", listResult[1].Text); + + var arrayResult = Packer.UnpackArrayFromBytes(Packer.PackArrayToBytes(items.ToArray())); + + Assert.AreEqual(2, arrayResult.Length); + Assert.AreEqual("first", arrayResult[0].Text); + Assert.AreEqual("second", arrayResult[1].Text); + + Assert.IsEmpty(Packer.UnpackListFromBytes(Packer.PackListToBytes(null))); + Assert.IsEmpty(Packer.UnpackArrayFromBytes(Packer.PackArrayToBytes(null))); + } + + [Test] + public void TransformDataRoundTrips() { + var data = new TransformData(new Vector3(10f, 20f, 30f), new Vector3(0f, 90f, 45f), 2.5f); + + var result = Packer.FromBytes(Packer.ToBytes(data)); + + Assert.AreEqual(data.Position, result.Position); + Assert.AreEqual(data.Rotation, result.Rotation); + Assert.AreEqual(data.Scale, result.Scale); + } + + [Test] + public void AreBytesEqualHandlesNullEmptyAndContent() { + Assert.IsTrue(Packer.AreBytesEqual(null, null)); + Assert.IsTrue(Packer.AreBytesEqual(null, Array.Empty())); + Assert.IsTrue(Packer.AreBytesEqual(Array.Empty(), Array.Empty())); + Assert.IsTrue(Packer.AreBytesEqual(new byte[] { 1, 2, 3 }, new byte[] { 1, 2, 3 })); + Assert.IsFalse(Packer.AreBytesEqual(new byte[] { 1, 2, 3 }, new byte[] { 1, 2, 4 })); + Assert.IsFalse(Packer.AreBytesEqual(new byte[] { 1, 2, 3 }, new byte[] { 1, 2 })); + Assert.IsFalse(Packer.AreBytesEqual(null, new byte[] { 1 })); + } + } +} diff --git a/Tests/PackerTests.cs.meta b/Tests/PackerTests.cs.meta new file mode 100644 index 0000000..bf22ed3 --- /dev/null +++ b/Tests/PackerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ede9afe0c145684898239f44ef917f2 \ No newline at end of file diff --git a/Tests/SingletonManagerTests.cs b/Tests/SingletonManagerTests.cs new file mode 100644 index 0000000..f91808d --- /dev/null +++ b/Tests/SingletonManagerTests.cs @@ -0,0 +1,39 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; +using NUnit.Framework; +using Spellbound.Core.Tooling; + +namespace Spellbound.Core.Tests { + public class SingletonManagerTests { + private class Service { } + + [TearDown] + public void TearDown() => SingletonManager.UnregisterSingleton(); + + [Test] + public void RegisterThenGetReturnsSameInstance() { + var service = new Service(); + + SingletonManager.RegisterSingleton(service); + + Assert.AreSame(service, SingletonManager.GetSingletonInstance()); + Assert.IsTrue(SingletonManager.TryGetSingletonInstance(out var resolved)); + Assert.AreSame(service, resolved); + } + + [Test] + public void MissingSingletonThrowsAndTryGetReturnsFalse() { + Assert.Throws(() => SingletonManager.GetSingletonInstance()); + Assert.IsFalse(SingletonManager.TryGetSingletonInstance(out _)); + } + + [Test] + public void UnregisterRemovesInstance() { + SingletonManager.RegisterSingleton(new Service()); + SingletonManager.UnregisterSingleton(); + + Assert.IsFalse(SingletonManager.TryGetSingletonInstance(out _)); + } + } +} diff --git a/Tests/SingletonManagerTests.cs.meta b/Tests/SingletonManagerTests.cs.meta new file mode 100644 index 0000000..37b1945 --- /dev/null +++ b/Tests/SingletonManagerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8656fd75c32cec45821c7f483191592 \ No newline at end of file From e739d8e231acb2f8163ec455ad9148ff53d1391f Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 15:55:06 -0500 Subject: [PATCH 10/19] bumped dev version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e047faf..9987b43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.0", + "version": "1.1.1", "displayName": "Spellbound Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From 02167fe1289d1eba569d8d14d19e95a94e38ca88 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 15:56:49 -0500 Subject: [PATCH 11/19] updated core package name --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5a9520b..de1f2cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.1", - "displayName": "Spellbound Core", + "version": "1.1.2", + "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", "unityRelease": "40f1", From 41bd2856bb082492108656147a95f25977f07b76 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 19:05:24 -0500 Subject: [PATCH 12/19] minor updates to console --- Runtime/Console/ClearCommand.cs | 13 ++--- Runtime/Console/ConsoleController.cs | 9 +++- Runtime/Console/ConsoleLogger.cs | 40 ++++++++------- Tests/ConsoleLoggerTests.cs | 74 ++++++++++++++++++++++++++++ Tests/ConsoleLoggerTests.cs.meta | 2 + package.json | 2 +- 6 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 Tests/ConsoleLoggerTests.cs create mode 100644 Tests/ConsoleLoggerTests.cs.meta diff --git a/Runtime/Console/ClearCommand.cs b/Runtime/Console/ClearCommand.cs index 88cfff6..6d82491 100644 --- a/Runtime/Console/ClearCommand.cs +++ b/Runtime/Console/ClearCommand.cs @@ -1,6 +1,4 @@ -// Copyright 2026 Spellbound Studio Inc. - -using UnityEngine; +// Copyright 2026 Spellbound Studio Inc. namespace Spellbound.Core.Console { [ConsoleCommandClass("clear", "cls")] @@ -10,14 +8,9 @@ public class ClearCommand : ICommand { public string Usage => "clear"; public CommandResult Execute(string[] args) { - var console = Object.FindAnyObjectByType(); - - if (console == null) - return CommandResult.Fail("Console controller not found"); - - console.ClearOutput(); + ConsoleLogger.Clear(); return CommandResult.Ok(); } } -} \ No newline at end of file +} diff --git a/Runtime/Console/ConsoleController.cs b/Runtime/Console/ConsoleController.cs index 4d8897e..5ee1790 100644 --- a/Runtime/Console/ConsoleController.cs +++ b/Runtime/Console/ConsoleController.cs @@ -56,17 +56,24 @@ private void Awake() { CommandRegistry.Instance.AutoRegisterCommands(); SetVisibilityImmediate(false); - ConsoleLogger.Initialize(this); } private void OnEnable() { if (inputField != null) inputField.onSubmit.AddListener(OnSubmitInput); + + ConsoleLogger.LinePrinted += LogOutput; + ConsoleLogger.ErrorPrinted += LogError; + ConsoleLogger.Cleared += ClearOutput; } private void OnDisable() { if (inputField != null) inputField.onSubmit.RemoveListener(OnSubmitInput); + + ConsoleLogger.LinePrinted -= LogOutput; + ConsoleLogger.ErrorPrinted -= LogError; + ConsoleLogger.Cleared -= ClearOutput; } #endregion diff --git a/Runtime/Console/ConsoleLogger.cs b/Runtime/Console/ConsoleLogger.cs index 1297bb6..94becea 100644 --- a/Runtime/Console/ConsoleLogger.cs +++ b/Runtime/Console/ConsoleLogger.cs @@ -1,38 +1,44 @@ -// Copyright 2026 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. + +using System; +using UnityEngine; namespace Spellbound.Core.Console { /// /// Static logging utility that prints directly to the console in build or in the editor. /// Useful for debugging and adding capability to your packages. + /// Console frontends subscribe to the events to receive output. /// public static class ConsoleLogger { - private static ConsoleController _consoleControllerInstance; + public static event Action LinePrinted; + public static event Action ErrorPrinted; + public static event Action Cleared; - /// - /// Initialize the logger with a console instance. - /// Called automatically by ConsoleController on Awake. - /// - internal static void Initialize(ConsoleController console) => _consoleControllerInstance = console; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetForPlaySession() { + LinePrinted = null; + ErrorPrinted = null; + Cleared = null; + } /// /// Print a message to the developer console. /// - public static void PrintToConsole(string message) { - if (_consoleControllerInstance != null) - _consoleControllerInstance.LogOutput(message); - } + public static void PrintToConsole(string message) => LinePrinted?.Invoke(message); /// /// Print an error message to the developer console. /// - public static void PrintError(string message) { - if (_consoleControllerInstance != null) - _consoleControllerInstance.LogError(message); - } + public static void PrintError(string message) => ErrorPrinted?.Invoke(message); + + /// + /// Clear the output of any listening console frontend. + /// + public static void Clear() => Cleared?.Invoke(); /// /// Check if the console logger is initialized and ready to use. /// - public static bool IsInitialized => _consoleControllerInstance != null; + public static bool IsInitialized => LinePrinted != null; } -} \ No newline at end of file +} diff --git a/Tests/ConsoleLoggerTests.cs b/Tests/ConsoleLoggerTests.cs new file mode 100644 index 0000000..ac44acf --- /dev/null +++ b/Tests/ConsoleLoggerTests.cs @@ -0,0 +1,74 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using Spellbound.Core.Console; + +namespace Spellbound.Core.Tests { + public class ConsoleLoggerTests { + [Test] + public void PrintToConsoleRaisesLinePrinted() { + string received = null; + void Handler(string message) => received = message; + + ConsoleLogger.LinePrinted += Handler; + + try { + ConsoleLogger.PrintToConsole("hello"); + Assert.AreEqual("hello", received); + } + finally { + ConsoleLogger.LinePrinted -= Handler; + } + } + + [Test] + public void PrintErrorRaisesErrorPrinted() { + string received = null; + void Handler(string message) => received = message; + + ConsoleLogger.ErrorPrinted += Handler; + + try { + ConsoleLogger.PrintError("boom"); + Assert.AreEqual("boom", received); + } + finally { + ConsoleLogger.ErrorPrinted -= Handler; + } + } + + [Test] + public void ClearRaisesCleared() { + var cleared = false; + void Handler() => cleared = true; + + ConsoleLogger.Cleared += Handler; + + try { + ConsoleLogger.Clear(); + Assert.IsTrue(cleared); + } + finally { + ConsoleLogger.Cleared -= Handler; + } + } + + [Test] + public void IsInitializedTracksLineSubscribers() { + void Handler(string message) { } + + Assert.IsFalse(ConsoleLogger.IsInitialized); + + ConsoleLogger.LinePrinted += Handler; + + try { + Assert.IsTrue(ConsoleLogger.IsInitialized); + } + finally { + ConsoleLogger.LinePrinted -= Handler; + } + + Assert.IsFalse(ConsoleLogger.IsInitialized); + } + } +} diff --git a/Tests/ConsoleLoggerTests.cs.meta b/Tests/ConsoleLoggerTests.cs.meta new file mode 100644 index 0000000..3b10a90 --- /dev/null +++ b/Tests/ConsoleLoggerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a33cd07b4204cee4fbab45d8691f8ae9 \ No newline at end of file diff --git a/package.json b/package.json index de1f2cf..816aa8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.2", + "version": "1.1.3", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From 31f0a2b87714b57537cae38ec050a06f7a68238f Mon Sep 17 00:00:00 2001 From: Judsin Date: Sun, 26 Jul 2026 21:41:53 -0500 Subject: [PATCH 13/19] missed logger in the commit --- Runtime/Console/ConsoleLogger.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Runtime/Console/ConsoleLogger.cs b/Runtime/Console/ConsoleLogger.cs index 94becea..a23b316 100644 --- a/Runtime/Console/ConsoleLogger.cs +++ b/Runtime/Console/ConsoleLogger.cs @@ -7,7 +7,6 @@ namespace Spellbound.Core.Console { /// /// Static logging utility that prints directly to the console in build or in the editor. /// Useful for debugging and adding capability to your packages. - /// Console frontends subscribe to the events to receive output. /// public static class ConsoleLogger { public static event Action LinePrinted; @@ -31,9 +30,6 @@ private static void ResetForPlaySession() { /// public static void PrintError(string message) => ErrorPrinted?.Invoke(message); - /// - /// Clear the output of any listening console frontend. - /// public static void Clear() => Cleared?.Invoke(); /// From bf138ada6009e53c6ef9e141e091873ded37e358 Mon Sep 17 00:00:00 2001 From: Judsin Date: Sat, 1 Aug 2026 19:48:34 -0500 Subject: [PATCH 14/19] abstracted the registry, fixed the transforms, and added a remove sink to console --- CHANGELOG.md | 8 + Editor/LogConfigEditor.cs | 3 + Editor/ObjectPresetManifestPostprocessor.cs | 43 +++ .../ObjectPresetManifestPostprocessor.cs.meta | 2 + .../EntityPrefabRegistryAuthoring.cs | 14 +- .../PresetBakeManifest.cs | 20 ++ .../PresetBakeManifest.cs.meta | 2 + Runtime/Logger/Log.cs | 54 +++- Runtime/Logger/LogSinkScope.cs | 15 + Runtime/Logger/LogSinkScope.cs.meta | 2 + Runtime/Logger/RecordedLogEntry.cs | 21 ++ Runtime/Logger/RecordedLogEntry.cs.meta | 2 + Runtime/Logger/Sinks/RecordingLogSink.cs | 76 +++++ Runtime/Logger/Sinks/RecordingLogSink.cs.meta | 2 + Runtime/Logger/readme.md | 22 +- Runtime/ObjectHandling/TransformData.cs | 2 +- Runtime/Objects/PresetRegistry.cs | 73 +---- Runtime/Registries/ResourceRegistry.cs | 203 ++++++++++++++ Runtime/Registries/ResourceRegistry.cs.meta | 2 + Tests/LogSinkTests.cs | 163 +++++++++++ Tests/LogSinkTests.cs.meta | 2 + Tests/ResourceRegistryTests.cs | 263 ++++++++++++++++++ Tests/ResourceRegistryTests.cs.meta | 2 + Tests/Spellbound.Core.Tests.asmdef | 1 + Tests/TransformDataTests.cs | 40 +++ Tests/TransformDataTests.cs.meta | 2 + package.json | 2 +- 27 files changed, 965 insertions(+), 76 deletions(-) create mode 100644 Editor/ObjectPresetManifestPostprocessor.cs create mode 100644 Editor/ObjectPresetManifestPostprocessor.cs.meta create mode 100644 Runtime/EntityPrefabRegistry/PresetBakeManifest.cs create mode 100644 Runtime/EntityPrefabRegistry/PresetBakeManifest.cs.meta create mode 100644 Runtime/Logger/LogSinkScope.cs create mode 100644 Runtime/Logger/LogSinkScope.cs.meta create mode 100644 Runtime/Logger/RecordedLogEntry.cs create mode 100644 Runtime/Logger/RecordedLogEntry.cs.meta create mode 100644 Runtime/Logger/Sinks/RecordingLogSink.cs create mode 100644 Runtime/Logger/Sinks/RecordingLogSink.cs.meta create mode 100644 Runtime/Registries/ResourceRegistry.cs create mode 100644 Runtime/Registries/ResourceRegistry.cs.meta create mode 100644 Tests/LogSinkTests.cs create mode 100644 Tests/LogSinkTests.cs.meta create mode 100644 Tests/ResourceRegistryTests.cs create mode 100644 Tests/ResourceRegistryTests.cs.meta create mode 100644 Tests/TransformDataTests.cs create mode 100644 Tests/TransformDataTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebdf06..03714a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.1.4] - 8/1/2026 + +- Log sinks can now be unregistered with `Log.RemoveSink` or scoped to a `using` block with `Log.AddScopedSink`. +- `EntityPrefabRegistryAuthoring` takes a `PresetBakeManifest`, letting the prefab registry rebake when presets are added, removed, or edited. +- `TransformData.RotAsQuaternion` rebuilds rotation in XYZ euler order, matching how the constructors capture it. Composed rotations no longer come back skewed. +- `RecordingLogSink` captures log entries in memory for assertions, and sink discovery now ignores non-public types so test sinks no longer appear in the Log Config inspector. +- `ResourceRegistry` discovers registry entries under a Resources folder and adds the lazy load, name index, and per-entry validation that every registry was hand-rolling. `PresetRegistry` now sits on it; a failed load clears the registry and reports again on the next access instead of leaving it half populated. + ## [2.0.0] - 4/18/2025 ### Second Release diff --git a/Editor/LogConfigEditor.cs b/Editor/LogConfigEditor.cs index 247f40d..0bd6ab6 100644 --- a/Editor/LogConfigEditor.cs +++ b/Editor/LogConfigEditor.cs @@ -171,6 +171,9 @@ private static List DiscoverSinks() { if (type.IsAbstract || type.IsInterface) continue; + if (!type.IsPublic) + continue; + if (type.GetConstructor(Type.EmptyTypes) == null) continue; diff --git a/Editor/ObjectPresetManifestPostprocessor.cs b/Editor/ObjectPresetManifestPostprocessor.cs new file mode 100644 index 0000000..da72314 --- /dev/null +++ b/Editor/ObjectPresetManifestPostprocessor.cs @@ -0,0 +1,43 @@ +// Copyright 2026 Spellbound Studio Inc. + +using Spellbound.Core.EntityPrefabs; +using Spellbound.Core.Objects; +using UnityEditor; + +namespace Spellbound.Core { + public sealed class ObjectPresetManifestPostprocessor : AssetPostprocessor { + private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, + string[] movedAssets, string[] movedFromAssetPaths) { + if (!ContainsPreset(importedAssets) && !ContainsPreset(movedAssets) && !ContainsAsset(deletedAssets)) + return; + + foreach (var guid in AssetDatabase.FindAssets($"t:{nameof(PresetBakeManifest)}")) { + var manifest = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + + if (manifest != null) + manifest.Bump(); + } + } + + private static bool ContainsPreset(string[] paths) { + foreach (var path in paths) { + if (!path.EndsWith(".asset")) + continue; + + if (typeof(ObjectPreset).IsAssignableFrom(AssetDatabase.GetMainAssetTypeAtPath(path))) + return true; + } + + return false; + } + + private static bool ContainsAsset(string[] paths) { + foreach (var path in paths) { + if (path.EndsWith(".asset")) + return true; + } + + return false; + } + } +} diff --git a/Editor/ObjectPresetManifestPostprocessor.cs.meta b/Editor/ObjectPresetManifestPostprocessor.cs.meta new file mode 100644 index 0000000..c7ea4e6 --- /dev/null +++ b/Editor/ObjectPresetManifestPostprocessor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe3c59ba1e7beca4fa69c578576238b3 \ No newline at end of file diff --git a/Runtime/EntityPrefabRegistry/EntityPrefabRegistryAuthoring.cs b/Runtime/EntityPrefabRegistry/EntityPrefabRegistryAuthoring.cs index 8b8551e..9778f6e 100644 --- a/Runtime/EntityPrefabRegistry/EntityPrefabRegistryAuthoring.cs +++ b/Runtime/EntityPrefabRegistry/EntityPrefabRegistryAuthoring.cs @@ -8,10 +8,20 @@ namespace Spellbound.Core.EntityPrefabs { /// /// Combs the resource folder and populates buffer with entity prefabs and their guid. /// - public sealed class EntityPrefabRegistryAuthoring : MonoBehaviour { } + public sealed class EntityPrefabRegistryAuthoring : MonoBehaviour { + public PresetBakeManifest presetManifest; + } public sealed class PrefabRegistryBaker : Baker { public override void Bake(EntityPrefabRegistryAuthoring authoring) { + if (authoring.presetManifest == null) { + Debug.LogWarning( + $"{nameof(EntityPrefabRegistryAuthoring)} on '{authoring.name}' has no " + + $"{nameof(PresetBakeManifest)} assigned, so added or removed presets will not trigger a rebake."); + } + else + DependsOn(authoring.presetManifest); + var entity = GetEntity(TransformUsageFlags.None); AddComponent(entity); @@ -20,6 +30,8 @@ public override void Bake(EntityPrefabRegistryAuthoring authoring) { var presets = Resources.LoadAll(""); foreach (var preset in presets) { + DependsOn(preset); + if (preset.bakePrefab == null) { Debug.LogWarning($"Preset {preset.name} has no bakePrefab assigned, skipping."); diff --git a/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs b/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs new file mode 100644 index 0000000..765b3bc --- /dev/null +++ b/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs @@ -0,0 +1,20 @@ +// Copyright 2026 Spellbound Studio Inc. + +using UnityEngine; + +namespace Spellbound.Core.EntityPrefabs { + [CreateAssetMenu(fileName = "Preset Bake Manifest", menuName = "Spellbound/Presets/PresetBakeManifest")] + public sealed class PresetBakeManifest : ScriptableObject { + [SerializeField] private int version; + + public int Version => version; + +#if UNITY_EDITOR + public void Bump() { + version++; + UnityEditor.EditorUtility.SetDirty(this); + UnityEditor.AssetDatabase.SaveAssetIfDirty(this); + } +#endif + } +} diff --git a/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs.meta b/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs.meta new file mode 100644 index 0000000..d9e4ccf --- /dev/null +++ b/Runtime/EntityPrefabRegistry/PresetBakeManifest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd01d8b9422614245b34116818b296c7 \ No newline at end of file diff --git a/Runtime/Logger/Log.cs b/Runtime/Logger/Log.cs index 2d67c5f..7ee04b9 100644 --- a/Runtime/Logger/Log.cs +++ b/Runtime/Logger/Log.cs @@ -11,16 +11,58 @@ private struct RegisteredSink { public LogLevel FilterLevel; } - private static RegisteredSink[] _sinks = System.Array.Empty(); + private static readonly object SinkMutationLock = new object(); + + private static volatile RegisteredSink[] _sinks = System.Array.Empty(); public static void AddSink(ILogSink sink, LogConfig config, LogLevel filterLevel) { + if (sink == null) + return; + sink.Initialize(config); - var old = _sinks; - var next = new RegisteredSink[old.Length + 1]; - System.Array.Copy(old, next, old.Length); - next[old.Length] = new RegisteredSink { Sink = sink, FilterLevel = filterLevel }; - _sinks = next; + lock (SinkMutationLock) { + var old = _sinks; + var next = new RegisteredSink[old.Length + 1]; + System.Array.Copy(old, next, old.Length); + next[old.Length] = new RegisteredSink { Sink = sink, FilterLevel = filterLevel }; + _sinks = next; + } + } + + public static LogSinkScope AddScopedSink(ILogSink sink, LogConfig config, LogLevel filterLevel) { + AddSink(sink, config, filterLevel); + + return new LogSinkScope(sink); + } + + public static bool RemoveSink(ILogSink sink) { + if (sink == null) + return false; + + lock (SinkMutationLock) { + var old = _sinks; + var index = -1; + + for (var i = 0; i < old.Length; i++) { + if (!ReferenceEquals(old[i].Sink, sink)) + continue; + + index = i; + + break; + } + + if (index < 0) + return false; + + var next = new RegisteredSink[old.Length - 1]; + System.Array.Copy(old, next, index); + System.Array.Copy(old, index + 1, next, index, old.Length - index - 1); + _sinks = next; + + return true; + } } [Conditional("SPELLBOUND_LOG_VERBOSE")] diff --git a/Runtime/Logger/LogSinkScope.cs b/Runtime/Logger/LogSinkScope.cs new file mode 100644 index 0000000..54bafda --- /dev/null +++ b/Runtime/Logger/LogSinkScope.cs @@ -0,0 +1,15 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; + +namespace Spellbound.Core.Logging { + public readonly struct LogSinkScope : IDisposable { + private readonly ILogSink _sink; + + internal LogSinkScope(ILogSink sink) => _sink = sink; + + public ILogSink Sink => _sink; + + public void Dispose() => Log.RemoveSink(_sink); + } +} diff --git a/Runtime/Logger/LogSinkScope.cs.meta b/Runtime/Logger/LogSinkScope.cs.meta new file mode 100644 index 0000000..742105f --- /dev/null +++ b/Runtime/Logger/LogSinkScope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0faedda3a18f624da87405b3c5ae0e5 \ No newline at end of file diff --git a/Runtime/Logger/RecordedLogEntry.cs b/Runtime/Logger/RecordedLogEntry.cs new file mode 100644 index 0000000..c25f2ec --- /dev/null +++ b/Runtime/Logger/RecordedLogEntry.cs @@ -0,0 +1,21 @@ +// Copyright 2026 Spellbound Studio Inc. + +namespace Spellbound.Core.Logging { + public readonly struct RecordedLogEntry { + public readonly LogLevel Level; + public readonly string Source; + public readonly string Message; + public readonly string Member; + public readonly int Line; + + public RecordedLogEntry(LogLevel level, string source, string message, string member, int line) { + Level = level; + Source = source; + Message = message; + Member = member; + Line = line; + } + + public override string ToString() => $"[{Level}] [{Source}.{Member}:{Line}] {Message}"; + } +} diff --git a/Runtime/Logger/RecordedLogEntry.cs.meta b/Runtime/Logger/RecordedLogEntry.cs.meta new file mode 100644 index 0000000..0d237e1 --- /dev/null +++ b/Runtime/Logger/RecordedLogEntry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 80765c03f9f6fa14f809c40334521984 \ No newline at end of file diff --git a/Runtime/Logger/Sinks/RecordingLogSink.cs b/Runtime/Logger/Sinks/RecordingLogSink.cs new file mode 100644 index 0000000..7115207 --- /dev/null +++ b/Runtime/Logger/Sinks/RecordingLogSink.cs @@ -0,0 +1,76 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; + +namespace Spellbound.Core.Logging { + public class RecordingLogSink : ILogSink { + private const string DisplayNameValue = "Recording"; + + private readonly List _entries = new List(); + private readonly object _gate = new object(); + private readonly LogLevel _minimumLevel; + + public RecordingLogSink(LogLevel minimumLevel) => _minimumLevel = minimumLevel; + + public string DisplayName => DisplayNameValue; + + public void Initialize(LogConfig config) { } + + public void Emit(LogLevel level, string source, string message, string member, int line) { + if (level < _minimumLevel) + return; + + lock (_gate) + _entries.Add(new RecordedLogEntry(level, source, message, member, line)); + } + + public RecordedLogEntry[] Entries { + get { + lock (_gate) + return _entries.ToArray(); + } + } + + public int Count { + get { + lock (_gate) + return _entries.Count; + } + } + + public int CountOf(LogLevel level) { + lock (_gate) { + var count = 0; + + for (var i = 0; i < _entries.Count; i++) { + if (_entries[i].Level == level) + count++; + } + + return count; + } + } + + public bool Contains(LogLevel level, string substring) { + lock (_gate) { + for (var i = 0; i < _entries.Count; i++) { + if (_entries[i].Level != level) + continue; + + var message = _entries[i].Message; + + if (message != null && message.IndexOf(substring, StringComparison.Ordinal) >= 0) + return true; + } + + return false; + } + } + + public void Clear() { + lock (_gate) + _entries.Clear(); + } + } +} diff --git a/Runtime/Logger/Sinks/RecordingLogSink.cs.meta b/Runtime/Logger/Sinks/RecordingLogSink.cs.meta new file mode 100644 index 0000000..b3384bf --- /dev/null +++ b/Runtime/Logger/Sinks/RecordingLogSink.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b438b5b8ea2c38478e2b6a224dea0b9 \ No newline at end of file diff --git a/Runtime/Logger/readme.md b/Runtime/Logger/readme.md index 7d1567e..a87e1c4 100644 --- a/Runtime/Logger/readme.md +++ b/Runtime/Logger/readme.md @@ -52,7 +52,7 @@ Example: global set to **Debug**, File sink filtered to **Debug**, Unity Console ## Creating a Custom Sink -Implement `ILogSink` with a parameterless constructor. It will be discovered automatically via reflection and appear in the config inspector. +Implement `ILogSink` as a public, top-level type with a parameterless constructor. It will be discovered automatically via reflection and appear in the config inspector. Nested and internal sinks, and sinks whose constructor takes arguments, are never discovered, so they stay out of the inspector and out of user config assets. ```csharp public class TelemetrySink : ILogSink { @@ -72,4 +72,22 @@ Sinks with external dependencies that require constructor arguments should skip ```csharp Log.AddSink(new MyComplexSink(apiKey, endpoint), config, LogLevel.Warning); -``` \ No newline at end of file +``` + +## Temporary Sinks + +`Log.RemoveSink` unregisters a sink by instance and returns whether it was registered. `Log.AddScopedSink` returns a disposable that removes it again, so a sink can be attached for the life of a `using` block and nothing leaks into the rest of the session. + +```csharp +var sink = new RecordingLogSink(LogLevel.Error); + +using (Log.AddScopedSink(sink, config, LogLevel.Verbose)) { + RunTheThingThatShouldFail(); + Assert.AreEqual(1, sink.CountOf(LogLevel.Error)); + Assert.IsTrue(sink.Contains(LogLevel.Error, "chunk load failed")); +} +``` + +`RecordingLogSink` keeps entries in memory instead of writing them anywhere, which is what lets a test assert that code reported an error. Its constructor takes a minimum level, so it is not reflection-discoverable and never appears in the config inspector. `Entries`, `Count`, `CountOf`, `Contains`, and `Clear` are the reading surface, and it is safe to emit into from background threads. + +Removal does not dispose the sink. Sinks that hold resources, like `FileSink`, are still the caller's to dispose. \ No newline at end of file diff --git a/Runtime/ObjectHandling/TransformData.cs b/Runtime/ObjectHandling/TransformData.cs index 3d38438..9e28405 100644 --- a/Runtime/ObjectHandling/TransformData.cs +++ b/Runtime/ObjectHandling/TransformData.cs @@ -50,7 +50,7 @@ public readonly LocalTransform ToLocalTransform() => Scale = Scale }; - public Quaternion RotAsQuaternion() => Quaternion.Euler(Rotation); + public Quaternion RotAsQuaternion() => quaternion.EulerXYZ(math.radians(Rotation)); public Vector3 ScaleAsVector3() => new(Scale, Scale, Scale); diff --git a/Runtime/Objects/PresetRegistry.cs b/Runtime/Objects/PresetRegistry.cs index 8a468fe..1286c3f 100644 --- a/Runtime/Objects/PresetRegistry.cs +++ b/Runtime/Objects/PresetRegistry.cs @@ -1,91 +1,34 @@ // Copyright 2026 Spellbound Studio Inc. -using System; using System.Collections.Generic; using Spellbound.Core.Registries; using UnityEngine; namespace Spellbound.Core.Objects { - /// - /// Resolves s by the stable FNV-1a hash of their asset GUID. Auto-discovers - /// every preset under a Resources folder; a hash collision is a hard error (regenerate one preset's GUID). - /// public static class PresetRegistry { - private static readonly HashRegistry Registry = new(); - private static bool _isLoaded; + private static readonly ResourceRegistry Registry = new(string.Empty); - /// - /// Every registered preset. - /// - public static IReadOnlyList All { - get { - EnsureLoaded(); - - return Registry.All; - } - } + public static IReadOnlyList All => Registry.All; #region Lifecycle [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void ResetForPlaySession() { - Registry.Clear(); - _isLoaded = false; - } + private static void ResetForPlaySession() => Registry.Reset(); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - private static void WarmUp() => EnsureLoaded(); + private static void WarmUp() => Registry.EnsureLoaded(); #endregion #region API - /// - /// The preset for a hash, or null. - /// - public static ObjectPreset ResolvePreset(uint hash) { - EnsureLoaded(); - - return Registry.TryGet(hash, out var preset) ? preset : null; - } - - /// - /// The preset for a hash; false if none is registered. - /// - public static bool TryResolvePreset(uint hash, out ObjectPreset preset) { - EnsureLoaded(); - - return Registry.TryGet(hash, out preset); - } - - /// - /// True if a preset with this hash is registered. - /// - public static bool Contains(uint hash) { - EnsureLoaded(); - - return Registry.Contains(hash); - } - - #endregion - - #region Internal - - private static void EnsureLoaded() { - if (_isLoaded) - return; + public static ObjectPreset ResolvePreset(uint hash) => Registry.Get(hash); - _isLoaded = true; + public static bool TryResolvePreset(uint hash, out ObjectPreset preset) => Registry.TryGet(hash, out preset); - foreach (var preset in Resources.LoadAll("")) { - if (Registry.Contains(preset.Hash)) - throw new InvalidOperationException( - $"Preset hash collision: '{preset.objectName}' (asset '{preset.name}', guid {preset.Guid}) " + - $"collides at hash {preset.Hash}. Regenerate one preset's GUID to resolve."); + public static bool Contains(uint hash) => Registry.Contains(hash); - Registry.Add(preset); - } - } + public static void Reload() => Registry.Reload(); #endregion } diff --git a/Runtime/Registries/ResourceRegistry.cs b/Runtime/Registries/ResourceRegistry.cs new file mode 100644 index 0000000..d14a20c --- /dev/null +++ b/Runtime/Registries/ResourceRegistry.cs @@ -0,0 +1,203 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using Spellbound.Core.Logging; +using UnityEngine; + +namespace Spellbound.Core.Registries { + public sealed class ResourceRegistry : IRegistry + where TEntry : ScriptableObject, IRegistryEntry { + private readonly string _resourceFolder; + private readonly Func _nameSelector; + private readonly Action _validateEntry; + private readonly Func _loader; + + private readonly HashRegistry _entries = new(); + private readonly Dictionary _byName = new(); + + private bool _isLoaded; + + public ResourceRegistry( + string resourceFolder, + Func nameSelector = null, + Action validateEntry = null, + Func loader = null) { + _resourceFolder = resourceFolder ?? string.Empty; + _nameSelector = nameSelector; + _validateEntry = validateEntry; + _loader = loader ?? Resources.LoadAll; + } + + public int Count { + get { + EnsureLoaded(); + + return _entries.Count; + } + } + + public IReadOnlyList All { + get { + EnsureLoaded(); + + return _entries.All; + } + } + + public bool TryGet(uint hash, out TEntry entry) { + EnsureLoaded(); + + return _entries.TryGet(hash, out entry); + } + + public TEntry Get(uint hash) { + EnsureLoaded(); + + return _entries.TryGet(hash, out var entry) ? entry : null; + } + + public bool Contains(uint hash) { + EnsureLoaded(); + + return _entries.Contains(hash); + } + + public TEntry Get(string entryName) { + RequireNameIndex(); + EnsureLoaded(); + + return entryName != null && _byName.TryGetValue(entryName, out var entry) ? entry : null; + } + + public uint GetHash(string entryName) { + var entry = Get(entryName); + + if (entry == null) { + throw new KeyNotFoundException( + $"{typeof(TEntry).Name} '{entryName}' is not registered. Author one under {DescribeFolder()}."); + } + + return entry.Hash; + } + + public bool TryGetHash(string entryName, out uint hash) { + var entry = Get(entryName); + hash = entry == null ? 0u : entry.Hash; + + return entry != null; + } + + public bool IsRegistered(string entryName) => Get(entryName) != null; + + public string GetName(uint hash) { + RequireNameIndex(); + EnsureLoaded(); + + return _entries.TryGet(hash, out var entry) ? _nameSelector(entry) : null; + } + + public bool TryGetName(uint hash, out string entryName) { + entryName = GetName(hash); + + return entryName != null; + } + + public void EnsureLoaded() { + if (_isLoaded) + return; + + Load(); + } + + public void Reload() { + Clear(); + Load(); + } + + public void Reset() => Clear(); + + private void Load() { + try { + var loaded = _loader(_resourceFolder); + + if (loaded == null) + return; + + foreach (var entry in loaded) { + if (entry == null) + continue; + + if (entry.Hash == 0u) { + Log.Error( + $"{typeof(TEntry).Name} '{entry.name}' has no stamped identity; select the asset once in " + + "the editor so it restamps, then save the project. Entry skipped."); + + continue; + } + + RejectHashCollision(entry); + IndexByName(entry); + + _validateEntry?.Invoke(entry); + + _entries.Add(entry); + } + } + catch { + Clear(); + + throw; + } + + _isLoaded = true; + } + + private void RejectHashCollision(TEntry entry) { + if (!_entries.TryGet(entry.Hash, out var existing)) + return; + + throw new InvalidOperationException( + $"{typeof(TEntry).Name} hash collision: asset '{entry.name}' collides with '{existing.name}' at hash " + + $"{entry.Hash}. Regenerate one asset's GUID to resolve."); + } + + private void IndexByName(TEntry entry) { + if (_nameSelector == null) + return; + + var entryName = _nameSelector(entry); + + if (string.IsNullOrEmpty(entryName)) { + throw new InvalidOperationException( + $"{typeof(TEntry).Name} asset '{entry.name}' has an empty name. This registry indexes by name, so " + + "every entry needs one."); + } + + if (_byName.TryAdd(entryName, entry)) + return; + + throw new InvalidOperationException( + $"Duplicate {typeof(TEntry).Name} name: '{entryName}' (asset '{entry.name}') is already registered by " + + $"asset '{_byName[entryName].name}'. Names must be unique, rename one."); + } + + private void RequireNameIndex() { + if (_nameSelector != null) + return; + + throw new InvalidOperationException( + $"ResourceRegistry<{typeof(TEntry).Name}> was built without a name selector, so it cannot resolve by " + + "name. Pass one to the constructor."); + } + + private void Clear() { + _entries.Clear(); + _byName.Clear(); + _isLoaded = false; + } + + private string DescribeFolder() => + string.IsNullOrEmpty(_resourceFolder) ? "a Resources folder" : $"Resources/{_resourceFolder}"; + } +} diff --git a/Runtime/Registries/ResourceRegistry.cs.meta b/Runtime/Registries/ResourceRegistry.cs.meta new file mode 100644 index 0000000..6f161e4 --- /dev/null +++ b/Runtime/Registries/ResourceRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ffeeb61c2fcc86440a7724ec778318cf \ No newline at end of file diff --git a/Tests/LogSinkTests.cs b/Tests/LogSinkTests.cs new file mode 100644 index 0000000..c0f5250 --- /dev/null +++ b/Tests/LogSinkTests.cs @@ -0,0 +1,163 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Linq; +using NUnit.Framework; +using Spellbound.Core.Logging; + +namespace Spellbound.Core.Tests { + public class LogSinkTests { + private static RecordingLogSink NewSink() => new RecordingLogSink(LogLevel.Verbose); + + private static void Emit(LogLevel level, string message) => + Log.Emit(level, message, "LogSinkTests.cs", nameof(Emit), 0); + + private static string[] MessagesOf(RecordingLogSink sink) => + sink.Entries.Select(entry => entry.Message).ToArray(); + + [Test] + public void AddedSinkReceivesEmittedMessages() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + + try { + Emit(LogLevel.Error, "boom"); + + CollectionAssert.AreEqual(new[] { "boom" }, MessagesOf(sink)); + } + finally { + Log.RemoveSink(sink); + } + } + + [Test] + public void RemovedSinkReceivesNothingFurther() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + Emit(LogLevel.Error, "before"); + + Assert.IsTrue(Log.RemoveSink(sink)); + + Emit(LogLevel.Error, "after"); + + CollectionAssert.AreEqual(new[] { "before" }, MessagesOf(sink)); + } + + [Test] + public void RemoveSinkReturnsFalseWhenNotRegistered() { + Assert.IsFalse(Log.RemoveSink(NewSink())); + Assert.IsFalse(Log.RemoveSink(null)); + } + + [Test] + public void RemoveSinkRemovesOnlyTheGivenInstance() { + var kept = NewSink(); + var removed = NewSink(); + Log.AddSink(kept, null, LogLevel.Verbose); + Log.AddSink(removed, null, LogLevel.Verbose); + + try { + Log.RemoveSink(removed); + Emit(LogLevel.Error, "boom"); + + CollectionAssert.AreEqual(new[] { "boom" }, MessagesOf(kept)); + Assert.AreEqual(0, removed.Count); + } + finally { + Log.RemoveSink(kept); + } + } + + [Test] + public void RemoveSinkIsIdempotent() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + + Assert.IsTrue(Log.RemoveSink(sink)); + Assert.IsFalse(Log.RemoveSink(sink)); + } + + [Test] + public void ScopedSinkUnregistersOnDispose() { + var sink = NewSink(); + + using (Log.AddScopedSink(sink, null, LogLevel.Verbose)) + Emit(LogLevel.Error, "inside"); + + Emit(LogLevel.Error, "outside"); + + CollectionAssert.AreEqual(new[] { "inside" }, MessagesOf(sink)); + } + + [Test] + public void RegistrationFilterLevelGatesEmission() { + var sink = NewSink(); + + using (Log.AddScopedSink(sink, null, LogLevel.Warning)) { + Emit(LogLevel.Info, "chatter"); + Emit(LogLevel.Warning, "careful"); + Emit(LogLevel.Error, "boom"); + } + + CollectionAssert.AreEqual( + new[] { LogLevel.Warning, LogLevel.Error }, + sink.Entries.Select(entry => entry.Level).ToArray() + ); + } + + [Test] + public void SinkMinimumLevelGatesRecording() { + var sink = new RecordingLogSink(LogLevel.Error); + + using (Log.AddScopedSink(sink, null, LogLevel.Verbose)) { + Emit(LogLevel.Warning, "careful"); + Emit(LogLevel.Error, "boom"); + } + + CollectionAssert.AreEqual(new[] { "boom" }, MessagesOf(sink)); + } + + [Test] + public void ErrorReachesScopedSink() { + var sink = NewSink(); + + using (Log.AddScopedSink(sink, null, LogLevel.Error)) + Log.Error("reported"); + + var entries = sink.Entries; + + Assert.AreEqual(1, entries.Length); + Assert.AreEqual(LogLevel.Error, entries[0].Level); + Assert.AreEqual("reported", entries[0].Message); + Assert.AreEqual("LogSinkTests", entries[0].Source); + } + + [Test] + public void CountOfAndContainsMatchRecordedEntries() { + var sink = NewSink(); + + using (Log.AddScopedSink(sink, null, LogLevel.Verbose)) { + Emit(LogLevel.Warning, "disk almost full"); + Emit(LogLevel.Error, "chunk load failed"); + Emit(LogLevel.Error, "chunk save failed"); + } + + Assert.AreEqual(2, sink.CountOf(LogLevel.Error)); + Assert.AreEqual(1, sink.CountOf(LogLevel.Warning)); + Assert.IsTrue(sink.Contains(LogLevel.Error, "save failed")); + Assert.IsFalse(sink.Contains(LogLevel.Warning, "save failed")); + } + + [Test] + public void ClearDropsRecordedEntries() { + var sink = NewSink(); + + using (Log.AddScopedSink(sink, null, LogLevel.Verbose)) { + Emit(LogLevel.Error, "boom"); + sink.Clear(); + Emit(LogLevel.Error, "again"); + } + + CollectionAssert.AreEqual(new[] { "again" }, MessagesOf(sink)); + } + } +} diff --git a/Tests/LogSinkTests.cs.meta b/Tests/LogSinkTests.cs.meta new file mode 100644 index 0000000..e8a309f --- /dev/null +++ b/Tests/LogSinkTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd31ce4b1669f6644b97fdd5d752893b \ No newline at end of file diff --git a/Tests/ResourceRegistryTests.cs b/Tests/ResourceRegistryTests.cs new file mode 100644 index 0000000..7d8ddef --- /dev/null +++ b/Tests/ResourceRegistryTests.cs @@ -0,0 +1,263 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Spellbound.Core.Registries; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Spellbound.Core.Tests { + public class ResourceRegistryTests { + private class Entry : ScriptableObject, IRegistryEntry { + public uint Hash { get; private set; } + public string EntryName { get; private set; } + + public static Entry Create(uint hash, string entryName = null, string assetName = null) { + var entry = CreateInstance(); + entry.Hash = hash; + entry.EntryName = entryName; + entry.name = assetName ?? entryName ?? $"Entry{hash}"; + + return entry; + } + } + + private readonly List _created = new(); + + private Entry Make(uint hash, string entryName = null, string assetName = null) { + var entry = Entry.Create(hash, entryName, assetName); + _created.Add(entry); + + return entry; + } + + private static Func LoaderFor(params Entry[] entries) => _ => entries; + + [TearDown] + public void TearDown() { + LogAssert.ignoreFailingMessages = false; + + foreach (var entry in _created) + UnityEngine.Object.DestroyImmediate(entry); + + _created.Clear(); + } + + [Test] + public void LoadsLazilyOnFirstAccess() { + var loads = 0; + var registry = new ResourceRegistry("Things", loader: _ => { + loads++; + + return new[] { Make(1u), Make(2u) }; + }); + + Assert.AreEqual(0, loads); + Assert.AreEqual(2, registry.Count); + Assert.AreEqual(1, loads); + + _ = registry.All; + + Assert.AreEqual(1, loads); + } + + [Test] + public void LoaderReceivesResourceFolder() { + string requestedFolder = null; + var registry = new ResourceRegistry("Things", loader: folder => { + requestedFolder = folder; + + return Array.Empty(); + }); + + registry.EnsureLoaded(); + + Assert.AreEqual("Things", requestedFolder); + } + + [Test] + public void ResolvesByHash() { + var entry = Make(42u); + var registry = new ResourceRegistry("Things", loader: LoaderFor(entry)); + + Assert.AreSame(entry, registry.Get(42u)); + Assert.IsTrue(registry.TryGet(42u, out var resolved)); + Assert.AreSame(entry, resolved); + Assert.IsTrue(registry.Contains(42u)); + } + + [Test] + public void MissingHashResolvesToNull() { + var registry = new ResourceRegistry("Things", loader: LoaderFor(Make(1u))); + + Assert.IsNull(registry.Get(99u)); + Assert.IsFalse(registry.TryGet(99u, out _)); + Assert.IsFalse(registry.Contains(99u)); + } + + [Test] + public void ResolvesByName() { + var entry = Make(7u, "Vitality"); + var registry = new ResourceRegistry("Things", e => e.EntryName, loader: LoaderFor(entry)); + + Assert.AreSame(entry, registry.Get("Vitality")); + Assert.AreEqual(7u, registry.GetHash("Vitality")); + Assert.IsTrue(registry.TryGetHash("Vitality", out var hash)); + Assert.AreEqual(7u, hash); + Assert.IsTrue(registry.IsRegistered("Vitality")); + Assert.AreEqual("Vitality", registry.GetName(7u)); + } + + [Test] + public void UnknownNameFailsWithoutThrowingOnTryPath() { + var registry = new ResourceRegistry("Things", e => e.EntryName, loader: LoaderFor(Make(7u, "Vitality"))); + + Assert.IsNull(registry.Get("Strength")); + Assert.IsFalse(registry.IsRegistered("Strength")); + Assert.IsFalse(registry.TryGetHash("Strength", out var hash)); + Assert.AreEqual(0u, hash); + Assert.Throws(() => registry.GetHash("Strength")); + } + + [Test] + public void NameLookupWithoutSelectorThrows() { + var registry = new ResourceRegistry("Things", loader: LoaderFor(Make(7u, "Vitality"))); + + Assert.Throws(() => registry.Get("Vitality")); + Assert.Throws(() => registry.GetName(7u)); + } + + [Test] + public void UnstampedEntryIsSkipped() { + LogAssert.ignoreFailingMessages = true; + var stamped = Make(5u); + var registry = new ResourceRegistry("Things", loader: LoaderFor(Make(0u), stamped)); + + Assert.AreEqual(1, registry.Count); + Assert.AreSame(stamped, registry.Get(5u)); + } + + [Test] + public void NullEntryIsSkipped() { + var registry = new ResourceRegistry("Things", loader: _ => new[] { null, Make(5u) }); + + Assert.AreEqual(1, registry.Count); + } + + [Test] + public void HashCollisionThrows() { + var registry = new ResourceRegistry( + "Things", loader: LoaderFor(Make(3u, assetName: "First"), Make(3u, assetName: "Second"))); + + var exception = Assert.Throws(() => registry.EnsureLoaded()); + + StringAssert.Contains("First", exception.Message); + StringAssert.Contains("Second", exception.Message); + } + + [Test] + public void DuplicateNameThrows() { + var registry = new ResourceRegistry( + "Things", + e => e.EntryName, + loader: LoaderFor(Make(1u, "Vitality", "A"), Make(2u, "Vitality", "B"))); + + var exception = Assert.Throws(() => registry.EnsureLoaded()); + + StringAssert.Contains("Vitality", exception.Message); + } + + [Test] + public void EmptyNameThrows() { + var registry = new ResourceRegistry( + "Things", e => e.EntryName, loader: LoaderFor(Make(1u, assetName: "Unnamed"))); + + Assert.Throws(() => registry.EnsureLoaded()); + } + + [Test] + public void ValidatorRunsForEveryEntry() { + var validated = new List(); + var registry = new ResourceRegistry( + "Things", validateEntry: e => validated.Add(e.Hash), loader: LoaderFor(Make(1u), Make(2u))); + + registry.EnsureLoaded(); + + CollectionAssert.AreEqual(new[] { 1u, 2u }, validated); + } + + [Test] + public void ValidatorFailureIsRetriedOnNextAccess() { + var attempts = 0; + var registry = new ResourceRegistry( + "Things", + validateEntry: entry => { + if (entry.Hash == 2u) + throw new InvalidOperationException("bad entry"); + }, + loader: _ => { + attempts++; + + return attempts == 1 ? new[] { Make(1u), Make(2u) } : new[] { Make(1u) }; + }); + + Assert.Throws(() => registry.EnsureLoaded()); + Assert.AreEqual(1, registry.Count); + Assert.AreEqual(2, attempts); + } + + [Test] + public void ResetClearsAndReloadsOnNextAccess() { + var loads = 0; + var registry = new ResourceRegistry("Things", loader: _ => { + loads++; + + return new[] { Make(1u) }; + }); + + registry.EnsureLoaded(); + registry.Reset(); + + Assert.AreEqual(1, loads); + Assert.AreEqual(1, registry.Count); + Assert.AreEqual(2, loads); + } + + [Test] + public void ReloadPicksUpNewEntries() { + var loads = 0; + var registry = new ResourceRegistry("Things", e => e.EntryName, loader: _ => { + loads++; + + return loads == 1 + ? new[] { Make(1u, "Vitality") } + : new[] { Make(1u, "Vitality"), Make(2u, "Strength") }; + }); + + Assert.AreEqual(1, registry.Count); + + registry.Reload(); + + Assert.AreEqual(2, registry.Count); + Assert.AreEqual(2u, registry.GetHash("Strength")); + } + + [Test] + public void EmptyFolderIsNotAnError() { + var registry = new ResourceRegistry("Things", loader: _ => Array.Empty()); + + Assert.AreEqual(0, registry.Count); + Assert.IsEmpty(registry.All); + } + + [Test] + public void ImplementsRegistryContractForPacking() { + var entry = Make(11u); + IRegistry registry = new ResourceRegistry("Things", loader: LoaderFor(entry)); + + Assert.AreEqual(1, registry.Count); + Assert.AreSame(entry, registry.Get(11u)); + } + } +} diff --git a/Tests/ResourceRegistryTests.cs.meta b/Tests/ResourceRegistryTests.cs.meta new file mode 100644 index 0000000..0865f23 --- /dev/null +++ b/Tests/ResourceRegistryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64a517363cf031746b43f9f6b2fa2f1a \ No newline at end of file diff --git a/Tests/Spellbound.Core.Tests.asmdef b/Tests/Spellbound.Core.Tests.asmdef index 0fbaa7f..e53c0ab 100644 --- a/Tests/Spellbound.Core.Tests.asmdef +++ b/Tests/Spellbound.Core.Tests.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "", "references": [ "Spellbound.Core", + "Unity.Transforms", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], diff --git a/Tests/TransformDataTests.cs b/Tests/TransformDataTests.cs new file mode 100644 index 0000000..4a15157 --- /dev/null +++ b/Tests/TransformDataTests.cs @@ -0,0 +1,40 @@ +// Copyright 2026 Spellbound Studio Inc. + +using NUnit.Framework; +using UnityEngine; + +namespace Spellbound.Core.Tests { + public class TransformDataTests { + [Test] + public void ComposedRotationSurvivesCaptureAndRebuild() { + var go = new GameObject(); + + try { + go.transform.rotation = Quaternion.Euler(35f, 70f, 20f); + + var data = new TransformData(go.transform); + + Assert.Less(Quaternion.Angle(go.transform.rotation, data.RotAsQuaternion()), 0.01f); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void YawOnlyRotationSurvivesCaptureAndRebuild() { + var go = new GameObject(); + + try { + go.transform.rotation = Quaternion.Euler(0f, 125f, 0f); + + var data = new TransformData(go.transform); + + Assert.Less(Quaternion.Angle(go.transform.rotation, data.RotAsQuaternion()), 0.01f); + } + finally { + Object.DestroyImmediate(go); + } + } + } +} diff --git a/Tests/TransformDataTests.cs.meta b/Tests/TransformDataTests.cs.meta new file mode 100644 index 0000000..2d6b9d7 --- /dev/null +++ b/Tests/TransformDataTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77bf8dbbcd0d5254e8bde929cd6ab225 \ No newline at end of file diff --git a/package.json b/package.json index 816aa8a..25fb033 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.3", + "version": "1.1.4", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From 2e2ec3294c746262500db0363cca952f72871ffe Mon Sep 17 00:00:00 2001 From: Judsin Date: Sat, 1 Aug 2026 20:06:14 -0500 Subject: [PATCH 15/19] console bug fix --- CHANGELOG.md | 4 ++ Runtime/Logger/Log.cs | 52 +++++++++++++++++++++++- Runtime/Logger/LogBootstrap.cs | 15 +++++++ Runtime/Logger/readme.md | 14 ++++++- Tests/LogSinkTests.cs | 72 ++++++++++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 156 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03714a4..4334a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.1.5] - 8/1/2026 + +- `Log.ClearSinks` and `Log.SuspendSinks` mute sinks the caller does not hold a reference to. `LogBootstrap` now clears before registering and on returning to edit mode, so sinks no longer stack when domain reload is disabled. + ## [1.1.4] - 8/1/2026 - Log sinks can now be unregistered with `Log.RemoveSink` or scoped to a `using` block with `Log.AddScopedSink`. diff --git a/Runtime/Logger/Log.cs b/Runtime/Logger/Log.cs index 7ee04b9..878997a 100644 --- a/Runtime/Logger/Log.cs +++ b/Runtime/Logger/Log.cs @@ -6,7 +6,7 @@ namespace Spellbound.Core.Logging { public static class Log { - private struct RegisteredSink { + internal struct RegisteredSink { public ILogSink Sink; public LogLevel FilterLevel; } @@ -65,6 +65,56 @@ public static bool RemoveSink(ILogSink sink) { } } + public static void ClearSinks() { + lock (SinkMutationLock) + _sinks = System.Array.Empty(); + } + + public static SinkSuspension SuspendSinks() { + lock (SinkMutationLock) { + var suspended = _sinks; + _sinks = System.Array.Empty(); + + return new SinkSuspension(suspended); + } + } + + private static void RestoreSinks(RegisteredSink[] suspended) { + if (suspended == null || suspended.Length == 0) + return; + + lock (SinkMutationLock) { + var current = _sinks; + var restored = new System.Collections.Generic.List(current); + + foreach (var entry in suspended) { + var alreadyPresent = false; + + foreach (var existing in current) { + if (!ReferenceEquals(existing.Sink, entry.Sink)) + continue; + + alreadyPresent = true; + + break; + } + + if (!alreadyPresent) + restored.Add(entry); + } + + _sinks = restored.ToArray(); + } + } + + public readonly struct SinkSuspension : System.IDisposable { + private readonly RegisteredSink[] _suspended; + + internal SinkSuspension(RegisteredSink[] suspended) => _suspended = suspended; + + public void Dispose() => RestoreSinks(_suspended); + } + [Conditional("SPELLBOUND_LOG_VERBOSE")] public static void Verbose( string message, diff --git a/Runtime/Logger/LogBootstrap.cs b/Runtime/Logger/LogBootstrap.cs index c501e8f..afffef5 100644 --- a/Runtime/Logger/LogBootstrap.cs +++ b/Runtime/Logger/LogBootstrap.cs @@ -18,6 +18,8 @@ public static class LogBootstrap { [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void Initialize() { + Log.ClearSinks(); + var config = Resources.Load(ConfigResourcePath); if (config == null) { @@ -28,6 +30,19 @@ private static void Initialize() { RegisterEnabledSinks(config); } +#if UNITY_EDITOR + [UnityEditor.InitializeOnLoadMethod] + private static void SubscribeToPlayModeExit() { + UnityEditor.EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + UnityEditor.EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPlayModeStateChanged(UnityEditor.PlayModeStateChange state) { + if (state == UnityEditor.PlayModeStateChange.EnteredEditMode) + Log.ClearSinks(); + } +#endif + private static LogConfig CreateDefaultConfig() { var config = ScriptableObject.CreateInstance(); config.globalLevel = LogLevel.Error; diff --git a/Runtime/Logger/readme.md b/Runtime/Logger/readme.md index a87e1c4..5fbfba9 100644 --- a/Runtime/Logger/readme.md +++ b/Runtime/Logger/readme.md @@ -90,4 +90,16 @@ using (Log.AddScopedSink(sink, config, LogLevel.Verbose)) { `RecordingLogSink` keeps entries in memory instead of writing them anywhere, which is what lets a test assert that code reported an error. Its constructor takes a minimum level, so it is not reflection-discoverable and never appears in the config inspector. `Entries`, `Count`, `CountOf`, `Contains`, and `Clear` are the reading surface, and it is safe to emit into from background threads. -Removal does not dispose the sink. Sinks that hold resources, like `FileSink`, are still the caller's to dispose. \ No newline at end of file +Removal does not dispose the sink. Sinks that hold resources, like `FileSink`, are still the caller's to dispose. + +To mute sinks you do not hold a reference to, such as the ones `LogBootstrap` registered from config, use `Log.SuspendSinks`. It detaches everything and reattaches on dispose, leaving any sink registered during the suspension in place. + +```csharp +using (Log.SuspendSinks()) +using (Log.AddScopedSink(sink, config, LogLevel.Verbose)) { + RunTheThingThatShouldFail(); + Assert.AreEqual(1, sink.CountOf(LogLevel.Error)); +} +``` + +`Log.ClearSinks` drops every registration without restoring. `LogBootstrap` calls it before registering, so entering play mode with domain reload disabled re-registers rather than stacking a second copy of every sink, and it calls it again on returning to edit mode so play-mode sinks do not outlive the session. \ No newline at end of file diff --git a/Tests/LogSinkTests.cs b/Tests/LogSinkTests.cs index c0f5250..7117d4a 100644 --- a/Tests/LogSinkTests.cs +++ b/Tests/LogSinkTests.cs @@ -147,6 +147,78 @@ public void CountOfAndContainsMatchRecordedEntries() { Assert.IsFalse(sink.Contains(LogLevel.Warning, "save failed")); } + [Test] + public void ClearSinksRemovesEveryRegistration() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + + Log.ClearSinks(); + Emit(LogLevel.Error, "boom"); + + Assert.AreEqual(0, sink.Count); + Assert.IsFalse(Log.RemoveSink(sink)); + } + + [Test] + public void SuspendSinksMutesThenRestores() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + + try { + using (Log.SuspendSinks()) + Emit(LogLevel.Error, "muted"); + + Emit(LogLevel.Error, "audible"); + + CollectionAssert.AreEqual(new[] { "audible" }, MessagesOf(sink)); + } + finally { + Log.RemoveSink(sink); + } + } + + [Test] + public void SinksAddedDuringSuspensionSurviveRestore() { + var original = NewSink(); + var recorder = NewSink(); + Log.AddSink(original, null, LogLevel.Verbose); + + try { + using (Log.SuspendSinks()) { + Log.AddSink(recorder, null, LogLevel.Verbose); + Emit(LogLevel.Error, "during"); + } + + Emit(LogLevel.Error, "after"); + + CollectionAssert.AreEqual(new[] { "during", "after" }, MessagesOf(recorder)); + CollectionAssert.AreEqual(new[] { "after" }, MessagesOf(original)); + } + finally { + Log.RemoveSink(original); + Log.RemoveSink(recorder); + } + } + + [Test] + public void DisposingSuspensionTwiceDoesNotDuplicateSinks() { + var sink = NewSink(); + Log.AddSink(sink, null, LogLevel.Verbose); + + try { + var suspension = Log.SuspendSinks(); + suspension.Dispose(); + suspension.Dispose(); + + Emit(LogLevel.Error, "once"); + + CollectionAssert.AreEqual(new[] { "once" }, MessagesOf(sink)); + } + finally { + Log.RemoveSink(sink); + } + } + [Test] public void ClearDropsRecordedEntries() { var sink = NewSink(); diff --git a/package.json b/package.json index 25fb033..6d459c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.4", + "version": "1.1.5", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From 857e3493920922963e482e3ed9a9480ae4681c44 Mon Sep 17 00:00:00 2001 From: Judsin Date: Mon, 3 Aug 2026 11:59:10 -0500 Subject: [PATCH 16/19] update with sbyte and minor improvements --- CHANGELOG.md | 7 + Runtime/ObjectHandling/ObjectParent.cs | 31 +- Runtime/Packing/Packer.cs | 18 + Tests/ObjectParentTests.cs | 173 +++++++ Tests/ObjectParentTests.cs.meta | 2 + Tests/PackerTests.cs | 617 ++++++++++++++++++++++++- Tests/Spellbound.Core.Tests.asmdef | 3 + package.json | 2 +- 8 files changed, 836 insertions(+), 17 deletions(-) create mode 100644 Tests/ObjectParentTests.cs create mode 100644 Tests/ObjectParentTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 4334a3d..71e639e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.1.6] - 8/2/2026 + +- `Packer` reads and writes `sbyte` through `WriteSByte` and `ReadSByte`, sharing the single-byte two's complement layout of `WriteByte`. +- `Packer` test coverage extended to every read and write pair, the bitwise variants, the smart packer helpers, and `BuildPayload`. +- `ObjectParent.GetNextInstanceIndex` advances a per-chunk cursor instead of rescanning from the seed count on every call, so placement cost stays flat as a chunk fills. Freed indices below the cursor are not reused within a chunk session. +- `ObjectParent.StaticEntityDistanceQuery` skips evaluation until the POV has moved 4m or the chunk's static entity count has changed, and returns whether it evaluated. Surface promotion and demotion can lag movement by up to 4m, well inside the default 50/70 interaction band. + ## [1.1.5] - 8/1/2026 - `Log.ClearSinks` and `Log.SuspendSinks` mute sinks the caller does not hold a reference to. `LogBootstrap` now clears before registering and on returning to edit mode, so sinks no longer stack when domain reload is disabled. diff --git a/Runtime/ObjectHandling/ObjectParent.cs b/Runtime/ObjectHandling/ObjectParent.cs index 0b4b129..6a541b1 100644 --- a/Runtime/ObjectHandling/ObjectParent.cs +++ b/Runtime/ObjectHandling/ObjectParent.cs @@ -31,13 +31,18 @@ public class ObjectParent : IDisposable, IObjectInstanceConsumer { private EntityQuery _dynamicQuery; private readonly Entity _ecsChunk; + private const float SurfaceQueryMovementThreshold = 4f; + private readonly Dictionary _eventSurfaces = new(); private Vector3 _lastPovPosition; + private bool _hasEvaluatedStaticProximity; + private int _lastEvaluatedStaticEntityCount; public IObjectDataAccess StaticDataAccess { get; } public IDynamicDataAccess DynamicDataAccess { get; } private int _seedInstanceCount; + private int _instanceIndexCursor = int.MinValue; public event Action OnDynamicProximityEval = delegate { }; @@ -335,12 +340,14 @@ private void BufferEntitySpawnRequests(IReadOnlyList<(int, uint, TransformData)> #region IObjectInstanceConsumer Implementation public int GetNextInstanceIndex() { - var i = _seedInstanceCount; + if (_instanceIndexCursor < _seedInstanceCount) + _instanceIndexCursor = _seedInstanceCount; - while (StaticDataAccess.HasInstance(i) || DynamicDataAccess.HasInstance(i)) - i++; + while (StaticDataAccess.HasInstance(_instanceIndexCursor) + || DynamicDataAccess.HasInstance(_instanceIndexCursor)) + _instanceIndexCursor++; - return i; + return _instanceIndexCursor; } /// @@ -676,14 +683,24 @@ public void DynamicDistanceQuery(float3[] povs) { instancesToSleep.Dispose(); } - public void StaticEntityDistanceQuery(float3 localPov) { + public bool StaticEntityDistanceQuery(float3 localPov) { + var maxCapacity = _staticQuery.CalculateEntityCount(); + + if (_hasEvaluatedStaticProximity + && maxCapacity == _lastEvaluatedStaticEntityCount + && math.distancesq(localPov, _lastPovPosition) + < SurfaceQueryMovementThreshold * SurfaceQueryMovementThreshold) + return false; + + _hasEvaluatedStaticProximity = true; + _lastEvaluatedStaticEntityCount = maxCapacity; _lastPovPosition = localPov; + var existingEventSurfaces = new NativeHashSet(_eventSurfaces.Count, Allocator.TempJob); foreach (var key in _eventSurfaces.Keys) existingEventSurfaces.Add(key); - var maxCapacity = _staticQuery.CalculateEntityCount(); var entities = _staticQuery.ToEntityArray(Allocator.TempJob); var transforms = _staticQuery.ToComponentDataArray(Allocator.TempJob); var thresholds = _staticQuery.ToComponentDataArray(Allocator.TempJob); @@ -726,6 +743,8 @@ public void StaticEntityDistanceQuery(float3 localPov) { existingEventSurfaces.Dispose(); instancesToAwaken.Dispose(); instancesToSleep.Dispose(); + + return true; } #endregion Distance Queries diff --git a/Runtime/Packing/Packer.cs b/Runtime/Packing/Packer.cs index b7c0669..0f0472c 100644 --- a/Runtime/Packing/Packer.cs +++ b/Runtime/Packing/Packer.cs @@ -45,6 +45,24 @@ public static byte ReadByte(ref ReadOnlySpan buffer) { } #endregion + + #region SByte + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteSByte(ref Span buffer, sbyte value) { + buffer[0] = unchecked((byte)value); + buffer = buffer[1..]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static sbyte ReadSByte(ref ReadOnlySpan buffer) { + var value = unchecked((sbyte)buffer[0]); + buffer = buffer[1..]; + + return value; + } + + #endregion SByte #region Bool diff --git a/Tests/ObjectParentTests.cs b/Tests/ObjectParentTests.cs new file mode 100644 index 0000000..05769b9 --- /dev/null +++ b/Tests/ObjectParentTests.cs @@ -0,0 +1,173 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; +using NUnit.Framework; +using Spellbound.Core.ECS; +using Spellbound.Core.ObjectData; +using Spellbound.Core.ObjectHandling; +using Spellbound.Core.Surfaces; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; +using UnityEngine; + +namespace Spellbound.Core.Tests { + public class ObjectParentTests { + private class FakeStaticDataAccess : IObjectDataAccess { + public readonly HashSet Indices = new(); + + public void SetConsumer(IObjectInstanceConsumer consumer) { } + + public Dictionary GetAllRuntimeInstances() => new(); + + public IReadOnlyCollection GetAllSeedInstanceDeletions() => new List(); + + public void CreateRuntimeInstance(uint presetHash, Vector3 position, Vector3 rotation, int scale) { } + + public bool HasInstance(int instanceIndex) => Indices.Contains(instanceIndex); + + public bool IsDeleted(int instanceIndex) => false; + + public bool TryRead(int instanceIndex, byte eventSurfaceIndex, out T data) + where T : IPackerObjectData, new() { + data = default; + + return false; + } + + public bool TryReadAllBySurface(int instanceIndex, byte eventSurfaceIndex, out List data) { + data = null; + + return false; + } + + public bool TryReadAll(int instanceIndex, out List data) { + data = null; + + return false; + } + + public void Write(int instanceIndex, uint presetHash, byte eventSurfaceIndex, T newData, byte contextIn) + where T : IPackerObjectData, new() { } + + public void Delta( + int instanceIndex, uint presetHash, byte eventSurfaceIndex, TDispatch dispatch) + where TData : IPackerObjectData, new() where TDispatch : IPackerDispatch, new() { } + + public void DeleteInstance(int instanceIndex) { } + } + + private class FakeDynamicDataAccess : IDynamicDataAccess { + public readonly HashSet Indices = new(); + + public void SetConsumer(IObjectInstanceConsumer consumer) { } + + public bool HasInstance(int instanceIndex) => Indices.Contains(instanceIndex); + + public Dictionary GetAllRuntimeDynamicInstances() => new(); + + public void CreateRuntimeObject( + uint presetHash, Vector3 position, Vector3 rotation, int scale, + List<(InstanceDataKey, byte[])> dataSlots = null) { } + + public void Awaken(int instanceIndex) { } + + public void Sleep(int instanceIndex, DynamicInstanceEntry entry, IEventSurface eventSurface) { } + + public void SetRuntimeDynamicEntry(int instanceIndex, DynamicInstanceEntry entry) { } + } + + private World _previousWorld; + private World _world; + private FakeStaticDataAccess _staticDataAccess; + private FakeDynamicDataAccess _dynamicDataAccess; + private ObjectParent _objectParent; + + [SetUp] + public void SetUp() { + _previousWorld = World.DefaultGameObjectInjectionWorld; + _world = new World("ObjectParentTests"); + World.DefaultGameObjectInjectionWorld = _world; + + _staticDataAccess = new FakeStaticDataAccess(); + _dynamicDataAccess = new FakeDynamicDataAccess(); + _objectParent = new ObjectParent( + null, null, _staticDataAccess, _dynamicDataAccess, Vector3Int.zero, Entity.Null); + } + + [TearDown] + public void TearDown() { + _objectParent.Dispose(); + _world.Dispose(); + World.DefaultGameObjectInjectionWorld = _previousWorld; + } + + [Test] + public void ReturnsFirstFreeIndexAtOrAboveSeedCount() { + _objectParent.SetSeedInstanceCount(3); + _staticDataAccess.Indices.Add(3); + _staticDataAccess.Indices.Add(4); + + Assert.AreEqual(5, _objectParent.GetNextInstanceIndex()); + } + + [Test] + public void SkipsIndicesHeldByEitherStore() { + _staticDataAccess.Indices.Add(0); + _dynamicDataAccess.Indices.Add(1); + + Assert.AreEqual(2, _objectParent.GetNextInstanceIndex()); + } + + [Test] + public void DoesNotReuseFreedLowerIndex() { + _objectParent.SetSeedInstanceCount(3); + _staticDataAccess.Indices.Add(3); + _staticDataAccess.Indices.Add(4); + + var first = _objectParent.GetNextInstanceIndex(); + _staticDataAccess.Indices.Add(first); + _staticDataAccess.Indices.Remove(3); + + Assert.AreEqual(first + 1, _objectParent.GetNextInstanceIndex()); + } + + [Test] + public void SnapsForwardWhenSeedCountArrivesLate() { + Assert.AreEqual(0, _objectParent.GetNextInstanceIndex()); + + _objectParent.SetSeedInstanceCount(5); + + Assert.AreEqual(5, _objectParent.GetNextInstanceIndex()); + } + + [Test] + public void StaticProximityQueryEvaluatesOnlyPastMovementThreshold() { + Assert.IsTrue(_objectParent.StaticEntityDistanceQuery(float3.zero)); + Assert.IsFalse(_objectParent.StaticEntityDistanceQuery(new float3(2f, 0f, 0f))); + Assert.IsTrue(_objectParent.StaticEntityDistanceQuery(new float3(5f, 0f, 0f))); + } + + [Test] + public void StaticProximityQueryEvaluatesWhenEntityCountChanges() { + Assert.IsTrue(_objectParent.StaticEntityDistanceQuery(float3.zero)); + Assert.IsFalse(_objectParent.StaticEntityDistanceQuery(float3.zero)); + + CreateStaticEntity(new float3(10000f, 0f, 0f)); + + Assert.IsTrue(_objectParent.StaticEntityDistanceQuery(float3.zero)); + Assert.IsFalse(_objectParent.StaticEntityDistanceQuery(float3.zero)); + } + + private void CreateStaticEntity(float3 position) { + var entityManager = _world.EntityManager; + var entity = entityManager.CreateEntity(); + + entityManager.AddSharedComponent(entity, new ChunkParentComponent { ChunkCoord = int3.zero }); + entityManager.AddComponentData(entity, LocalTransform.FromPosition(position)); + entityManager.AddComponentData(entity, new ProximityThresholdComponent { Value = new float2(50f, 70f) }); + entityManager.AddComponentData(entity, new InstanceIndexComponent { Value = 0 }); + entityManager.AddSharedComponent(entity, new PresetHashComponent { Value = 1u }); + } + } +} diff --git a/Tests/ObjectParentTests.cs.meta b/Tests/ObjectParentTests.cs.meta new file mode 100644 index 0000000..0d3e791 --- /dev/null +++ b/Tests/ObjectParentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32c8f5226763cab48adf9b495bfdec41 \ No newline at end of file diff --git a/Tests/PackerTests.cs b/Tests/PackerTests.cs index 0099bc7..129d634 100644 --- a/Tests/PackerTests.cs +++ b/Tests/PackerTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; +using System.Text; using NUnit.Framework; +using Spellbound.Core.Logging; using Spellbound.Core.Packing; using UnityEngine; @@ -10,6 +12,7 @@ namespace Spellbound.Core.Tests { public class PackerTests { private struct NumericPayload : IPacker { public byte ByteValue; + public sbyte SByteValue; public bool BoolValue; public short ShortValue; public ushort UShortValue; @@ -21,6 +24,7 @@ private struct NumericPayload : IPacker { public void Pack(ref Span buffer) { Packer.WriteByte(ref buffer, ByteValue); + Packer.WriteSByte(ref buffer, SByteValue); Packer.WriteBool(ref buffer, BoolValue); Packer.WriteShort(ref buffer, ShortValue); Packer.WriteUShort(ref buffer, UShortValue); @@ -33,6 +37,7 @@ public void Pack(ref Span buffer) { public void Unpack(ref ReadOnlySpan buffer) { ByteValue = Packer.ReadByte(ref buffer); + SByteValue = Packer.ReadSByte(ref buffer); BoolValue = Packer.ReadBool(ref buffer); ShortValue = Packer.ReadShort(ref buffer); UShortValue = Packer.ReadUShort(ref buffer); @@ -51,10 +56,276 @@ private struct TextPayload : IPacker { public void Unpack(ref ReadOnlySpan buffer) => Text = Packer.ReadString(ref buffer); } + [PackerId("spellbound.core.tests.counter")] + private struct CounterState : ISmartPacker { + public int Count; + + public uint Hash => SmartPackerRegistry.GetHash(); + public ISmartPacker CreateNewInstance() => new CounterState(); + + public void Pack(ref Span buffer) => Packer.WriteInt(ref buffer, Count); + public void Unpack(ref ReadOnlySpan buffer) => Count = Packer.ReadInt(ref buffer); + } + + [PackerId("spellbound.core.tests.label")] + private class LabelState : ISmartPacker { + public string Label; + + public uint Hash => SmartPackerRegistry.GetHash(); + public ISmartPacker CreateNewInstance() => new LabelState(); + + public void Pack(ref Span buffer) => Packer.WriteString(ref buffer, Label); + public void Unpack(ref ReadOnlySpan buffer) => Label = Packer.ReadString(ref buffer); + } + + [Test] + public void ByteRoundTripsAcrossFullRange() { + var buffer = new byte[256]; + var writeSpan = buffer.AsSpan(); + + for (var value = 0; value <= byte.MaxValue; value++) + Packer.WriteByte(ref writeSpan, (byte)value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + for (var value = 0; value <= byte.MaxValue; value++) + Assert.AreEqual((byte)value, Packer.ReadByte(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void SByteRoundTripsAcrossFullRange() { + var buffer = new byte[256]; + var writeSpan = buffer.AsSpan(); + + for (var value = (int)sbyte.MinValue; value <= sbyte.MaxValue; value++) + Packer.WriteSByte(ref writeSpan, (sbyte)value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + for (var value = (int)sbyte.MinValue; value <= sbyte.MaxValue; value++) + Assert.AreEqual((sbyte)value, Packer.ReadSByte(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void SByteWritesOneTwosComplementByte() { + var buffer = new byte[4]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteSByte(ref writeSpan, sbyte.MinValue); + Packer.WriteSByte(ref writeSpan, -1); + Packer.WriteSByte(ref writeSpan, 0); + Packer.WriteSByte(ref writeSpan, sbyte.MaxValue); + + Assert.AreEqual(0, writeSpan.Length); + Assert.AreEqual(new byte[] { 0x80, 0xFF, 0x00, 0x7F }, buffer); + } + + [Test] + public void SByteAndByteShareTheSameByteLayout() { + var signed = new byte[2]; + var unsigned = new byte[2]; + var signedSpan = signed.AsSpan(); + var unsignedSpan = unsigned.AsSpan(); + + Packer.WriteSByte(ref signedSpan, -128); + Packer.WriteSByte(ref signedSpan, -1); + Packer.WriteByte(ref unsignedSpan, 128); + Packer.WriteByte(ref unsignedSpan, 255); + + Assert.AreEqual(unsigned, signed); + } + + [Test] + public void SByteReadsBackFromBytesWrittenAsUnsigned() { + var buffer = new byte[] { 0x80, 0xFF, 0x00, 0x7F }; + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(sbyte.MinValue, Packer.ReadSByte(ref readSpan)); + Assert.AreEqual((sbyte)-1, Packer.ReadSByte(ref readSpan)); + Assert.AreEqual((sbyte)0, Packer.ReadSByte(ref readSpan)); + Assert.AreEqual(sbyte.MaxValue, Packer.ReadSByte(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void BoolRoundTripsBothStates() { + var buffer = new byte[2]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteBool(ref writeSpan, true); + Packer.WriteBool(ref writeSpan, false); + + Assert.AreEqual(0, writeSpan.Length); + Assert.AreEqual(new byte[] { 1, 0 }, buffer); + + ReadOnlySpan readSpan = buffer; + + Assert.IsTrue(Packer.ReadBool(ref readSpan)); + Assert.IsFalse(Packer.ReadBool(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void BoolReadsAnyNonZeroByteAsTrue() { + ReadOnlySpan readSpan = new byte[] { 2, 255 }; + + Assert.IsTrue(Packer.ReadBool(ref readSpan)); + Assert.IsTrue(Packer.ReadBool(ref readSpan)); + } + + [Test] + public void ShortRoundTripsAtBoundaries() { + var values = new short[] { short.MinValue, -1, 0, 1, short.MaxValue }; + var buffer = new byte[values.Length * sizeof(short)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteShort(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadShort(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void UShortRoundTripsAtBoundaries() { + var values = new ushort[] { ushort.MinValue, 1, 32768, ushort.MaxValue }; + var buffer = new byte[values.Length * sizeof(ushort)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteUShort(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadUShort(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void IntRoundTripsAtBoundaries() { + var values = new[] { int.MinValue, -1, 0, 1, int.MaxValue }; + var buffer = new byte[values.Length * sizeof(int)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteInt(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadInt(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void UIntRoundTripsAtBoundaries() { + var values = new[] { uint.MinValue, 1u, 2147483648u, uint.MaxValue }; + var buffer = new byte[values.Length * sizeof(uint)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteUInt(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadUInt(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void LongRoundTripsAtBoundaries() { + var values = new[] { long.MinValue, -1L, 0L, 1L, long.MaxValue }; + var buffer = new byte[values.Length * sizeof(long)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteLong(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadLong(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void ULongRoundTripsAtBoundaries() { + var values = new[] { ulong.MinValue, 1ul, 9223372036854775808ul, ulong.MaxValue }; + var buffer = new byte[values.Length * sizeof(ulong)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteULong(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) + Assert.AreEqual(value, Packer.ReadULong(ref readSpan)); + + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void FloatRoundTripsSpecialValues() { + var values = new[] { + 0f, -0f, 1f, -1f, float.Epsilon, float.MinValue, float.MaxValue, + float.PositiveInfinity, float.NegativeInfinity, float.NaN + }; + + var buffer = new byte[values.Length * sizeof(float)]; + var writeSpan = buffer.AsSpan(); + + foreach (var value in values) + Packer.WriteFloat(ref writeSpan, value); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + foreach (var value in values) { + Assert.AreEqual(BitConverter.SingleToInt32Bits(value), + BitConverter.SingleToInt32Bits(Packer.ReadFloat(ref readSpan))); + } + + Assert.AreEqual(0, readSpan.Length); + } + [Test] public void NumericPrimitivesRoundTrip() { var payload = new NumericPayload { ByteValue = 200, + SByteValue = -128, BoolValue = true, ShortValue = -12345, UShortValue = 54321, @@ -65,9 +336,37 @@ public void NumericPrimitivesRoundTrip() { FloatValue = 3.14159f }; - var result = Packer.FromBytes(Packer.ToBytes(payload)); + var bytes = Packer.ToBytes(payload); + + Assert.AreEqual(35, bytes.Length); + Assert.AreEqual(payload, Packer.FromBytes(bytes)); + } + + [Test] + public void BitwiseVariantsRoundTrip() { + var buffer = new byte[32]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteShortBitwise(ref writeSpan, short.MinValue); + Packer.WriteUShortBitwise(ref writeSpan, ushort.MaxValue); + Packer.WriteIntBitwise(ref writeSpan, int.MinValue); + Packer.WriteUIntBitwise(ref writeSpan, uint.MaxValue); + Packer.WriteLongBitwise(ref writeSpan, long.MinValue); + Packer.WriteULongBitwise(ref writeSpan, ulong.MaxValue); + Packer.WriteFloatBitwise(ref writeSpan, -12.5f); + + Assert.AreEqual(0, writeSpan.Length); - Assert.AreEqual(payload, result); + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(short.MinValue, Packer.ReadShortBitwise(ref readSpan)); + Assert.AreEqual(ushort.MaxValue, Packer.ReadUShortBitwise(ref readSpan)); + Assert.AreEqual(int.MinValue, Packer.ReadIntBitwise(ref readSpan)); + Assert.AreEqual(uint.MaxValue, Packer.ReadUIntBitwise(ref readSpan)); + Assert.AreEqual(long.MinValue, Packer.ReadLongBitwise(ref readSpan)); + Assert.AreEqual(ulong.MaxValue, Packer.ReadULongBitwise(ref readSpan)); + Assert.AreEqual(-12.5f, Packer.ReadFloatBitwise(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); } [Test] @@ -97,19 +396,28 @@ public void BitwiseVariantsMatchBitConverterLayout() { } [Test] - public void VectorAndQuaternionRoundTrip() { - var buffer = new byte[36]; + public void BitwiseVariantsReadWhatBitConverterWrote() { + var buffer = new byte[28]; var writeSpan = buffer.AsSpan(); - Packer.WriteVector3(ref writeSpan, new Vector3(1.5f, -2.5f, 3.5f)); - Packer.WriteVector2(ref writeSpan, new Vector2(-4.5f, 5.5f)); - Packer.WriteQuaternion(ref writeSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + Packer.WriteShort(ref writeSpan, -4321); + Packer.WriteUShort(ref writeSpan, 64321); + Packer.WriteInt(ref writeSpan, -987654321); + Packer.WriteUInt(ref writeSpan, 4000000000u); + Packer.WriteLong(ref writeSpan, -9876543210123L); + Packer.WriteULong(ref writeSpan, 18000000000000000000ul); + + Assert.AreEqual(0, writeSpan.Length); ReadOnlySpan readSpan = buffer; - Assert.AreEqual(new Vector3(1.5f, -2.5f, 3.5f), Packer.ReadVector3(ref readSpan)); - Assert.AreEqual(new Vector2(-4.5f, 5.5f), Packer.ReadVector2(ref readSpan)); - Assert.AreEqual(new Quaternion(0.1f, 0.2f, 0.3f, 0.4f), Packer.ReadQuaternion(ref readSpan)); + Assert.AreEqual((short)-4321, Packer.ReadShortBitwise(ref readSpan)); + Assert.AreEqual((ushort)64321, Packer.ReadUShortBitwise(ref readSpan)); + Assert.AreEqual(-987654321, Packer.ReadIntBitwise(ref readSpan)); + Assert.AreEqual(4000000000u, Packer.ReadUIntBitwise(ref readSpan)); + Assert.AreEqual(-9876543210123L, Packer.ReadLongBitwise(ref readSpan)); + Assert.AreEqual(18000000000000000000ul, Packer.ReadULongBitwise(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); } [Test] @@ -123,6 +431,39 @@ public void StringsRoundTripIncludingUnicode() { Assert.AreEqual(string.Empty, nullText.Text); } + [Test] + public void StringWritesUtf8WithLengthPrefix() { + const string text = "jaźń"; + var expected = Encoding.UTF8.GetBytes(text); + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteString(ref writeSpan, text); + + Assert.AreEqual(buffer.Length - sizeof(int) - expected.Length, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(expected.Length, Packer.ReadInt(ref readSpan)); + Assert.AreEqual(expected, readSpan[..expected.Length].ToArray()); + } + + [Test] + public void StringsRoundTripBackToBack() { + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteString(ref writeSpan, "first"); + Packer.WriteString(ref writeSpan, string.Empty); + Packer.WriteString(ref writeSpan, "second"); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual("first", Packer.ReadString(ref readSpan)); + Assert.AreEqual(string.Empty, Packer.ReadString(ref readSpan)); + Assert.AreEqual("second", Packer.ReadString(ref readSpan)); + } + [Test] public void PayloadLargerThanStackBufferRoundTrips() { var large = new string('x', 12000); @@ -136,6 +477,62 @@ public void PayloadLargerThanStackBufferRoundTrips() { Assert.AreEqual("small", small.Text); } + [Test] + public void VectorAndQuaternionRoundTrip() { + var buffer = new byte[36]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteVector3(ref writeSpan, new Vector3(1.5f, -2.5f, 3.5f)); + Packer.WriteVector2(ref writeSpan, new Vector2(-4.5f, 5.5f)); + Packer.WriteQuaternion(ref writeSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new Vector3(1.5f, -2.5f, 3.5f), Packer.ReadVector3(ref readSpan)); + Assert.AreEqual(new Vector2(-4.5f, 5.5f), Packer.ReadVector2(ref readSpan)); + Assert.AreEqual(new Quaternion(0.1f, 0.2f, 0.3f, 0.4f), Packer.ReadQuaternion(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void VectorAndQuaternionBitwiseRoundTrip() { + var buffer = new byte[36]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteVector3Bitwise(ref writeSpan, new Vector3(1.5f, -2.5f, 3.5f)); + Packer.WriteVector2Bitwise(ref writeSpan, new Vector2(-4.5f, 5.5f)); + Packer.WriteQuaternionBitwise(ref writeSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + + Assert.AreEqual(0, writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new Vector3(1.5f, -2.5f, 3.5f), Packer.ReadVector3Bitwise(ref readSpan)); + Assert.AreEqual(new Vector2(-4.5f, 5.5f), Packer.ReadVector2Bitwise(ref readSpan)); + Assert.AreEqual(new Quaternion(0.1f, 0.2f, 0.3f, 0.4f), Packer.ReadQuaternionBitwise(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void VectorBitwiseMatchesBitConverterLayout() { + var standard = new byte[36]; + var bitwise = new byte[36]; + var standardSpan = standard.AsSpan(); + var bitwiseSpan = bitwise.AsSpan(); + + Packer.WriteVector3(ref standardSpan, new Vector3(1.5f, -2.5f, 3.5f)); + Packer.WriteVector2(ref standardSpan, new Vector2(-4.5f, 5.5f)); + Packer.WriteQuaternion(ref standardSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + + Packer.WriteVector3Bitwise(ref bitwiseSpan, new Vector3(1.5f, -2.5f, 3.5f)); + Packer.WriteVector2Bitwise(ref bitwiseSpan, new Vector2(-4.5f, 5.5f)); + Packer.WriteQuaternionBitwise(ref bitwiseSpan, new Quaternion(0.1f, 0.2f, 0.3f, 0.4f)); + + Assert.AreEqual(standard, bitwise); + } + [Test] public void BytesRoundTripAndNullBecomesEmpty() { var buffer = new byte[64]; @@ -150,6 +547,22 @@ public void BytesRoundTripAndNullBecomesEmpty() { Assert.AreEqual(Array.Empty(), Packer.ReadBytes(ref readSpan)); } + [Test] + public void BytesFromSpanRoundTripIncludingEmpty() { + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteBytes(ref writeSpan, (ReadOnlySpan)new byte[] { 4, 5, 6, 7 }); + Packer.WriteBytes(ref writeSpan, ReadOnlySpan.Empty); + + Assert.AreEqual(buffer.Length - (sizeof(int) + 4) - sizeof(int), writeSpan.Length); + + ReadOnlySpan readSpan = buffer; + + Assert.AreEqual(new byte[] { 4, 5, 6, 7 }, Packer.ReadBytes(ref readSpan)); + Assert.AreEqual(Array.Empty(), Packer.ReadBytes(ref readSpan)); + } + [Test] public void IntListRoundTripAndNullBecomesEmpty() { var buffer = new byte[64]; @@ -164,6 +577,30 @@ public void IntListRoundTripAndNullBecomesEmpty() { Assert.AreEqual(new List(), Packer.UnpackIntList(ref readSpan)); } + [Test] + public void PackerListAndArrayRoundTripInPlace() { + var buffer = new byte[128]; + var writeSpan = buffer.AsSpan(); + + Packer.PackList(ref writeSpan, new List { new() { Text = "one" }, new() { Text = "two" } }); + Packer.PackArray(ref writeSpan, new[] { new TextPayload { Text = "three" } }); + Packer.PackList(ref writeSpan, (List)null); + Packer.PackArray(ref writeSpan, (TextPayload[])null); + + ReadOnlySpan readSpan = buffer; + + var list = Packer.UnpackList(ref readSpan); + var array = Packer.UnpackArray(ref readSpan); + + Assert.AreEqual(2, list.Count); + Assert.AreEqual("one", list[0].Text); + Assert.AreEqual("two", list[1].Text); + Assert.AreEqual(1, array.Length); + Assert.AreEqual("three", array[0].Text); + Assert.IsEmpty(Packer.UnpackList(ref readSpan)); + Assert.IsEmpty(Packer.UnpackArray(ref readSpan)); + } + [Test] public void PackerListAndArrayRoundTrip() { var items = new List { @@ -187,6 +624,30 @@ public void PackerListAndArrayRoundTrip() { Assert.IsEmpty(Packer.UnpackArrayFromBytes(Packer.PackArrayToBytes(null))); } + [Test] + public void ListAndArrayBytesLargerThanStackBufferRoundTrip() { + var items = new List(); + + for (var i = 0; i < 400; i++) + items.Add(new TextPayload { Text = new string('y', 40) + i }); + + var listResult = Packer.UnpackListFromBytes(Packer.PackListToBytes(items)); + var arrayResult = Packer.UnpackArrayFromBytes(Packer.PackArrayToBytes(items.ToArray())); + + Assert.AreEqual(items.Count, listResult.Count); + Assert.AreEqual(items.Count, arrayResult.Length); + Assert.AreEqual(items[399].Text, listResult[399].Text); + Assert.AreEqual(items[399].Text, arrayResult[399].Text); + } + + [Test] + public void UnpackFromBytesTreatsNullAndEmptyAsEmpty() { + Assert.IsEmpty(Packer.UnpackListFromBytes(null)); + Assert.IsEmpty(Packer.UnpackListFromBytes(Array.Empty())); + Assert.IsEmpty(Packer.UnpackArrayFromBytes(null)); + Assert.IsEmpty(Packer.UnpackArrayFromBytes(Array.Empty())); + } + [Test] public void TransformDataRoundTrips() { var data = new TransformData(new Vector3(10f, 20f, 30f), new Vector3(0f, 90f, 45f), 2.5f); @@ -198,6 +659,142 @@ public void TransformDataRoundTrips() { Assert.AreEqual(data.Scale, result.Scale); } + [Test] + public void SmartToBytesTagsThePayloadAndUnpacksBack() { + var bytes = Packer.SmartToBytes(new CounterState { Count = 42 }); + + ReadOnlySpan span = bytes; + + Assert.AreEqual(SmartPackerRegistry.GetHash(), Packer.ReadUInt(ref span)); + Assert.AreEqual(sizeof(uint) + sizeof(int), bytes.Length); + Assert.IsTrue(Packer.TrySmartUnpack(bytes, out var value)); + Assert.AreEqual(42, value.Count); + } + + [Test] + public void SmartToBytesUsesRuntimeTypeForReferenceTypes() { + ISmartPacker packer = new LabelState { Label = "reference" }; + + var bytes = Packer.SmartToBytes(packer); + + ReadOnlySpan span = bytes; + + Assert.AreEqual(SmartPackerRegistry.GetHash(), Packer.ReadUInt(ref span)); + Assert.IsTrue(Packer.TrySmartUnpack(bytes, out var value)); + Assert.AreEqual("reference", value.Label); + } + + [Test] + public void SmartToBytesThrowsOnNullReference() { + Assert.Throws(() => Packer.SmartToBytes(null)); + } + + [Test] + public void TrySmartUnpackRejectsShortAndMismatchedPayloads() { + var labelBytes = Packer.SmartToBytes(new LabelState { Label = "mismatch" }); + + using (Log.SuspendSinks()) { + Assert.IsFalse(Packer.TrySmartUnpack(null, out var fromNull)); + Assert.AreEqual(0, fromNull.Count); + Assert.IsFalse(Packer.TrySmartUnpack(new byte[] { 1, 2, 3 }, out _)); + Assert.IsFalse(Packer.TrySmartUnpack(labelBytes, out _)); + } + } + + [Test] + public void SmartListRoundTripsPolymorphically() { + var items = new List { + new CounterState { Count = 7 }, + new LabelState { Label = "mixed" }, + null, + new CounterState { Count = -7 } + }; + + var bytes = Packer.SmartListToBytes(items); + var result = Packer.SmartListFromBytes(bytes); + + Assert.AreEqual(3, result.Count); + Assert.AreEqual(7, ((CounterState)result[0]).Count); + Assert.AreEqual("mixed", ((LabelState)result[1]).Label); + Assert.AreEqual(-7, ((CounterState)result[2]).Count); + } + + [Test] + public void SmartListRoundTripsInPlace() { + var buffer = new byte[64]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteSmartList(ref writeSpan, new List { new CounterState { Count = 3 } }); + Packer.WriteSmartList(ref writeSpan, (List)null); + + ReadOnlySpan readSpan = buffer; + + var first = Packer.ReadSmartList(ref readSpan); + + Assert.AreEqual(1, first.Count); + Assert.AreEqual(3, ((CounterState)first[0]).Count); + Assert.IsEmpty(Packer.ReadSmartList(ref readSpan)); + } + + [Test] + public void SmartListReadThrowsOnUnregisteredTag() { + var buffer = new byte[16]; + var writeSpan = buffer.AsSpan(); + + Packer.WriteInt(ref writeSpan, 1); + Packer.WriteUInt(ref writeSpan, 123456789u); + + Assert.Throws(() => { + ReadOnlySpan readSpan = buffer; + Packer.ReadSmartList(ref readSpan); + }); + } + + [Test] + public void SmartListFromBytesReturnsEmptyOnMissingOrMalformedData() { + var malformed = new byte[8]; + var writeSpan = malformed.AsSpan(); + Packer.WriteInt(ref writeSpan, 1); + Packer.WriteUInt(ref writeSpan, 987654321u); + + Assert.IsEmpty(Packer.SmartListToBytes(null)); + Assert.IsEmpty(Packer.SmartListToBytes(new List())); + Assert.IsEmpty(Packer.SmartListFromBytes(null)); + Assert.IsEmpty(Packer.SmartListFromBytes(Array.Empty())); + + using (Log.SuspendSinks()) + Assert.IsEmpty(Packer.SmartListFromBytes(malformed)); + } + + [Test] + public void BuildPayloadReturnsOnlyTheBytesWritten() { + var payload = Packer.BuildPayload((ref Span buffer) => { + Packer.WriteInt(ref buffer, 42); + Packer.WriteString(ref buffer, "core"); + }); + + Assert.AreEqual(sizeof(int) + sizeof(int) + 4, payload.Length); + + ReadOnlySpan readSpan = payload; + + Assert.AreEqual(42, Packer.ReadInt(ref readSpan)); + Assert.AreEqual("core", Packer.ReadString(ref readSpan)); + Assert.AreEqual(0, readSpan.Length); + } + + [Test] + public void BuildPayloadGrowsBeyondTheStackBuffer() { + var large = new string('z', 20000); + + var payload = Packer.BuildPayload((ref Span buffer) => Packer.WriteString(ref buffer, large)); + + Assert.AreEqual(sizeof(int) + large.Length, payload.Length); + + ReadOnlySpan readSpan = payload; + + Assert.AreEqual(large, Packer.ReadString(ref readSpan)); + } + [Test] public void AreBytesEqualHandlesNullEmptyAndContent() { Assert.IsTrue(Packer.AreBytesEqual(null, null)); diff --git a/Tests/Spellbound.Core.Tests.asmdef b/Tests/Spellbound.Core.Tests.asmdef index e53c0ab..97af902 100644 --- a/Tests/Spellbound.Core.Tests.asmdef +++ b/Tests/Spellbound.Core.Tests.asmdef @@ -3,6 +3,9 @@ "rootNamespace": "", "references": [ "Spellbound.Core", + "Unity.Collections", + "Unity.Entities", + "Unity.Mathematics", "Unity.Transforms", "UnityEngine.TestRunner", "UnityEditor.TestRunner" diff --git a/package.json b/package.json index 6d459c6..574c859 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.5", + "version": "1.1.6", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From b9a011224b78edf947d9db9def14d91d6edc9367 Mon Sep 17 00:00:00 2001 From: Judsin Date: Mon, 7 Sep 2026 14:52:11 -0500 Subject: [PATCH 17/19] added the weighted sampling to core --- CHANGELOG.md | 5 + Runtime/Sampling.meta | 8 ++ Runtime/Sampling/WeightedEntry.cs | 12 ++ Runtime/Sampling/WeightedEntry.cs.meta | 11 ++ Runtime/Sampling/WeightedPool.cs | 17 +++ Runtime/Sampling/WeightedPool.cs.meta | 11 ++ Runtime/Sampling/WeightedTable.cs | 170 +++++++++++++++++++++++++ Runtime/Sampling/WeightedTable.cs.meta | 11 ++ Tests/WeightedTableTests.cs | 146 +++++++++++++++++++++ Tests/WeightedTableTests.cs.meta | 11 ++ package.json | 2 +- 11 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 Runtime/Sampling.meta create mode 100644 Runtime/Sampling/WeightedEntry.cs create mode 100644 Runtime/Sampling/WeightedEntry.cs.meta create mode 100644 Runtime/Sampling/WeightedPool.cs create mode 100644 Runtime/Sampling/WeightedPool.cs.meta create mode 100644 Runtime/Sampling/WeightedTable.cs create mode 100644 Runtime/Sampling/WeightedTable.cs.meta create mode 100644 Tests/WeightedTableTests.cs create mode 100644 Tests/WeightedTableTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e639e..efb7e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [1.1.7] - 9/7/2026 + +- `WeightedTable` in `Spellbound.Core.Sampling`: a serializable weighted list with a nothing slot. One roll through `PickIndex` or `TryPick`, driven by any random source in `[0, TotalWeight)`, resolves by binary search over cumulative weights built once per table. `Sample` draws several picks with or without replacement. `Define` builds a table in code. +- `WeightedPool`: a ScriptableObject holding one `WeightedTable` for tables shared between assets. + ## [1.1.6] - 8/2/2026 - `Packer` reads and writes `sbyte` through `WriteSByte` and `ReadSByte`, sharing the single-byte two's complement layout of `WriteByte`. diff --git a/Runtime/Sampling.meta b/Runtime/Sampling.meta new file mode 100644 index 0000000..ea1ce61 --- /dev/null +++ b/Runtime/Sampling.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6968a192767c7f065f4311747c3d6334 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Sampling/WeightedEntry.cs b/Runtime/Sampling/WeightedEntry.cs new file mode 100644 index 0000000..ca60885 --- /dev/null +++ b/Runtime/Sampling/WeightedEntry.cs @@ -0,0 +1,12 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using UnityEngine; + +namespace Spellbound.Core.Sampling { + [Serializable] + public struct WeightedEntry { + public T candidate; + [Min(0)] public int weight; + } +} diff --git a/Runtime/Sampling/WeightedEntry.cs.meta b/Runtime/Sampling/WeightedEntry.cs.meta new file mode 100644 index 0000000..8c23479 --- /dev/null +++ b/Runtime/Sampling/WeightedEntry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6c543de5b6cf556094c51ce81ffeebcb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Sampling/WeightedPool.cs b/Runtime/Sampling/WeightedPool.cs new file mode 100644 index 0000000..0c50dbf --- /dev/null +++ b/Runtime/Sampling/WeightedPool.cs @@ -0,0 +1,17 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; +using UnityEngine; + +namespace Spellbound.Core.Sampling { + public abstract class WeightedPool : ScriptableObject where T : class { + [SerializeField] private WeightedTable table = new(); + + public WeightedTable Table => table ??= new WeightedTable(); + + public bool TryPick(System.Random rng, out T candidate) => Table.TryPick(rng, out candidate); + + public List Sample(int count, System.Random rng, bool withReplacement = false) => + Table.Sample(count, rng, withReplacement); + } +} diff --git a/Runtime/Sampling/WeightedPool.cs.meta b/Runtime/Sampling/WeightedPool.cs.meta new file mode 100644 index 0000000..a65cada --- /dev/null +++ b/Runtime/Sampling/WeightedPool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ce232916f5c5d1c2049ae837a979a650 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Sampling/WeightedTable.cs b/Runtime/Sampling/WeightedTable.cs new file mode 100644 index 0000000..9270a18 --- /dev/null +++ b/Runtime/Sampling/WeightedTable.cs @@ -0,0 +1,170 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Spellbound.Core.Sampling { + [Serializable] + public class WeightedTable : ISerializationCallbackReceiver where T : class { + [SerializeField] private List> entries = new(); + [SerializeField, Min(0)] private int nothingWeight; + + private int[] _cumulativeWeights; + private int _totalWeight; + + public IReadOnlyList> Entries => entries ??= new List>(); + + public int NothingWeight => Math.Max(0, nothingWeight); + + public int TotalWeight { + get { + EnsureBuilt(); + + return _totalWeight; + } + } + + public int PickIndex(int roll) { + EnsureBuilt(); + + if (_totalWeight <= 0 || roll < 0 || roll >= _totalWeight) + return -1; + + var low = 0; + var high = _cumulativeWeights.Length - 1; + + while (low < high) { + var middle = (low + high) / 2; + + if (_cumulativeWeights[middle] > roll) + high = middle; + else + low = middle + 1; + } + + return low == entries.Count ? -1 : low; + } + + public bool TryPick(int roll, out T candidate) { + var index = PickIndex(roll); + candidate = index >= 0 ? entries[index].candidate : null; + + return candidate != null; + } + + public bool TryPick(System.Random rng, out T candidate) { + var total = TotalWeight; + + if (total <= 0) { + candidate = null; + + return false; + } + + return TryPick(rng.Next(total), out candidate); + } + + public List Sample(int count, System.Random rng, bool withReplacement = false) { + var result = new List(Math.Max(count, 0)); + + if (count <= 0) + return result; + + if (withReplacement) { + SampleWithReplacement(count, rng, result); + + return result; + } + + SampleWithoutReplacement(count, rng, result); + + return result; + } + + public void Define(IEnumerable> definedEntries, int definedNothingWeight = 0) { + entries = definedEntries != null + ? new List>(definedEntries) + : new List>(); + nothingWeight = Math.Max(0, definedNothingWeight); + _cumulativeWeights = null; + } + + void ISerializationCallbackReceiver.OnBeforeSerialize() { } + + void ISerializationCallbackReceiver.OnAfterDeserialize() => _cumulativeWeights = null; + + private void SampleWithReplacement(int count, System.Random rng, List result) { + if (TotalWeight <= NothingWeight) + return; + + for (var pick = 0; pick < count; pick++) { + if (TryPick(rng, out var candidate)) + result.Add(candidate); + } + } + + private void SampleWithoutReplacement(int count, System.Random rng, List result) { + var list = Entries; + var remaining = new int[list.Count]; + var remainingTotal = NothingWeight; + + for (var index = 0; index < list.Count; index++) { + remaining[index] = EffectiveWeight(index); + remainingTotal += remaining[index]; + } + + for (var pick = 0; pick < count; pick++) { + if (remainingTotal <= NothingWeight) + break; + + var roll = rng.Next(remainingTotal); + var cumulative = 0; + var picked = -1; + + for (var index = 0; index < remaining.Length; index++) { + cumulative += remaining[index]; + + if (roll >= cumulative) + continue; + + picked = index; + + break; + } + + if (picked < 0) + continue; + + result.Add(list[picked].candidate); + remainingTotal -= remaining[picked]; + remaining[picked] = 0; + } + } + + private void EnsureBuilt() { + var list = Entries; + + if (_cumulativeWeights != null && _cumulativeWeights.Length == list.Count + 1) + return; + + _cumulativeWeights = new int[list.Count + 1]; + var running = 0; + + for (var index = 0; index < list.Count; index++) { + running += EffectiveWeight(index); + _cumulativeWeights[index] = running; + } + + running += NothingWeight; + _cumulativeWeights[list.Count] = running; + _totalWeight = running; + } + + private int EffectiveWeight(int index) { + var entry = entries[index]; + + return entry.candidate != null && entry.weight > 0 ? entry.weight : 0; + } + } +} diff --git a/Runtime/Sampling/WeightedTable.cs.meta b/Runtime/Sampling/WeightedTable.cs.meta new file mode 100644 index 0000000..f32810a --- /dev/null +++ b/Runtime/Sampling/WeightedTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e81433560925b888e7455c0c39c85b82 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/WeightedTableTests.cs b/Tests/WeightedTableTests.cs new file mode 100644 index 0000000..9860d94 --- /dev/null +++ b/Tests/WeightedTableTests.cs @@ -0,0 +1,146 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; +using NUnit.Framework; +using Spellbound.Core.Sampling; + +namespace Spellbound.Core.Tests { + public class WeightedTableTests { + private class Candidate { + public string Name; + } + + private static readonly Candidate A = new() { Name = "A" }; + private static readonly Candidate B = new() { Name = "B" }; + private static readonly Candidate C = new() { Name = "C" }; + + private static WeightedTable Table(int nothingWeight, params (Candidate candidate, int weight)[] entries) { + var list = new List>(); + + foreach (var (candidate, weight) in entries) + list.Add(new WeightedEntry { candidate = candidate, weight = weight }); + + var table = new WeightedTable(); + table.Define(list, nothingWeight); + + return table; + } + + [Test] + public void PickIndexMapsEveryRollToTheSlotOwningItsBand() { + var table = Table(4, (A, 1), (B, 3)); + + Assert.AreEqual(8, table.TotalWeight); + Assert.AreEqual(0, table.PickIndex(0)); + + for (var roll = 1; roll < 4; roll++) + Assert.AreEqual(1, table.PickIndex(roll)); + + for (var roll = 4; roll < 8; roll++) + Assert.AreEqual(-1, table.PickIndex(roll)); + } + + [Test] + public void PickIndexReturnsNothingOutsideTheRange() { + var table = Table(0, (A, 2), (B, 2)); + + Assert.AreEqual(1, table.PickIndex(3)); + Assert.AreEqual(-1, table.PickIndex(4)); + Assert.AreEqual(-1, table.PickIndex(-1)); + } + + [Test] + public void PickIndexSkipsZeroWeightAndNullCandidates() { + var table = Table(0, (null, 5), (A, 0), (B, 1)); + + Assert.AreEqual(1, table.TotalWeight); + Assert.AreEqual(2, table.PickIndex(0)); + } + + [Test] + public void TryPickReportsNothingAsFalse() { + var table = Table(1, (A, 1)); + + Assert.IsTrue(table.TryPick(0, out var picked)); + Assert.AreSame(A, picked); + Assert.IsFalse(table.TryPick(1, out picked)); + Assert.IsNull(picked); + } + + [Test] + public void EmptyTableNeverPicks() { + var table = new WeightedTable(); + + Assert.AreEqual(0, table.TotalWeight); + Assert.IsFalse(table.TryPick(new System.Random(1), out _)); + Assert.IsEmpty(table.Sample(3, new System.Random(1))); + Assert.IsEmpty(table.Sample(3, new System.Random(1), true)); + } + + [Test] + public void SameSeedGivesTheSameSequence() { + var table = Table(2, (A, 1), (B, 3), (C, 5)); + + var first = table.Sample(20, new System.Random(99), true); + var second = table.Sample(20, new System.Random(99), true); + + CollectionAssert.AreEqual(first, second); + } + + [Test] + public void SampleWithoutReplacementReturnsDistinctPicksAndStopsWhenExhausted() { + var table = Table(0, (A, 1), (B, 1), (C, 1)); + + var picked = table.Sample(5, new System.Random(7)); + + Assert.AreEqual(3, picked.Count); + CollectionAssert.AllItemsAreUnique(picked); + } + + [Test] + public void SampleWithReplacementCanRepeat() { + var table = Table(0, (A, 1)); + + var picked = table.Sample(3, new System.Random(7), true); + + Assert.AreEqual(3, picked.Count); + + foreach (var candidate in picked) + Assert.AreSame(A, candidate); + } + + [Test] + public void NothingConsumesAPickAndIsNeverRemoved() { + var table = Table(1, (A, 1)); + + var withReplacement = table.Sample(200, new System.Random(3), true); + var withoutReplacement = table.Sample(200, new System.Random(3)); + + Assert.Less(withReplacement.Count, 200); + Assert.Greater(withReplacement.Count, 0); + Assert.AreEqual(1, withoutReplacement.Count); + Assert.AreSame(A, withoutReplacement[0]); + } + + [Test] + public void OnlyNothingLeftStopsSampling() { + var table = Table(5, (A, 0)); + + Assert.IsEmpty(table.Sample(10, new System.Random(1))); + Assert.IsEmpty(table.Sample(10, new System.Random(1), true)); + } + + [Test] + public void DefineRebuildsTheCumulativeWeights() { + var table = Table(0, (A, 1)); + + Assert.AreEqual(1, table.TotalWeight); + + table.Define(new[] { new WeightedEntry { candidate = B, weight = 4 } }, 2); + + Assert.AreEqual(6, table.TotalWeight); + Assert.AreEqual(0, table.PickIndex(3)); + Assert.AreEqual(-1, table.PickIndex(4)); + } + } +} diff --git a/Tests/WeightedTableTests.cs.meta b/Tests/WeightedTableTests.cs.meta new file mode 100644 index 0000000..2ee500b --- /dev/null +++ b/Tests/WeightedTableTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d385299d684588cb19b243e6b46b7cc0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 574c859..7eeb5ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.6", + "version": "1.1.7", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From c10c8065ac40aaf2ebcd58e283de05e22d3fca1a Mon Sep 17 00:00:00 2001 From: Judsin Date: Mon, 7 Sep 2026 14:55:59 -0500 Subject: [PATCH 18/19] weighted table rename --- CHANGELOG.md | 4 ++++ Runtime/Sampling/{WeightedPool.cs => WeightedTableAsset.cs} | 2 +- .../{WeightedPool.cs.meta => WeightedTableAsset.cs.meta} | 0 package.json | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) rename Runtime/Sampling/{WeightedPool.cs => WeightedTableAsset.cs} (86%) rename Runtime/Sampling/{WeightedPool.cs.meta => WeightedTableAsset.cs.meta} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb7e80..bded0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [1.1.8] - 9/7/2026 + +- `WeightedPool` renamed to `WeightedTableAsset`. It is the asset form of a `WeightedTable`, and the old name collided with `ObjectPool`. + ## [1.1.7] - 9/7/2026 - `WeightedTable` in `Spellbound.Core.Sampling`: a serializable weighted list with a nothing slot. One roll through `PickIndex` or `TryPick`, driven by any random source in `[0, TotalWeight)`, resolves by binary search over cumulative weights built once per table. `Sample` draws several picks with or without replacement. `Define` builds a table in code. diff --git a/Runtime/Sampling/WeightedPool.cs b/Runtime/Sampling/WeightedTableAsset.cs similarity index 86% rename from Runtime/Sampling/WeightedPool.cs rename to Runtime/Sampling/WeightedTableAsset.cs index 0c50dbf..9afdecb 100644 --- a/Runtime/Sampling/WeightedPool.cs +++ b/Runtime/Sampling/WeightedTableAsset.cs @@ -4,7 +4,7 @@ using UnityEngine; namespace Spellbound.Core.Sampling { - public abstract class WeightedPool : ScriptableObject where T : class { + public abstract class WeightedTableAsset : ScriptableObject where T : class { [SerializeField] private WeightedTable table = new(); public WeightedTable Table => table ??= new WeightedTable(); diff --git a/Runtime/Sampling/WeightedPool.cs.meta b/Runtime/Sampling/WeightedTableAsset.cs.meta similarity index 100% rename from Runtime/Sampling/WeightedPool.cs.meta rename to Runtime/Sampling/WeightedTableAsset.cs.meta diff --git a/package.json b/package.json index 7eeb5ac..731b3ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.spellboundstudios.core", - "version": "1.1.7", + "version": "1.1.8", "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", From d66cefc7a5234f80a3fd9d1a8630348691f2ccd8 Mon Sep 17 00:00:00 2001 From: Judsin Date: Fri, 18 Sep 2026 12:55:56 -0500 Subject: [PATCH 19/19] pre warm arg --- Runtime/ObjectPooling/ObjectPool.cs | 20 +++++++++ Tests/ObjectPoolTests.cs | 68 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/Runtime/ObjectPooling/ObjectPool.cs b/Runtime/ObjectPooling/ObjectPool.cs index 13c3c8a..554a021 100644 --- a/Runtime/ObjectPooling/ObjectPool.cs +++ b/Runtime/ObjectPooling/ObjectPool.cs @@ -15,19 +15,39 @@ public abstract class ObjectPool where T : class { /// How many items are parked and rentable without allocating. public int Available => _free.Count; + public int MaximumRetained { get; set; } + /// Rent an item — popped off the top, or freshly d when the pool is empty. public T Rent() => _free.Count > 0 ? _free.Pop() : Create(); /// Reset an item and park it for the next . public void Return(T item) { + if (MaximumRetained > 0 && _free.Count >= MaximumRetained) { + Discard(item); + + return; + } + Reset(item); _free.Push(item); } + public void Prewarm(int count) { + var target = MaximumRetained > 0 && count > MaximumRetained ? MaximumRetained : count; + + while (_free.Count < target) { + var item = Create(); + Reset(item); + _free.Push(item); + } + } + /// Build a fresh instance when the pool has none to hand out. protected abstract T Create(); /// Return an item to a clean state before it is reused. No-op by default. protected virtual void Reset(T item) { } + + protected virtual void Discard(T item) { } } } diff --git a/Tests/ObjectPoolTests.cs b/Tests/ObjectPoolTests.cs index 5eb5ebe..3ea747d 100644 --- a/Tests/ObjectPoolTests.cs +++ b/Tests/ObjectPoolTests.cs @@ -11,6 +11,7 @@ private class Item { private class ItemPool : ObjectPool { public int Created; + public int Discarded; protected override Item Create() { Created++; @@ -19,6 +20,73 @@ protected override Item Create() { } protected override void Reset(Item item) => item.ResetCount++; + + protected override void Discard(Item item) => Discarded++; + } + + [Test] + public void PrewarmFillsToCountWithResetItems() { + var pool = new ItemPool(); + + pool.Prewarm(3); + + Assert.AreEqual(3, pool.Created); + Assert.AreEqual(3, pool.Available); + Assert.AreEqual(1, pool.Rent().ResetCount); + Assert.AreEqual(3, pool.Created); + } + + [Test] + public void PrewarmOnlyCreatesTheShortfall() { + var pool = new ItemPool(); + pool.Return(pool.Rent()); + + pool.Prewarm(3); + + Assert.AreEqual(3, pool.Created); + Assert.AreEqual(3, pool.Available); + + pool.Prewarm(2); + + Assert.AreEqual(3, pool.Created); + Assert.AreEqual(3, pool.Available); + } + + [Test] + public void PrewarmStopsAtMaximumRetained() { + var pool = new ItemPool { MaximumRetained = 2 }; + + pool.Prewarm(5); + + Assert.AreEqual(2, pool.Created); + Assert.AreEqual(2, pool.Available); + } + + [Test] + public void ReturnDiscardsBeyondMaximumRetained() { + var pool = new ItemPool { MaximumRetained = 1 }; + var first = pool.Rent(); + var second = pool.Rent(); + + pool.Return(first); + pool.Return(second); + + Assert.AreEqual(1, pool.Available); + Assert.AreEqual(1, pool.Discarded); + Assert.AreEqual(0, second.ResetCount); + Assert.AreSame(first, pool.Rent()); + } + + [Test] + public void ZeroMaximumRetainedKeepsEverything() { + var pool = new ItemPool(); + var items = new[] { pool.Rent(), pool.Rent(), pool.Rent() }; + + foreach (var item in items) + pool.Return(item); + + Assert.AreEqual(3, pool.Available); + Assert.AreEqual(0, pool.Discarded); } [Test]