diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebdf06..bded0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## [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. +- `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`. +- `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. + +## [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 0395ced..0bd6ab6 100644 --- a/Editor/LogConfigEditor.cs +++ b/Editor/LogConfigEditor.cs @@ -1,9 +1,5 @@ // Copyright 2026 Spellbound Studio Inc. -// === LogConfigEditor.cs === -// Copyright 2026 Spellbound Studio Inc. - -#if UNITY_EDITOR using System; using System.Collections.Generic; using System.Linq; @@ -175,6 +171,9 @@ private static List DiscoverSinks() { if (type.IsAbstract || type.IsInterface) continue; + if (!type.IsPublic) + continue; + if (type.GetConstructor(Type.EmptyTypes) == null) continue; @@ -295,4 +294,3 @@ private struct DiscoveredSink { } } } -#endif \ No newline at end of file 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/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/Editor/SerializeReferenceDropdownDrawer.cs b/Editor/SerializeReferenceDropdownDrawer.cs new file mode 100644 index 0000000..5517937 --- /dev/null +++ b/Editor/SerializeReferenceDropdownDrawer.cs @@ -0,0 +1,97 @@ +// 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/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 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/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/ClearCommand.cs b/Runtime/Console/ClearCommand.cs index 4ffc74f..6d82491 100644 --- a/Runtime/Console/ClearCommand.cs +++ b/Runtime/Console/ClearCommand.cs @@ -1,6 +1,4 @@ -// Copyright 2025 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/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/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/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..5ee1790 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; @@ -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 3b0fe5a..a23b316 100644 --- a/Runtime/Console/ConsoleLogger.cs +++ b/Runtime/Console/ConsoleLogger.cs @@ -1,4 +1,7 @@ -// Copyright 2025 Spellbound Studio Inc. +// Copyright 2026 Spellbound Studio Inc. + +using System; +using UnityEngine; namespace Spellbound.Core.Console { /// @@ -6,33 +9,32 @@ namespace Spellbound.Core.Console { /// Useful for debugging and adding capability to your packages. /// 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); + + 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/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..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. /// @@ -29,11 +32,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/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 ac22ec9..878997a 100644 --- a/Runtime/Logger/Log.cs +++ b/Runtime/Logger/Log.cs @@ -6,21 +6,113 @@ namespace Spellbound.Core.Logging { public static class Log { - private struct RegisteredSink { + internal struct RegisteredSink { public ILogSink Sink; 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; + } + } + + 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")] @@ -68,13 +160,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/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/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/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/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..5fbfba9 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,34 @@ 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. + +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/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/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/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..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 { @@ -26,7 +27,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 +51,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 +77,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() { @@ -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/ObjectData/DynamicInstanceEntry.cs b/Runtime/ObjectData/DynamicInstanceEntry.cs index c1153a4..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); } } @@ -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 5f7b62e..80505ea 100644 --- a/Runtime/ObjectData/IObjectDataAccess.cs +++ b/Runtime/ObjectData/IObjectDataAccess.cs @@ -29,20 +29,22 @@ 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 TryReadAll(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. - Task TryDeleteInstance(int instanceIndex); + void DeleteInstance(int instanceIndex); } } \ No newline at end of file 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 new file mode 100644 index 0000000..b7ec909 --- /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(byte eventSurfaceIndex, out T data) where T : IPackerObjectData, new(); + + void Write(byte eventSurfaceIndex, T newData, byte contextIn) where T : IPackerObjectData, new(); + + public void Delta(byte eventSurfaceIndex, TDispatch dispatch) + where TData : IPackerObjectData, new() + where TDispatch : IPackerDispatch, new(); + + bool TryReadAllBySurface(byte 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/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 fb579ba..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 { }; @@ -127,7 +132,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,28 +146,26 @@ 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); + int instanceIndex, uint presetHash, byte eventSurfaceIndex, out List results) => + StaticDataAccess.TryReadAllBySurface(instanceIndex, eventSurfaceIndex, out results); - public bool TryWriteData( - int instanceIndex, uint presetHash, int eventSurfaceIndex, T newData, byte context = 0) + public bool WriteData( + int instanceIndex, uint presetHash, byte eventSurfaceIndex, T newData, byte context = 0) where T : IPackerObjectData, new() { StaticDataAccess.Write(instanceIndex, presetHash, eventSurfaceIndex, newData, context); return true; } - public bool TryTransformData( - int instanceIndex, uint presetHash, int eventSurfaceIndex, TDispatch delta) + public void Delta( + int instanceIndex, uint presetHash, byte 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 @@ -337,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; } /// @@ -540,9 +545,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; @@ -675,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); @@ -725,6 +743,8 @@ public void StaticEntityDistanceQuery(float3 localPov) { existingEventSurfaces.Dispose(); instancesToAwaken.Dispose(); instancesToSleep.Dispose(); + + return true; } #endregion Distance Queries diff --git a/Runtime/ObjectHandling/TransformData.cs b/Runtime/ObjectHandling/TransformData.cs index ef62e8d..9e28405 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; } @@ -49,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/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/Runtime/Objects/ObjectPreset.cs b/Runtime/Objects/ObjectPreset.cs index bfd0d5d..f62d5f8 100644 --- a/Runtime/Objects/ObjectPreset.cs +++ b/Runtime/Objects/ObjectPreset.cs @@ -26,15 +26,20 @@ 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; - 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,60 +50,66 @@ private void RewireModules() { } } - public bool TryGetModule(out T result, int surfaceIndex = 0) where T : class { - if (TryGetModules(out var results, surfaceIndex)) { - result = results[0]; + public bool TryGetModule(out T result, byte surfaceIndex = 0) where T : class { + var modules = LookupModules(surfaceIndex); + + if (modules.Count > 0) { + result = modules[0]; + return true; } result = null; + return false; } - public bool TryGetModules(out IReadOnlyList results, int surfaceIndex = 0) where T : class { - results = Array.Empty(); + public bool TryGetModules(out IReadOnlyList results, byte surfaceIndex = 0) where T : class { + results = LookupModules(surfaceIndex); - if (surfaceIndex < 0 || surfaceIndex >= surfaceModules.Count) - return false; + return results.Count > 0; + } - var matches = new List(); - foreach (var module in surfaceModules[surfaceIndex].presetModules) { - if (module is T t) - matches.Add(t); - } + public bool TryGetModulesAcrossSurfaces(out IReadOnlyList results) where T : class { + results = LookupModules(AllSurfaces); + + return results.Count > 0; + } - if (matches.Count == 0) - return false; + private IReadOnlyList LookupModules(int surfaceIndex) where T : class { + var key = (surfaceIndex, typeof(T)); - results = matches; + if (_moduleCache.TryGetValue(key, out var cached)) + return (IReadOnlyList)cached; - return true; - } - - public bool TryGetModulesAcrossSurfaces(out IReadOnlyList results) where T : class { - results = Array.Empty(); 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/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/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 269ea71..0f0472c 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)] @@ -40,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 @@ -325,11 +348,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) { @@ -612,21 +640,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 { @@ -639,20 +669,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"); } /// @@ -689,21 +721,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 { @@ -716,6 +750,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; } @@ -725,10 +760,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"); } /// @@ -853,21 +890,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 (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } - 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 +919,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 (Exception e) when (e is ArgumentException or IndexOutOfRangeException) { } } 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"); } /// @@ -923,21 +963,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 { @@ -950,20 +992,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"); } /// @@ -993,15 +1037,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; @@ -1012,20 +1048,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 { @@ -1037,6 +1075,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; } @@ -1046,10 +1085,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/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/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/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/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/Runtime/Sampling/WeightedTableAsset.cs b/Runtime/Sampling/WeightedTableAsset.cs new file mode 100644 index 0000000..9afdecb --- /dev/null +++ b/Runtime/Sampling/WeightedTableAsset.cs @@ -0,0 +1,17 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System.Collections.Generic; +using UnityEngine; + +namespace Spellbound.Core.Sampling { + public abstract class WeightedTableAsset : 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/WeightedTableAsset.cs.meta b/Runtime/Sampling/WeightedTableAsset.cs.meta new file mode 100644 index 0000000..a65cada --- /dev/null +++ b/Runtime/Sampling/WeightedTableAsset.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/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..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; @@ -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 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/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 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/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/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/LogSinkTests.cs b/Tests/LogSinkTests.cs new file mode 100644 index 0000000..7117d4a --- /dev/null +++ b/Tests/LogSinkTests.cs @@ -0,0 +1,235 @@ +// 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 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(); + + 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/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/ObjectPoolTests.cs b/Tests/ObjectPoolTests.cs new file mode 100644 index 0000000..3ea747d --- /dev/null +++ b/Tests/ObjectPoolTests.cs @@ -0,0 +1,113 @@ +// 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; + public int Discarded; + + protected override Item Create() { + Created++; + + return new Item(); + } + + 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] + 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..129d634 --- /dev/null +++ b/Tests/PackerTests.cs @@ -0,0 +1,809 @@ +// Copyright 2026 Spellbound Studio Inc. + +using System; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using Spellbound.Core.Logging; +using Spellbound.Core.Packing; +using UnityEngine; + +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; + 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.WriteSByte(ref buffer, SByteValue); + 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); + SByteValue = Packer.ReadSByte(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); + } + + [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, + IntValue = int.MinValue, + UIntValue = uint.MaxValue, + LongValue = long.MinValue, + ULongValue = ulong.MaxValue, + FloatValue = 3.14159f + }; + + 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); + + 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] + 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 BitwiseVariantsReadWhatBitConverterWrote() { + var buffer = new byte[28]; + var writeSpan = buffer.AsSpan(); + + 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((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] + 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 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); + + 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 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]; + 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 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]; + 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 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 { + 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 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); + + 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 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)); + 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/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/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 diff --git a/Tests/Spellbound.Core.Tests.asmdef b/Tests/Spellbound.Core.Tests.asmdef new file mode 100644 index 0000000..97af902 --- /dev/null +++ b/Tests/Spellbound.Core.Tests.asmdef @@ -0,0 +1,28 @@ +{ + "name": "Spellbound.Core.Tests", + "rootNamespace": "", + "references": [ + "Spellbound.Core", + "Unity.Collections", + "Unity.Entities", + "Unity.Mathematics", + "Unity.Transforms", + "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 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/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 c98de6c..731b3ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.spellboundstudios.core", - "version": "1.0.1", - "displayName": "Spellbound Core", + "version": "1.1.8", + "displayName": "Spellbound.Core", "description": "Core backbone for all Spellbound systems and libraries.", "unity": "6000.0", "unityRelease": "40f1", @@ -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" },