diff --git a/Prowl.Runtime.Generators/EquatableArray.cs b/Prowl.Runtime.Generators/EquatableArray.cs
new file mode 100644
index 000000000..10ebd5532
--- /dev/null
+++ b/Prowl.Runtime.Generators/EquatableArray.cs
@@ -0,0 +1,56 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Prowl.Generators;
+
+///
+/// Value-equality wrapper around T[] for use in incremental generator
+/// pipeline models. The default array equality (reference) would defeat caching.
+///
+internal readonly struct EquatableArray : IEquatable>, IEnumerable
+ where T : IEquatable
+{
+ private readonly T[]? _array;
+
+ public EquatableArray(T[] array) => _array = array;
+
+ public int Length => _array?.Length ?? 0;
+
+ public T this[int index] => _array![index];
+
+ public bool Equals(EquatableArray other)
+ {
+ if (ReferenceEquals(_array, other._array)) return true;
+ if (_array is null || other._array is null) return false;
+ if (_array.Length != other._array.Length) return false;
+ for (int i = 0; i < _array.Length; i++)
+ {
+ if (!_array[i].Equals(other._array[i]))
+ return false;
+ }
+ return true;
+ }
+
+ public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ if (_array is null) return 0;
+ unchecked
+ {
+ int hash = 17;
+ for (int i = 0; i < _array.Length; i++)
+ hash = hash * 31 + _array[i].GetHashCode();
+ return hash;
+ }
+ }
+
+ public IEnumerator GetEnumerator()
+ => ((IEnumerable)(_array ?? Array.Empty())).GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/Prowl.Runtime.Generators/EventDomainGenerator.cs b/Prowl.Runtime.Generators/EventDomainGenerator.cs
new file mode 100644
index 000000000..eed937849
--- /dev/null
+++ b/Prowl.Runtime.Generators/EventDomainGenerator.cs
@@ -0,0 +1,458 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Threading;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Prowl.Generators;
+
+[Generator]
+public class EventDomainGenerator : IIncrementalGenerator
+{
+ private const string EventDomainAttrFqn = "Prowl.Runtime.Events.EventDomainAttribute";
+ private const string EventArgsAttrFqn = "Prowl.Runtime.Events.EventArgsAttribute";
+ private const string EventKeyFqn = "global::Prowl.Runtime.Events.EventKey";
+ private const string UnitFqn = "global::Prowl.Runtime.Events.Unit";
+
+ private static readonly DiagnosticDescriptor s_notPartialDiag = new(
+ id: "PEVT0001",
+ title: "EventDomain class must be partial",
+ messageFormat: "The class '{0}' is marked with [EventDomain] but is not declared as 'partial'. Add the 'partial' modifier.",
+ category: "Prowl",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var classDeclarations = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ EventDomainAttrFqn,
+ predicate: static (node, _) => node is ClassDeclarationSyntax,
+ transform: static (ctx, ct) => GetDomainInfo(ctx, ct))
+ .Where(static m => m is not null);
+
+ context.RegisterSourceOutput(classDeclarations,
+ static (spc, domain) => Execute(spc, domain!.Value));
+ }
+
+ #region Data model (value-equatable for incremental caching)
+
+ private struct EventKeyInfo : IEquatable
+ {
+ public string Name;
+ public string ArgsTypeFqn;
+ public bool IsUnit;
+
+ public bool Equals(EventKeyInfo other)
+ => Name == other.Name
+ && ArgsTypeFqn == other.ArgsTypeFqn
+ && IsUnit == other.IsUnit;
+
+ public override bool Equals(object? obj) => obj is EventKeyInfo o && Equals(o);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ int h = 17;
+ h = h * 31 + (Name?.GetHashCode() ?? 0);
+ h = h * 31 + (ArgsTypeFqn?.GetHashCode() ?? 0);
+ h = h * 31 + IsUnit.GetHashCode();
+ return h;
+ }
+ }
+ }
+
+ private struct ContainingTypeInfo : IEquatable
+ {
+ public string Keyword; // "class", "struct", "record class", etc.
+ public string Name;
+ public string Accessibility; // "public", "internal", etc.
+ public bool IsStatic;
+
+ public bool Equals(ContainingTypeInfo other)
+ => Keyword == other.Keyword
+ && Name == other.Name
+ && Accessibility == other.Accessibility
+ && IsStatic == other.IsStatic;
+
+ public override bool Equals(object? obj) => obj is ContainingTypeInfo o && Equals(o);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ int h = 17;
+ h = h * 31 + (Keyword?.GetHashCode() ?? 0);
+ h = h * 31 + (Name?.GetHashCode() ?? 0);
+ h = h * 31 + (Accessibility?.GetHashCode() ?? 0);
+ h = h * 31 + IsStatic.GetHashCode();
+ return h;
+ }
+ }
+ }
+
+ private struct EventDomainInfo : IEquatable
+ {
+ public string? Namespace;
+ public string ClassName;
+ public string ClassAccessibility;
+ public bool IsStatic;
+ public bool IsGlobal;
+ public bool IsPartial;
+ public EquatableArray ContainingTypes;
+ public EquatableArray Events;
+ public Location? DiagnosticLocation;
+
+ public bool Equals(EventDomainInfo other)
+ => Namespace == other.Namespace
+ && ClassName == other.ClassName
+ && ClassAccessibility == other.ClassAccessibility
+ && IsStatic == other.IsStatic
+ && IsGlobal == other.IsGlobal
+ && IsPartial == other.IsPartial
+ && ContainingTypes.Equals(other.ContainingTypes)
+ && Events.Equals(other.Events);
+
+ public override bool Equals(object? obj) => obj is EventDomainInfo o && Equals(o);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ int h = 17;
+ h = h * 31 + (Namespace?.GetHashCode() ?? 0);
+ h = h * 31 + (ClassName?.GetHashCode() ?? 0);
+ h = h * 31 + (ClassAccessibility?.GetHashCode() ?? 0);
+ h = h * 31 + IsStatic.GetHashCode();
+ h = h * 31 + IsGlobal.GetHashCode();
+ h = h * 31 + IsPartial.GetHashCode();
+ h = h * 31 + ContainingTypes.GetHashCode();
+ h = h * 31 + Events.GetHashCode();
+ return h;
+ }
+ }
+ }
+
+ #endregion
+
+ private static EventDomainInfo? GetDomainInfo(GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
+ {
+ var classDecl = (ClassDeclarationSyntax)ctx.TargetNode;
+ var classSymbol = (INamedTypeSymbol)ctx.TargetSymbol;
+
+ bool isPartial = classDecl.Modifiers.Any(SyntaxKind.PartialKeyword);
+ bool isStatic = classSymbol.IsStatic;
+
+ // Extract Global property from [EventDomain] attribute
+ bool isGlobal = false;
+ foreach (var attrData in classSymbol.GetAttributes())
+ {
+ if (attrData.AttributeClass?.ToDisplayString() == "Prowl.Runtime.Events.EventDomainAttribute")
+ {
+ foreach (var namedArg in attrData.NamedArguments)
+ {
+ if (namedArg.Key == "Global" && namedArg.Value.Value is bool g)
+ isGlobal = g;
+ }
+ break;
+ }
+ }
+
+ // Collect EventKey fields with optional [EventArgs]
+ var events = new List();
+ foreach (var member in classSymbol.GetMembers())
+ {
+ ct.ThrowIfCancellationRequested();
+ if (member is IFieldSymbol field
+ && field.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == EventKeyFqn)
+ {
+ string argsType = UnitFqn; // default to Unit if no [EventArgs]
+ foreach (var attr in field.GetAttributes())
+ {
+ if (attr.AttributeClass?.ToDisplayString() == "Prowl.Runtime.Events.EventArgsAttribute"
+ && attr.ConstructorArguments.Length == 1
+ && attr.ConstructorArguments[0].Value is ITypeSymbol typeArg
+ && typeArg.TypeKind != TypeKind.Error)
+ {
+ argsType = typeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ break;
+ }
+ }
+
+ bool isUnit = argsType == UnitFqn;
+ string eventName = field.Name.StartsWith("_") ? field.Name.Substring(1) : field.Name;
+ events.Add(new EventKeyInfo
+ {
+ Name = eventName,
+ ArgsTypeFqn = argsType,
+ IsUnit = isUnit,
+ });
+ }
+ }
+
+ if (events.Count == 0 && isPartial)
+ return null; // nothing to generate
+
+ // Collect containing type chain for nested classes
+ var containingTypes = new List();
+ var parent = classSymbol.ContainingType;
+ while (parent is not null)
+ {
+ containingTypes.Insert(0, new ContainingTypeInfo
+ {
+ Keyword = parent.IsRecord ? "record class" : "class",
+ Name = parent.Name,
+ Accessibility = AccessibilityToString(parent.DeclaredAccessibility),
+ IsStatic = parent.IsStatic,
+ });
+ parent = parent.ContainingType;
+ }
+
+ string? ns = classSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : classSymbol.ContainingNamespace.ToDisplayString();
+
+ return new EventDomainInfo
+ {
+ Namespace = ns,
+ ClassName = classSymbol.Name,
+ ClassAccessibility = AccessibilityToString(classSymbol.DeclaredAccessibility),
+ IsStatic = isStatic,
+ IsGlobal = isGlobal,
+ IsPartial = isPartial,
+ ContainingTypes = new EquatableArray(containingTypes.ToArray()),
+ Events = new EquatableArray(events.ToArray()),
+ DiagnosticLocation = classDecl.Identifier.GetLocation(),
+ };
+ }
+
+ private static void Execute(SourceProductionContext spc, EventDomainInfo domain)
+ {
+ // Emit diagnostic if class is not partial
+ if (!domain.IsPartial)
+ {
+ spc.ReportDiagnostic(Diagnostic.Create(
+ s_notPartialDiag,
+ domain.DiagnosticLocation,
+ domain.ClassName));
+ return;
+ }
+
+ if (domain.Events.Length == 0)
+ return;
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("// Generated by Prowl.Runtime.Generators — do not edit.");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+
+ if (domain.Namespace is not null)
+ {
+ sb.AppendLine($"namespace {domain.Namespace}");
+ sb.AppendLine("{");
+ }
+
+ string indent = domain.Namespace is not null ? " " : "";
+
+ // Open containing types
+ foreach (var ct in domain.ContainingTypes)
+ {
+ string staticMod = ct.IsStatic ? " static" : "";
+ sb.AppendLine($"{indent}{ct.Accessibility}{staticMod} partial {ct.Keyword} {ct.Name}");
+ sb.AppendLine($"{indent}{{");
+ indent += " ";
+ }
+
+ // Open the domain class
+ string classMods = domain.IsStatic ? " static" : "";
+ sb.AppendLine($"{indent}{domain.ClassAccessibility}{classMods} partial class {domain.ClassName}");
+ sb.AppendLine($"{indent}{{");
+ string ci = indent + " "; // content indent
+
+ // --- Generate enum ---
+ sb.AppendLine($"{ci}/// Auto-generated enum backing the event keys in this domain.");
+ sb.AppendLine($"{ci}public enum EventTypes");
+ sb.AppendLine($"{ci}{{");
+ foreach (var evt in domain.Events)
+ {
+ sb.AppendLine($"{ci} [global::Prowl.Runtime.Events.EventArgs(typeof({evt.ArgsTypeFqn}))]");
+ sb.AppendLine($"{ci} {evt.Name},");
+ }
+ sb.AppendLine($"{ci}}}");
+ sb.AppendLine();
+
+ // --- Generate manager ---
+ string globalArg = domain.IsGlobal ? "global: true" : "";
+ string memberStatic = domain.IsStatic ? " static" : "";
+ string managerField = domain.IsStatic ? "s_eventManager" : "_eventManager";
+ string fieldDecl = domain.IsStatic ? "private static readonly" : "private readonly";
+ sb.AppendLine($"{ci}{fieldDecl} global::Prowl.Runtime.Events.EventManager {managerField} = new({globalArg});");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Gets the for this event domain. For instance domains, dispose this when the owner is no longer needed.");
+ sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventManager Manager => {managerField};");
+ sb.AppendLine();
+
+ // --- Generate event accessor properties for += / -= subscription and .Invoke() ---
+ foreach (var evt in domain.Events)
+ {
+ string argsType = evt.ArgsTypeFqn;
+
+ if (evt.IsUnit)
+ {
+ sb.AppendLine($"{ci}/// Access using += / -= to subscribe, or .Invoke() to fire. For priority/tags/source capture or IDisposable container, use {evt.Name}.Subscribe(...) on this accessor.");
+ sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventAccessor {evt.Name}");
+ sb.AppendLine($"{ci}{{");
+ sb.AppendLine($"{ci} get => new({managerField}, EventTypes.{evt.Name});");
+ sb.AppendLine($"{ci} set {{ }}");
+ sb.AppendLine($"{ci}}}");
+ }
+ else
+ {
+ sb.AppendLine($"{ci}/// Access using += / -= to subscribe, or .Invoke({argsType}) to fire. For priority/tags/source capture or IDisposable container, use {evt.Name}.Subscribe(...) on this accessor.");
+ sb.AppendLine($"{ci}public{memberStatic} global::Prowl.Runtime.Events.EventAccessor {evt.Name}");
+ sb.AppendLine($"{ci}{{");
+ sb.AppendLine($"{ci} get => new({managerField}, EventTypes.{evt.Name});");
+ sb.AppendLine($"{ci} set {{ }}");
+ sb.AppendLine($"{ci}}}");
+ }
+ }
+ sb.AppendLine();
+
+ // --- Generate per-event convenience methods ---
+ foreach (var evt in domain.Events)
+ {
+ string argsType = evt.ArgsTypeFqn;
+
+ sb.AppendLine($"{ci}// --- {evt.Name} ---");
+ sb.AppendLine();
+
+ if (evt.IsUnit)
+ {
+ EmitUnitMethods(sb, ci, evt.Name, memberStatic, managerField);
+ }
+ else
+ {
+ EmitTypedMethods(sb, ci, evt.Name, argsType, memberStatic, managerField);
+ }
+ }
+
+ // --- Generate tag-based management methods ---
+ sb.AppendLine($"{ci}/// Enables all handlers in this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public{memberStatic} void EnableByTag(string tag) => {managerField}.EnableByTag(tag);");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Disables all handlers in this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public{memberStatic} void DisableByTag(string tag) => {managerField}.DisableByTag(tag);");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Removes all handlers in this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public{memberStatic} void RemoveByTag(string tag) => {managerField}.RemoveByTag(tag);");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Enables all handlers across all global managers of this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public static void GlobalEnableByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalEnableByTag(tag);");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Disables all handlers across all global managers of this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public static void GlobalDisableByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalDisableByTag(tag);");
+ sb.AppendLine();
+ sb.AppendLine($"{ci}/// Removes all handlers across all global managers of this domain that have the specified tag.");
+ sb.AppendLine($"{ci}public static void GlobalRemoveByTag(string tag) => global::Prowl.Runtime.Events.EventManager.GlobalRemoveByTag(tag);");
+ sb.AppendLine();
+
+ // Close domain class
+ sb.AppendLine($"{indent}}}");
+
+ // Close containing types
+ for (int i = domain.ContainingTypes.Length - 1; i >= 0; i--)
+ {
+ indent = indent.Substring(0, indent.Length - 4);
+ sb.AppendLine($"{indent}}}");
+ }
+
+ // Close namespace
+ if (domain.Namespace is not null)
+ sb.AppendLine("}");
+
+ string hintName = domain.ContainingTypes.Length > 0
+ ? string.Join(".", domain.ContainingTypes.Select(c => c.Name)) + "." + domain.ClassName + ".g.cs"
+ : domain.ClassName + ".g.cs";
+
+ spc.AddSource(hintName, sb.ToString());
+ }
+
+ private static void EmitUnitMethods(StringBuilder sb, string ci, string name, string memberStatic, string managerField)
+ {
+ // Invoke (parameterless)
+ sb.AppendLine($"{ci}/// Invokes on this domain's manager.");
+ sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]");
+ sb.AppendLine($"{ci}public{memberStatic} void Invoke{name}()");
+ sb.AppendLine($"{ci} => {managerField}.InvokeEvent(EventTypes.{name});");
+ sb.AppendLine();
+
+ // InvokeAsync (parameterless)
+ sb.AppendLine($"{ci}/// Asynchronously invokes on this domain's manager, awaiting async handlers.");
+ sb.AppendLine($"{ci}public{memberStatic} global::System.Threading.Tasks.Task Invoke{name}Async()");
+ sb.AppendLine($"{ci} => {managerField}.InvokeEventAsync(EventTypes.{name});");
+ sb.AppendLine();
+
+ // GlobalInvoke (parameterless) — always static
+ sb.AppendLine($"{ci}/// Invokes across all global managers of this domain.");
+ sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]");
+ sb.AppendLine($"{ci}public static void GlobalInvoke{name}()");
+ sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEvent(EventTypes.{name});");
+ sb.AppendLine();
+
+ // GlobalInvokeAsync (parameterless) — always static
+ sb.AppendLine($"{ci}/// Asynchronously invokes across all global managers of this domain, awaiting async handlers.");
+ sb.AppendLine($"{ci}public static global::System.Threading.Tasks.Task GlobalInvoke{name}Async()");
+ sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEventAsync(EventTypes.{name});");
+ sb.AppendLine();
+ }
+
+ private static void EmitTypedMethods(StringBuilder sb, string ci, string name, string argsType, string memberStatic, string managerField)
+ {
+ // Invoke (typed)
+ sb.AppendLine($"{ci}/// Invokes on this domain's manager.");
+ sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]");
+ sb.AppendLine($"{ci}public{memberStatic} void Invoke{name}({argsType} args)");
+ sb.AppendLine($"{ci} => {managerField}.InvokeEvent(EventTypes.{name}, args);");
+ sb.AppendLine();
+
+ // InvokeAsync (typed)
+ sb.AppendLine($"{ci}/// Asynchronously invokes on this domain's manager, awaiting async handlers.");
+ sb.AppendLine($"{ci}public{memberStatic} global::System.Threading.Tasks.Task Invoke{name}Async({argsType} args)");
+ sb.AppendLine($"{ci} => {managerField}.InvokeEventAsync(EventTypes.{name}, args);");
+ sb.AppendLine();
+
+ // GlobalInvoke (typed) — always static
+ sb.AppendLine($"{ci}/// Invokes across all global managers of this domain.");
+ sb.AppendLine($"{ci}[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]");
+ sb.AppendLine($"{ci}public static void GlobalInvoke{name}({argsType} args)");
+ sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEvent(EventTypes.{name}, args);");
+ sb.AppendLine();
+
+ // GlobalInvokeAsync (typed) — always static
+ sb.AppendLine($"{ci}/// Asynchronously invokes across all global managers of this domain, awaiting async handlers.");
+ sb.AppendLine($"{ci}public static global::System.Threading.Tasks.Task GlobalInvoke{name}Async({argsType} args)");
+ sb.AppendLine($"{ci} => global::Prowl.Runtime.Events.EventManager.GlobalInvokeEventAsync(EventTypes.{name}, args);");
+ sb.AppendLine();
+ }
+
+ private static string AccessibilityToString(Accessibility accessibility) => accessibility switch
+ {
+ Accessibility.Public => "public",
+ Accessibility.Internal => "internal",
+ Accessibility.Protected => "protected",
+ Accessibility.ProtectedOrInternal => "protected internal",
+ Accessibility.ProtectedAndInternal => "private protected",
+ Accessibility.Private => "private",
+ _ => "internal",
+ };
+}
diff --git a/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj b/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj
new file mode 100644
index 000000000..fed008746
--- /dev/null
+++ b/Prowl.Runtime.Generators/Prowl.Runtime.Generators.csproj
@@ -0,0 +1,16 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ true
+ true
+ RS2008
+
+
+
+
+
+
+
diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs
index 8e0b87a2e..3b2ed64db 100644
--- a/Prowl.Runtime/Components/GameCanvas.cs
+++ b/Prowl.Runtime/Components/GameCanvas.cs
@@ -5,6 +5,7 @@
using System.Collections.Generic;
using Prowl.Echo;
+using Prowl.Runtime.Events;
using Prowl.Runtime.GUI;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
@@ -190,13 +191,13 @@ public override void OnDisable() { /* tree retained but no canvas walk picks us
/// Overlay/Camera canvases ignore this hook — they are pulled by
/// from the pipeline directly.
///
- public override void OnRenderCollect(Camera camera, List renderables, List _)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs onRenderCollectArgs)
{
if (RenderMode != RenderMode.WorldSpace) return;
RebuildIfDirty();
Tree.RefreshTransforms();
foreach (UIRenderItem it in Tree.Items)
- renderables.Add(it);
+ onRenderCollectArgs.renderables.Add(it);
}
// ============================================================
diff --git a/Prowl.Runtime/Components/Lights/DirectionalLight.cs b/Prowl.Runtime/Components/Lights/DirectionalLight.cs
index 6dc0c3016..f3cab709b 100644
--- a/Prowl.Runtime/Components/Lights/DirectionalLight.cs
+++ b/Prowl.Runtime/Components/Lights/DirectionalLight.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Vector;
@@ -36,9 +37,9 @@ public enum CascadeCount : int
private Float4[] _cascadeAtlasParams = new Float4[4]; // xy = atlas pos, z = atlas size, w = split distance
private int _activeCascades = 0;
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
- lights.Add(this);
+ args.lights.Add(this);
}
public override void DrawGizmos()
diff --git a/Prowl.Runtime/Components/Lights/Light.cs b/Prowl.Runtime/Components/Lights/Light.cs
index d45a50f83..ee101c4a0 100644
--- a/Prowl.Runtime/Components/Lights/Light.cs
+++ b/Prowl.Runtime/Components/Lights/Light.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Vector;
@@ -56,9 +57,9 @@ public abstract class Light : MonoBehaviour, IRenderableLight
public int ShadowSlot { get; internal set; } = -1;
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
- lights.Add(this);
+ args.lights.Add(this);
}
public virtual int GetLayer() => GameObject.LayerIndex;
diff --git a/Prowl.Runtime/Components/Lights/PointLight.cs b/Prowl.Runtime/Components/Lights/PointLight.cs
index 15e417733..bc8756ea1 100644
--- a/Prowl.Runtime/Components/Lights/PointLight.cs
+++ b/Prowl.Runtime/Components/Lights/PointLight.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Vector;
@@ -28,9 +29,9 @@ public enum Resolution : int
private Float4x4[] _shadowMatrices = new Float4x4[6]; // View-projection for each face
private bool _shadowsValid = false;
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
- lights.Add(this);
+ args.lights.Add(this);
}
public override void DrawGizmos()
diff --git a/Prowl.Runtime/Components/Lights/SpotLight.cs b/Prowl.Runtime/Components/Lights/SpotLight.cs
index 916acf4f7..b80066e13 100644
--- a/Prowl.Runtime/Components/Lights/SpotLight.cs
+++ b/Prowl.Runtime/Components/Lights/SpotLight.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Vector;
@@ -28,9 +29,9 @@ public enum Resolution : int
private Float4x4 _shadowMatrix;
private Float4 _shadowAtlasParams; // xy = atlas pos, z = atlas size, w = 1.0
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
- lights.Add(this);
+ args.lights.Add(this);
}
public override void DrawGizmos()
diff --git a/Prowl.Runtime/Components/LineRenderer.cs b/Prowl.Runtime/Components/LineRenderer.cs
index b9f0ba1d5..5a8c3432d 100644
--- a/Prowl.Runtime/Components/LineRenderer.cs
+++ b/Prowl.Runtime/Components/LineRenderer.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using Prowl.Runtime.Rendering;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Resources;
using Prowl.Vector;
@@ -84,10 +85,10 @@ public override void Update()
}
}
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
if (Material.Res != null && Points != null && Points.Count >= 2)
- renderables.Add(this);
+ args.renderables.Add(this);
}
private bool PointsEqual(List a, List b)
diff --git a/Prowl.Runtime/Components/MeshRenderer.cs b/Prowl.Runtime/Components/MeshRenderer.cs
index 663e96cc5..7c3a50950 100644
--- a/Prowl.Runtime/Components/MeshRenderer.cs
+++ b/Prowl.Runtime/Components/MeshRenderer.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
using Prowl.Vector;
@@ -36,7 +37,7 @@ public AssetRef Material
/// UV2 → atlas transform: uv2 * xy + zw. Assigned by the lightmap bake.
[HideInInspector] public Float4 LightmapScaleOffset = new(1, 1, 0, 0);
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
var mesh = Mesh.Res;
if (mesh == null || Materials.Count == 0) return;
@@ -62,7 +63,7 @@ public override void OnRenderCollect(Camera camera, List renderable
Float3 giAnchor = Float4x4.TransformPoint(mesh.bounds.Center, Transform.LocalToWorldMatrix);
LightmapBinding.Fill(props, GameObject.Scene, LightmapIndex, LightmapScaleOffset, giAnchor, mesh.HasUV2);
- renderables.Add(new MeshRenderable(
+ args.renderables.Add(new MeshRenderable(
mesh, mat, Transform.LocalToWorldMatrix,
GameObject.LayerIndex, props, subMeshIndex: subCount > 1 ? s : -1));
}
diff --git a/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs b/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs
index 9806269bd..042e8c42c 100644
--- a/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs
+++ b/Prowl.Runtime/Components/ParticleSystem/ParticleSystemComponent.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
using Prowl.Runtime.ParticleSystem.Modules;
@@ -155,7 +156,7 @@ public override void Update()
}
}
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
if (_particles.Count <= 0 || Material.Res == null || _quadMesh == null) return;
@@ -168,7 +169,7 @@ public override void OnRenderCollect(Camera camera, List renderable
// Create batched instanced renderables
InstancedMeshRenderable.CreateBatched(
- renderables,
+ args.renderables,
_quadMesh,
Material.Res,
_transforms,
@@ -181,7 +182,7 @@ public override void OnRenderCollect(Camera camera, List renderable
);
if (Light.Enabled)
- CollectParticleLights(lights);
+ CollectParticleLights(args.lights);
}
///
diff --git a/Prowl.Runtime/Components/SkinnedMeshRenderer.cs b/Prowl.Runtime/Components/SkinnedMeshRenderer.cs
index 024c4fe49..223a7f29d 100644
--- a/Prowl.Runtime/Components/SkinnedMeshRenderer.cs
+++ b/Prowl.Runtime/Components/SkinnedMeshRenderer.cs
@@ -5,6 +5,7 @@
using System.Collections.Generic;
using Prowl.Echo;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
using Prowl.Vector;
@@ -411,7 +412,7 @@ private void UploadMorphWeightTexture()
_morphWeightTexture!.SetData(data.AsMemory(0, _morphWeightCapacity), 0, 0, (uint)_morphWeightCapacity, 1);
}
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
var mesh = SharedMesh.Res;
if (mesh == null || Materials.Count == 0) return;
@@ -470,7 +471,7 @@ public override void OnRenderCollect(Camera camera, List renderable
if (mesh.HasBlendShapes)
ApplyBlendShapeProps(mesh, props);
- renderables.Add(new SkinnedMeshRenderable(
+ args.renderables.Add(new SkinnedMeshRenderable(
mesh, mat, Transform.LocalToWorldMatrix,
GameObject.LayerIndex, worldBounds, props, subMeshIndex: s));
}
diff --git a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
index 4bbb5080e..06db65932 100644
--- a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
+++ b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
using Prowl.Vector;
@@ -142,7 +143,7 @@ public Float3 TerrainToWorld(Float3 localPoint) =>
public Float3 WorldToTerrain(Float3 worldPoint) =>
Float4x4.TransformPoint(worldPoint, Transform.WorldToLocalMatrix);
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
var terrainData = Data.Res;
if (terrainData == null) return;
@@ -152,7 +153,7 @@ public override void OnRenderCollect(Camera camera, List renderable
Float4x4 worldToTerrain = Transform.WorldToLocalMatrix;
// Camera position in terrain-local space for LOD
- Float3 camLocal = WorldToTerrain(camera.Transform.Position);
+ Float3 camLocal = WorldToTerrain(args.camera.Transform.Position);
camLocal.Y = 0;
if (_quadtree == null
@@ -223,7 +224,7 @@ public override void OnRenderCollect(Camera camera, List renderable
var bounds = TransformAABB(localMin, localMax, terrainToWorld);
InstancedMeshRenderable.CreateBatched(
- renderables, _baseMesh, mat, _transforms,
+ args.renderables, _baseMesh, mat, _transforms,
(bounds.Min + bounds.Max) * 0.5f,
layer: GameObject.LayerIndex,
properties: _properties, bounds: bounds);
@@ -247,11 +248,11 @@ public override void OnRenderCollect(Camera camera, List renderable
var htex = terrainData.GetHeightmapTexture();
if (htex != null) grassMat.SetTexture("_Heightmap", htex);
- _grassRenderer?.CollectRenderables(terrainData, this, camera, grassMat, GrassDistance, GrassDensityMultiplier, renderables);
+ _grassRenderer?.CollectRenderables(terrainData, this, args.camera, grassMat, GrassDistance, GrassDensityMultiplier, args.renderables);
}
// Trees
- _treeRenderer?.CollectRenderables(terrainData, this, camera, TreeDistance, renderables);
+ _treeRenderer?.CollectRenderables(terrainData, this, args.camera, TreeDistance, args.renderables);
}
public override void DrawGizmos()
diff --git a/Prowl.Runtime/Components/WorldCanvas.cs b/Prowl.Runtime/Components/WorldCanvas.cs
index 62b47fcc4..0f2c92323 100644
--- a/Prowl.Runtime/Components/WorldCanvas.cs
+++ b/Prowl.Runtime/Components/WorldCanvas.cs
@@ -6,6 +6,7 @@
using Prowl.PaperUI;
using Prowl.Runtime.GUI;
+using Prowl.Runtime.Events;
using Prowl.Runtime.Rendering;
using Prowl.Runtime.Resources;
using Prowl.Vector;
@@ -103,7 +104,7 @@ public override void Update()
}
- public override void OnRenderCollect(Camera camera, List renderables, List lights)
+ public override void OnRenderCollect(SceneEvents.OnRenderCollectArgs args)
{
// Push this canvas as a renderable
if (_renderTexture.IsValid() && (Material.Res?.IsValid() ?? false) && _quadMesh.IsValid())
@@ -111,7 +112,7 @@ public override void OnRenderCollect(Camera camera, List renderable
_properties.Clear();
_properties.SetInt("_ObjectID", InstanceID);
_properties.SetTexture("_MainTex", _renderTexture.MainTexture);
- renderables.Add(this);
+ args.renderables.Add(this);
}
}
diff --git a/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs
new file mode 100644
index 000000000..5b70f43e9
--- /dev/null
+++ b/Prowl.Runtime/Events/AsyncEventDelegateContainer.cs
@@ -0,0 +1,131 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Threading.Tasks;
+
+namespace Prowl.Runtime.Events {
+
+///
+/// Typed delegate container wrapping a for async
+/// event handlers. Participates in the same priority-sorted snapshot as synchronous
+/// containers, enabling mixed sync/async handler chains.
+///
+/// When invoked via the synchronous path, the returned
+/// is observed but not awaited. Use
+/// or the generated
+/// InvokeXxxAsync methods to properly await async handlers.
+///
+///
+public class AsyncEventDelegateContainer : EventDelegateContainer, IAsyncInvocable
+ where T : struct, Enum
+{
+ ///
+ public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_asyncDelegate);
+
+ private readonly Func? _asyncDelegate;
+
+ public override Delegate? GetDelegate() => _asyncDelegate;
+
+ public AsyncEventDelegateContainer(T eventType, Func asyncDelegate, ExecutionOrder priority = default, string[]? tags = null)
+ : base(eventType, priority, tags)
+ {
+ _asyncDelegate = asyncDelegate;
+ }
+
+public AsyncEventDelegateContainer(T eventType, Func asyncDelegate, ExecutionOrder priority,
+ string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null)
+ : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags)
+{
+ _asyncDelegate = asyncDelegate;
+}
+
+ ///
+ /// Protected constructor for subclasses that provide their own invocation
+ /// logic and do not use the field.
+ ///
+ protected AsyncEventDelegateContainer(T eventType, ExecutionOrder priority, string[]? tags = null)
+ : base(eventType, priority, tags)
+ {
+ }
+
+protected AsyncEventDelegateContainer(T eventType, ExecutionOrder priority,
+ string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null)
+ : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags)
+{
+}
+
+ ///
+ /// Synchronous invocation fallback. Fires the async handler without awaiting.
+ /// When EVENT_DEBUG is defined, a warning is logged — prefer instead.
+ ///
+ public override void Invoke(TArgs args)
+ {
+ if (!Enabled) return;
+#if EVENT_DEBUG
+ EventSystemDiagnostics.LogWarning?.Invoke(
+ $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " +
+ $"Use InvokeAsync/InvokeEventAsync for proper async execution. " +
+ $"Handler: {SourceDescription}");
+#endif
+ _asyncDelegate?.Invoke(args);
+ }
+
+ ///
+ /// Asynchronously invokes the handler and returns the resulting .
+ ///
+ public virtual Task InvokeAsync(TArgs args)
+ {
+ if (!Enabled) return Task.CompletedTask;
+ return _asyncDelegate?.Invoke(args) ?? Task.CompletedTask;
+ }
+}
+
+///
+/// Specialized container for parameterless async events that stores a
+/// directly, avoiding the closure allocation that
+/// wrapping in a would incur.
+///
+public sealed class ParameterlessAsyncEventDelegateContainer : AsyncEventDelegateContainer
+ where T : struct, Enum
+{
+ ///
+ public override bool MatchesDelegate(Delegate handler) => handler != null && handler.Equals(_asyncAction);
+
+ private readonly Func _asyncAction;
+
+ public override Delegate? GetDelegate() => _asyncAction;
+
+ public ParameterlessAsyncEventDelegateContainer(T eventType, Func asyncAction, ExecutionOrder priority = default, string[]? tags = null)
+ : base(eventType, priority, tags)
+ {
+ _asyncAction = asyncAction;
+ }
+
+public ParameterlessAsyncEventDelegateContainer(T eventType, Func asyncAction, ExecutionOrder priority,
+ string? sourceFile, int sourceLine, string? sourceMember, string[]? tags = null)
+ : base(eventType, priority, sourceFile, sourceLine, sourceMember, tags)
+{
+ _asyncAction = asyncAction;
+}
+
+ public override void Invoke(Unit args)
+ {
+ if (!Enabled) return;
+#if EVENT_DEBUG
+ EventSystemDiagnostics.LogWarning?.Invoke(
+ $"[EventSystem] Async handler on {typeof(T).Name} invoked synchronously. " +
+ $"Use InvokeAsync/InvokeEventAsync for proper async execution. " +
+ $"Handler: {SourceDescription}");
+#endif
+ _asyncAction?.Invoke();
+ }
+
+ public override Task InvokeAsync(Unit args)
+ {
+ if (!Enabled) return Task.CompletedTask;
+ return _asyncAction?.Invoke() ?? Task.CompletedTask;
+ }
+}
+
+}
diff --git a/Prowl.Runtime/Events/Event.cs b/Prowl.Runtime/Events/Event.cs
new file mode 100644
index 000000000..029e0f0b2
--- /dev/null
+++ b/Prowl.Runtime/Events/Event.cs
@@ -0,0 +1,647 @@
+// This file is part of the Prowl Game Engine
+// Licensed under the MIT License. See the LICENSE file in the project root for details.
+
+using System;
+using System.Buffers;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+
+namespace Prowl.Runtime.Events;
+
+public class Event where T : struct, Enum
+{
+ ///
+ /// JIT-time cache: true when implements
+ /// . Evaluated once per closed generic and stored in a
+ /// static field, so the hot-path never boxes value-type
+ /// args just to check the interface.
+ ///
+ private static class CancellableCheck
+ {
+ public static readonly bool IsCancellable = typeof(ICancellable).IsAssignableFrom(typeof(TArgs));
+ }
+
+#if EVENT_DEBUG
+ ///
+ /// Configurable threshold in milliseconds. Handlers exceeding this duration
+ /// will be logged as warnings when EVENT_DEBUG is defined. Set to 0 to disable.
+ ///
+ public static double SlowHandlerThresholdMs { get; set; } = 200.0;
+#endif
+
+ private readonly T _eventType;
+ public T EventType => _eventType;
+
+ private readonly EventManager _eventManager;
+ public EventManager EventManager => _eventManager;
+
+ private readonly List> _delegates = [];
+
+ private readonly object _lock = new();
+
+ ///
+ /// Copy-on-write snapshot: a flat, priority-sorted array of all delegates.
+ /// Rebuilt only when the subscriber list changes (Add/Remove), never on Invoke.
+ /// Used by EVENT_DEBUG diagnostics to detect type-mismatch handlers.
+ ///
+ private EventDelegateContainer[] _cachedSnapshot = [];
+
+ ///
+ /// Actual valid length of when rented from ArrayPool.
+ /// The rented array may be larger than needed; use this length for iteration.
+ ///
+ private int _cachedSnapshotLength = 0;
+
+ ///
+ /// Per-TArgs typed COW snapshots keyed by .
+ /// Each value is a EventDelegateContainer<T, TArgs>[] stored as
+ /// .
+ ///
+ /// retrieves the matching typed array with a single
+ /// dictionary lookup and one array-reference cast — no per-element type check.
+ ///
+ ///
+ private readonly ConcurrentDictionary _typedSnapshots = new();
+
+ ///
+ /// Per-TArgs actual lengths of typed snapshots. Since typed arrays
+ /// are rented from ArrayPool, they may be larger than needed.
+ ///
+ private readonly ConcurrentDictionary _typedSnapshotLengths = new();
+
+ private volatile bool _enabled = true;
+ public bool Enabled
+ {
+ get => _enabled;
+ set => _enabled = value;
+ }
+
+ ///
+ /// When greater than zero, snapshot rebuilds are deferred until the batch
+ /// count returns to zero. Incremented by and
+ /// decremented by . Must only be accessed under .
+ ///
+ private int _batchDepth;
+
+ ///
+ /// Tracks whether any Add/Remove occurred while batching was active,
+ /// so that knows whether a rebuild is needed.
+ ///
+ private bool _batchDirty;
+
+ public Event(EventManager eventManager, T eventType)
+ {
+ this._eventType = eventType;
+ this._eventManager = eventManager;
+ }
+
+ ///
+ /// Begins a batch operation. While batched, and
+ /// will not rebuild snapshots. Call when finished to rebuild once.
+ /// Calls may be nested; only the outermost triggers the rebuild.
+ ///
+ public void BeginBatch()
+ {
+ lock (_lock)
+ {
+ _batchDepth++;
+ }
+ }
+
+ ///
+ /// Ends a batch operation. If this is the outermost batch and any mutations
+ /// occurred, the COW snapshot is rebuilt exactly once.
+ ///
+ public void EndBatch()
+ {
+ lock (_lock)
+ {
+ if (_batchDepth > 0)
+ _batchDepth--;
+
+ if (_batchDepth == 0 && _batchDirty)
+ {
+ _batchDirty = false;
+ RebuildSnapshot();
+ }
+ }
+ }
+
+
+ public void Invoke(TArgs args)
+ {
+ if (!Enabled) return;
+
+ EventDelegateContainer[] typedSnapshot;
+ int typedLength;
+#if EVENT_DEBUG
+ EventDelegateContainer[] fullSnapshot;
+ int fullLength;
+#endif
+ lock (_lock)
+ {
+ if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj))
+ {
+ typedSnapshot = (EventDelegateContainer[])obj;
+ typedLength = _typedSnapshotLengths[typeof(TArgs)];
+ }
+ else
+ {
+ typedSnapshot = [];
+ typedLength = 0;
+ }
+#if EVENT_DEBUG
+ fullSnapshot = _cachedSnapshot;
+ fullLength = _cachedSnapshotLength;
+#endif
+ }
+
+#if EVENT_DEBUG
+ // Warn about handlers on this event registered with a different TArgs.
+ if (fullLength > typedLength)
+ {
+ for (int j = 0; j < fullLength; j++)
+ {
+ if (fullSnapshot[j] is not EventDelegateContainer)
+ WarnTypeMismatch(fullSnapshot[j]);
+ }
+ }
+
+ double threshold = SlowHandlerThresholdMs;
+ Stopwatch? sw = threshold > 0 ? Stopwatch.StartNew() : null;
+#endif
+ var span = typedSnapshot.AsSpan(0, typedLength);
+ for (int j = 0; j < span.Length; j++)
+ {
+#if EVENT_DEBUG
+ sw?.Restart();
+#endif
+ try
+ {
+ span[j].Invoke(args);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ }
+
+#if EVENT_DEBUG
+ if (sw is not null)
+ {
+ sw.Stop();
+ double elapsed = sw.Elapsed.TotalMilliseconds;
+ if (elapsed > threshold)
+ {
+ EventSystemDiagnostics.LogWarning?.Invoke(
+ $"[EventSystem] Slow handler on {typeof(T).Name}.{_eventType}: " +
+ $"{elapsed:F2}ms (threshold {threshold:F1}ms). " +
+ $"Handler: {typedSnapshot[j].SourceDescription}");
+ }
+ }
+#endif
+
+ if (CancellableCheck.IsCancellable && args is ICancellable { Cancelled: true })
+ break;
+ }
+
+
+ }
+
+ ///
+ /// Asynchronously invokes all handlers for the given ,
+ /// awaiting async handlers () sequentially while
+ /// calling synchronous handlers inline. Priority ordering and cancellation semantics
+ /// are identical to .
+ ///
+ /// Intended for editor/tool events where handlers legitimately need to perform I/O.
+ /// Not recommended for the game-loop hot path — use instead.
+ ///
+ ///
+ public async Task InvokeAsync(TArgs args)
+ {
+ if (!Enabled) return;
+
+ EventDelegateContainer[] typedSnapshot;
+ int typedLength;
+#if EVENT_DEBUG
+ EventDelegateContainer[] fullSnapshot;
+ int fullLength;
+#endif
+ lock (_lock)
+ {
+ if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj))
+ {
+ typedSnapshot = (EventDelegateContainer[])obj;
+ typedLength = _typedSnapshotLengths[typeof(TArgs)];
+ }
+ else
+ {
+ typedSnapshot = [];
+ typedLength = 0;
+ }
+#if EVENT_DEBUG
+ fullSnapshot = _cachedSnapshot;
+ fullLength = _cachedSnapshotLength;
+#endif
+ }
+
+#if EVENT_DEBUG
+ // Warn about handlers on this event registered with a different TArgs.
+ if (fullLength > typedLength)
+ {
+ for (int j = 0; j < fullLength; j++)
+ {
+ if (fullSnapshot[j] is not EventDelegateContainer)
+ WarnTypeMismatch(fullSnapshot[j]);
+ }
+ }
+
+ double threshold = SlowHandlerThresholdMs;
+ Stopwatch? sw = threshold > 0 ? Stopwatch.StartNew() : null;
+#endif
+
+ for (int j = 0; j < typedLength; j++)
+ {
+#if EVENT_DEBUG
+ sw?.Restart();
+#endif
+ try
+ {
+ if (typedSnapshot[j] is IAsyncInvocable asyncHandler)
+ await asyncHandler.InvokeAsync(args).ConfigureAwait(false);
+ else
+ typedSnapshot[j].Invoke(args);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine(ex.Message);
+ }
+
+#if EVENT_DEBUG
+ if (sw is not null)
+ {
+ sw.Stop();
+ double elapsed = sw.Elapsed.TotalMilliseconds;
+ if (elapsed > threshold)
+ {
+ EventSystemDiagnostics.LogWarning?.Invoke(
+ $"[EventSystem] Slow handler on {typeof(T).Name}.{_eventType}: " +
+ $"{elapsed:F2}ms (threshold {threshold:F1}ms). " +
+ $"Handler: {typedSnapshot[j].SourceDescription}");
+ }
+ }
+#endif
+
+ if (CancellableCheck.IsCancellable && args is ICancellable { Cancelled: true })
+ break;
+ }
+
+ }
+
+ ///
+ /// Returns a read-only view of the currently registered handlers for the
+ /// given type, sorted by priority.
+ /// The span references a COW snapshot — it is safe to read after the lock
+ /// is released but may become stale if handlers are added or removed.
+ ///
+ public ReadOnlySpan> GetHandlers()
+ {
+ lock (_lock)
+ {
+ if (_typedSnapshots.TryGetValue(typeof(TArgs), out object? obj))
+ {
+ var array = (EventDelegateContainer[])obj;
+ int length = _typedSnapshotLengths[typeof(TArgs)];
+ return array.AsSpan(0, length);
+ }
+ return ReadOnlySpan>.Empty;
+ }
+ }
+
+#if EVENT_DEBUG
+ ///
+ /// Logs a warning when a registered handler is skipped because its TArgs
+ /// does not match the invoked type. Only compiled when EVENT_DEBUG is defined.
+ ///
+ private void WarnTypeMismatch(EventDelegateContainer container)
+ {
+ // Extract the registered TArgs from the concrete generic type.
+ Type containerType = container.GetType();
+ Type? registeredArgs = null;
+ if (containerType.IsGenericType && containerType.GenericTypeArguments.Length == 2)
+ registeredArgs = containerType.GenericTypeArguments[1];
+
+ EventSystemDiagnostics.LogWarning?.Invoke(
+ $"[EventSystem] Type mismatch on {typeof(T).Name}.{_eventType}: " +
+ $"handler registered for '{registeredArgs?.Name ?? "unknown"}' " +
+ $"but invoked with '{typeof(TArgs).Name}'. Handler was skipped. " +
+ $"(registered at {container.SourceDescription})");
+ }
+#endif
+
+ public bool Add(EventDelegateContainer eventDelegate, bool allowMultiple = false)
+ {
+ bool added = false;
+
+ lock (_lock)
+ {
+ if (allowMultiple || !ContainsDelegate(eventDelegate.GetDelegate()))
+ {
+ // Binary search for insertion point to maintain sorted order by priority.
+ // Uses upper-bound semantics so equal priorities preserve insertion order (stable).
+ int lo = 0, hi = _delegates.Count;
+ while (lo < hi)
+ {
+ int mid = (lo + hi) >>> 1;
+ if (_delegates[mid].Priority.CompareTo(eventDelegate.Priority) <= 0)
+ lo = mid + 1;
+ else
+ hi = mid;
+ }
+ _delegates.Insert(lo, eventDelegate);
+ eventDelegate.Link(this);
+ added = true;
+ }
+ if (added)
+ {
+ if (_batchDepth > 0)
+ _batchDirty = true;
+ else
+ RebuildSnapshot();
+ }
+ eventDelegate.Added = added;
+ return added;
+ }
+ }
+
+ public bool Remove(EventDelegateContainer eventDelegate)
+ {
+ lock (_lock)
+ {
+ bool result = _delegates.Remove(eventDelegate);
+ if (result)
+ {
+ eventDelegate.Unlink();
+ if (_batchDepth > 0)
+ _batchDirty = true;
+ else
+ RebuildSnapshot();
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Removes the first delegate container whose wrapped handler equals the
+ /// specified . Used by generated -= event accessors.
+ ///
+ public bool RemoveByDelegate(Delegate handler)
+ {
+ lock (_lock)
+ {
+ for (int i = 0; i < _delegates.Count; i++)
+ {
+ if (_delegates[i].MatchesDelegate(handler))
+ {
+ EventDelegateContainer container = _delegates[i];
+ _delegates.RemoveAt(i);
+ container.Unlink();
+ if (_batchDepth > 0)
+ _batchDirty = true;
+ else
+ RebuildSnapshot();
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private bool ContainsDelegate(Delegate? handler)
+ {
+ if (handler == null) return false;
+ for (int i = 0; i < _delegates.Count; i++)
+ {
+ if (_delegates[i].MatchesDelegate(handler)) return true;
+ }
+ return false;
+ }
+
+ public void EnableByTag(string tag)
+ {
+ lock (_lock)
+ {
+ foreach (var del in _delegates)
+ {
+ if (del.HasTag(tag)) del.Enable();
+ }
+ }
+ }
+
+ public void DisableByTag(string tag)
+ {
+ lock (_lock)
+ {
+ foreach (var del in _delegates)
+ {
+ if (del.HasTag(tag)) del.Disable();
+ }
+ }
+ }
+
+ public void RemoveByTag(string tag)
+ {
+ lock (_lock)
+ {
+ bool removed = false;
+ for (int i = _delegates.Count - 1; i >= 0; i--)
+ {
+ if (_delegates[i].HasTag(tag))
+ {
+ var container = _delegates[i];
+ _delegates.RemoveAt(i);
+ container.Unlink();
+ removed = true;
+ }
+ }
+
+ if (removed)
+ {
+ if (_batchDepth > 0)
+ _batchDirty = true;
+ else
+ RebuildSnapshot();
+ }
+ }
+ }
+
+
+ ///
+ /// Rebuilds the flat, priority-sorted snapshot array and per-TArgs typed
+ /// snapshot arrays from the current delegate list.
+ /// Must be called under .
+ ///
+ private void RebuildSnapshot()
+ {
+ int totalCount = _delegates.Count;
+
+ if (totalCount == 0)
+ {
+ // Return old snapshot to pool before clearing
+ if (_cachedSnapshot.Length > 0)
+ ArrayPool>.Shared.Return(_cachedSnapshot, clearArray: true);
+
+ _cachedSnapshot = [];
+ _cachedSnapshotLength = 0;
+ _typedSnapshots.Clear();
+ _typedSnapshotLengths.Clear();
+ return;
+ }
+
+ // Return old snapshot to pool before renting new one
+ if (_cachedSnapshot.Length > 0)
+ ArrayPool>.Shared.Return(_cachedSnapshot, clearArray: true);
+
+ // Rent from ArrayPool instead of allocating
+ EventDelegateContainer[] snapshot = ArrayPool>.Shared.Rent(totalCount);
+ for (int i = 0; i < totalCount; i++)
+ snapshot[i] = _delegates[i];
+ _cachedSnapshot = snapshot;
+ _cachedSnapshotLength = totalCount;
+
+ RebuildTypedSnapshots();
+ }
+
+ ///
+ /// Rebuilds per-TArgs typed snapshot arrays from the priority-sorted
+ /// delegate buckets. Each resulting array is a properly typed
+ /// EventDelegateContainer<T, TArgs>[], enabling
+ /// to iterate with direct method calls and
+ /// zero per-element type checks.
+ /// Must be called under .
+ ///
+ private void RebuildTypedSnapshots()
+ {
+ // Return all old typed snapshots to their respective pools
+ foreach (var kvp in _typedSnapshots)
+ {
+ Type argsType = kvp.Key;
+ if (!s_arrayReturnMethods.TryGetValue(argsType, out Action