diff --git a/.gitignore b/.gitignore
index 05c7236..1c24ff4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,10 @@
.idea
*.DotSettings.user
+
+# Unity hides directories whose name ends with '~' from the Editor, which is how a UPM package
+# ships its samples (Samples~) and docs (Documentation~). A user-global gitignore carrying the
+# usual editor-backup pattern '*~' also matches those directories, silently dropping them from
+# every commit — and therefore from the `git subtree split` the release workflow publishes.
+# Re-include them here: repository rules take precedence over core.excludesFile.
+!**/*~/
+!**/*~/**
diff --git a/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators.Tests/StateMachineTests/ReentrancyAndGuardsTests.cs b/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators.Tests/StateMachineTests/ReentrancyAndGuardsTests.cs
new file mode 100644
index 0000000..1bbdba1
--- /dev/null
+++ b/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators/Aspid.Core.HSM.Generators.Tests/StateMachineTests/ReentrancyAndGuardsTests.cs
@@ -0,0 +1,221 @@
+using System;
+using System.Linq;
+using System.Collections.Generic;
+using Xunit;
+
+namespace Aspid.Core.HSM.Generators.Tests.StateMachineTests;
+
+#region Helpers
+
+/// Root state that redirects the machine exactly once, from inside its own OnEnter.
+public sealed class RedirectRootState : BaseTestState, IEnterController
+{
+ public Action? RedirectOnce { get; set; }
+
+ public void OnEnter()
+ {
+ var redirect = RedirectOnce;
+ RedirectOnce = null;
+ redirect?.Invoke();
+ }
+}
+
+public sealed class RedirectChildState : BaseTestState, IChildState { }
+
+public sealed class RedirectLeafState : BaseTestState, IChildState { }
+
+public sealed class RedirectAltState : BaseTestState, IChildState { }
+
+/// State machine exposing the edge-level guard and strict mode for assertions.
+public sealed class GuardedStateMachine(StateFactory stateFactory) : StateMachineBase(stateFactory)
+{
+ public Func? EdgeGuard { get; set; }
+
+ public bool Strict { get; set; }
+
+ public List<(Type Source, Type Target)> ObservedEdges { get; } = [];
+
+ protected override bool StrictTransitions => Strict;
+
+ protected override bool IsTransitionEnabled(Type sourceType, Type targetType)
+ {
+ ObservedEdges.Add((sourceType, targetType));
+ return EdgeGuard?.Invoke(sourceType, targetType) ?? true;
+ }
+}
+
+#endregion
+
+public class ReentrancyAndGuardsTests
+{
+ private static TestStateFactory CreateHierarchyFactory()
+ {
+ var factory = new TestStateFactory();
+ factory.RegisterState();
+ factory.RegisterState();
+ factory.RegisterState();
+ factory.RegisterState();
+ return factory;
+ }
+
+ // The factory used to hand out its own chain buffer, so the previous result silently
+ // became the next result. Callers may now hold a chain across further factory calls.
+ [Fact]
+ public void CreateState_result_is_not_invalidated_by_a_later_call()
+ {
+ var factory = CreateHierarchyFactory();
+ factory.RegisterState();
+ var empty = Array.Empty();
+
+ // A two-state chain first, then a one-state chain: a shared buffer would shrink `first` underneath us.
+ var first = factory.CreateState(empty);
+ var snapshot = first.ToArray();
+
+ factory.CreateState(empty);
+
+ Assert.Equal(snapshot.Length, first.Count);
+ Assert.Equal(snapshot, first.ToArray());
+ }
+
+ // A ChangeState issued from OnEnter used to rewrite the chain the outer loop was still
+ // iterating, entering states twice and leaving duplicates in CurrentStates. It is now
+ // queued and applied once the running change completes (run-to-completion).
+ [Fact]
+ public void Reentrant_ChangeState_from_OnEnter_does_not_corrupt_the_chain()
+ {
+ var root = new RedirectRootState();
+ var child = new RedirectChildState();
+ var leaf = new RedirectLeafState();
+ var alt = new RedirectAltState();
+
+ var factory = new TestStateFactory();
+ factory.RegisterState(() => root);
+ factory.RegisterState(() => child);
+ factory.RegisterState(() => leaf);
+ factory.RegisterState(() => alt);
+
+ var sm = new TestableStateMachine(factory);
+ root.RedirectOnce = () => sm.ChangeState();
+
+ sm.ChangeState();
+
+ // No state appears twice, and the queued request produced the final chain.
+ Assert.Equal(sm.CurrentStates.Count, sm.CurrentStates.Distinct().Count());
+ Assert.Equal(new IState[] { root, alt }, sm.CurrentStates.ToArray());
+
+ // The interrupted change still ran to completion before the queued one was applied.
+ Assert.Equal(1, child.EnterCalled);
+ Assert.Equal(1, child.ExitCalled);
+ Assert.Equal(1, leaf.EnterCalled);
+ Assert.Equal(1, leaf.ExitCalled);
+
+ // Each state was entered exactly once — no double enter from a rewritten chain.
+ Assert.Equal(1, root.EnterCalled);
+ Assert.Equal(1, alt.EnterCalled);
+ Assert.Equal(0, alt.ExitCalled);
+ }
+
+ [Fact]
+ public void IsTransitionEnabled_blocks_a_denied_edge()
+ {
+ var factory = new TestStateFactory();
+ factory.RegisterState();
+ factory.RegisterState();
+
+ var sm = new GuardedStateMachine(factory);
+ sm.ChangeState();
+
+ sm.EdgeGuard = (source, target) =>
+ !(source == typeof(SimpleTestState) && target == typeof(AnotherTestState));
+
+ sm.ChangeState();
+
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+
+ [Fact]
+ public void IsTransitionEnabled_receives_the_source_leaf_and_the_target()
+ {
+ var factory = new TestStateFactory();
+ factory.RegisterState();
+ factory.RegisterState();
+
+ var sm = new GuardedStateMachine(factory);
+ sm.ChangeState();
+ sm.ObservedEdges.Clear();
+
+ sm.ChangeState();
+
+ Assert.Contains((typeof(SimpleTestState), typeof(AnotherTestState)), sm.ObservedEdges);
+ }
+
+ [Fact]
+ public void StrictTransitions_throws_for_an_unregistered_edge()
+ {
+ var sm = new GuardedStateMachine(CreateHierarchyFactory());
+ sm.ChangeState();
+ sm.Strict = true;
+
+ var exception = Assert.Throws(
+ () => sm.TransitionTo());
+
+ Assert.Contains(nameof(ChildTestState), exception.Message);
+ Assert.Contains(nameof(SiblingChildTestState), exception.Message);
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+
+ [Fact]
+ public void StrictTransitions_allows_a_fully_registered_path()
+ {
+ var sm = new GuardedStateMachine(CreateHierarchyFactory());
+ sm.RegisterTransition(new ChildToSiblingSegmentTransition());
+ sm.ChangeState();
+ sm.Strict = true;
+
+ sm.TransitionTo();
+
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+
+ // A partially covered path used to be treated exactly like a fully covered one.
+ [Fact]
+ public void StrictTransitions_throws_when_only_part_of_the_path_is_registered()
+ {
+ var sm = new GuardedStateMachine(CreateHierarchyFactory());
+ sm.RegisterTransition(new GrandchildToChildSegmentTransition());
+ sm.ChangeState();
+ sm.Strict = true;
+
+ Assert.Throws(() => sm.TransitionTo());
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+
+ // Same setup as above, with the default permissive mode: behaviour is unchanged.
+ [Fact]
+ public void Partially_registered_path_still_transitions_when_not_strict()
+ {
+ var sm = new GuardedStateMachine(CreateHierarchyFactory());
+ sm.RegisterTransition(new GrandchildToChildSegmentTransition());
+ sm.ChangeState();
+
+ sm.TransitionTo();
+
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+
+ // ChangeState is the documented escape hatch and stays outside the registry check.
+ [Fact]
+ public void ChangeState_is_not_subject_to_StrictTransitions()
+ {
+ var sm = new GuardedStateMachine(CreateHierarchyFactory());
+ sm.ChangeState();
+ sm.Strict = true;
+
+ sm.ChangeState();
+
+ Assert.IsType(sm.CurrentStates[^1]);
+ }
+}
+
+/// Standalone root state used to force a second, differently shaped chain from the factory.
+public sealed class SimpleTestStateForFactory : BaseTestState { }
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/LICENSE.md b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/LICENSE.md
new file mode 100644
index 0000000..5d9d908
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025-2026 Vladislav Panin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Aspid.Core.HSM.Samples.GameLoop.asmdef b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Aspid.Core.HSM.Samples.GameLoop.asmdef
new file mode 100644
index 0000000..f651b61
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Aspid.Core.HSM.Samples.GameLoop.asmdef
@@ -0,0 +1,18 @@
+{
+ "name": "Aspid.Core.HSM.Samples.GameLoop",
+ "rootNamespace": "Aspid.Core.HSM.Samples.GameLoop",
+ "references": [
+ "Aspid.Core.HSM",
+ "Aspid.Core.HSM.Unity",
+ "UniTask"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/GameHUDController.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/GameHUDController.cs
new file mode 100644
index 0000000..ead5868
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/GameHUDController.cs
@@ -0,0 +1,18 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
+{
+ public class GameHUDController : IEnterController, IExitController
+ {
+ public void OnEnter()
+ {
+ Debug.Log("[HSM] GameHUD: HUD shown");
+ }
+
+ public void OnExit()
+ {
+ Debug.Log("[HSM] GameHUD: HUD hidden");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuInputController.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuInputController.cs
new file mode 100644
index 0000000..07c497c
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuInputController.cs
@@ -0,0 +1,21 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+using UnityEngine.InputSystem;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
+{
+ public class MenuInputController : IUpdateController
+ {
+ public void Update(float deltaTime)
+ {
+ var keyboard = Keyboard.current;
+ if (keyboard == null) return;
+
+ if (keyboard.enterKey.wasPressedThisFrame)
+ Debug.Log("[HSM] MenuInput: Enter pressed — start game");
+
+ if (keyboard.escapeKey.wasPressedThisFrame)
+ Debug.Log("[HSM] MenuInput: Escape pressed — quit");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuUIController.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuUIController.cs
new file mode 100644
index 0000000..8d365f4
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/MenuUIController.cs
@@ -0,0 +1,18 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
+{
+ public class MenuUIController : IEnterController, IExitController
+ {
+ public void OnEnter()
+ {
+ Debug.Log("[HSM] MenuUI: UI enabled");
+ }
+
+ public void OnExit()
+ {
+ Debug.Log("[HSM] MenuUI: UI disabled");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/PlayerInputController.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/PlayerInputController.cs
new file mode 100644
index 0000000..ad011b7
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Controllers/PlayerInputController.cs
@@ -0,0 +1,26 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+using UnityEngine.InputSystem;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
+{
+ public class PlayerInputController : IUpdateController, IEnterController
+ {
+ public void OnEnter()
+ {
+ Debug.Log("[HSM] PlayerInput: controls activated");
+ }
+
+ public void Update(float deltaTime)
+ {
+ var keyboard = Keyboard.current;
+ if (keyboard == null) return;
+
+ if (keyboard.escapeKey.wasPressedThisFrame)
+ Debug.Log("[HSM] PlayerInput: Escape pressed — open pause");
+
+ if (keyboard.f1Key.wasPressedThisFrame)
+ Debug.Log("[HSM] PlayerInput: F1 pressed — toggle debug overlay");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/DebugOverlayExtension.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/DebugOverlayExtension.cs
new file mode 100644
index 0000000..f2fbc5b
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/DebugOverlayExtension.cs
@@ -0,0 +1,35 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Extensions
+{
+ public class DebugOverlayExtension : IExtensionState, IUpdateController
+ {
+ public bool CanAttachTo(IState hostState) => true;
+
+ public void OnAttached(IState hostState)
+ {
+ Debug.Log($"[HSM] DebugOverlay: attached to {hostState.GetType().Name}");
+ }
+
+ public void OnDetached(IState hostState)
+ {
+ Debug.Log($"[HSM] DebugOverlay: detached from {hostState.GetType().Name}");
+ }
+
+ public void Enter()
+ {
+ Debug.Log("[HSM] DebugOverlay: enabled");
+ }
+
+ public void Update(float deltaTime)
+ {
+ // In a real game this would render debug info via IMGUI or UIToolkit
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] DebugOverlay: disabled");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/FpsCounterExtension.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/FpsCounterExtension.cs
new file mode 100644
index 0000000..d9295ac
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Extensions/FpsCounterExtension.cs
@@ -0,0 +1,53 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Extensions
+{
+ [ExtensionFor(typeof(GameplayState), typeof(SinglePlayerState), typeof(PauseState))]
+ public class FpsCounterExtension : IExtensionState, IUpdateController
+ {
+ private float _frameCount;
+ private float _elapsed;
+ private float _currentFps;
+
+ public bool CanAttachTo(IState hostState) =>
+ hostState is GameplayState or SinglePlayerState or PauseState;
+
+ public void OnAttached(IState hostState)
+ {
+ Debug.Log($"[HSM] FpsCounter: attached to {hostState.GetType().Name}");
+ }
+
+ public void OnDetached(IState hostState)
+ {
+ Debug.Log($"[HSM] FpsCounter: detached (was {_currentFps:F1} FPS)");
+ }
+
+ public void Enter()
+ {
+ _frameCount = 0;
+ _elapsed = 0;
+ _currentFps = 0;
+ }
+
+ public void Update(float deltaTime)
+ {
+ _frameCount++;
+ _elapsed += deltaTime;
+
+ if (_elapsed >= 1f)
+ {
+ _currentFps = _frameCount / _elapsed;
+ Debug.Log($"[HSM] FPS: {_currentFps:F1}");
+ _frameCount = 0;
+ _elapsed = 0;
+ }
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] FpsCounter: stopped");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleGameManager.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleGameManager.cs
new file mode 100644
index 0000000..ec6dc5b
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleGameManager.cs
@@ -0,0 +1,125 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.Extensions;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+using Aspid.Core.HSM.Samples.GameLoop.Transitions;
+using UnityEngine;
+using UnityEngine.InputSystem;
+
+namespace Aspid.Core.HSM.Samples.GameLoop
+{
+ public class SampleGameManager : MonoStateMachine
+ {
+ private bool _debugOverlayActive;
+ private bool _fpsCounterActive;
+
+ private void Awake()
+ {
+ var factory = new SampleStateFactory();
+ Initialize(factory);
+
+ RegisterTransition(new MenuToGameplayTransition());
+ RegisterTransition(new GameplayToPauseTransition());
+ RegisterTransition(new PauseToGameplayTransition());
+
+ ChangeState();
+ Debug.Log("[Sample] Ready. Controls: Enter=Play, Escape=Pause/Resume, F1=DebugOverlay, F2=FPS, Backspace=MainMenu");
+ }
+
+ protected override void OnUpdating()
+ {
+ base.OnUpdating();
+ HandleInput();
+ }
+
+ private void HandleInput()
+ {
+ var keyboard = Keyboard.current;
+ if (keyboard == null) return;
+
+ var leaf = CurrentStates[^1];
+
+ switch (leaf)
+ {
+ case MainMenuState:
+ HandleMainMenuInput(keyboard);
+ break;
+ case SinglePlayerState:
+ HandleGameplayInput(keyboard);
+ break;
+ case PauseState:
+ HandlePauseInput(keyboard);
+ break;
+ }
+
+ HandleGlobalInput(keyboard);
+ }
+
+ private void HandleMainMenuInput(Keyboard keyboard)
+ {
+ if (keyboard.enterKey.wasPressedThisFrame)
+ {
+ Debug.Log("[Sample] Starting game...");
+ TransitionTo();
+ }
+ }
+
+ private void HandleGameplayInput(Keyboard keyboard)
+ {
+ if (keyboard.escapeKey.wasPressedThisFrame)
+ {
+ Debug.Log("[Sample] Pausing...");
+ TransitionTo();
+ }
+ }
+
+ private void HandlePauseInput(Keyboard keyboard)
+ {
+ if (keyboard.escapeKey.wasPressedThisFrame)
+ {
+ Debug.Log("[Sample] Resuming...");
+ TransitionTo();
+ }
+
+ if (keyboard.backspaceKey.wasPressedThisFrame)
+ {
+ Debug.Log("[Sample] Returning to main menu...");
+ ChangeState();
+ }
+ }
+
+ private void HandleGlobalInput(Keyboard keyboard)
+ {
+ if (keyboard.f1Key.wasPressedThisFrame)
+ {
+ _debugOverlayActive = !_debugOverlayActive;
+ if (_debugOverlayActive)
+ AttachExtension();
+ else
+ DetachExtension();
+ Debug.Log($"[Sample] DebugOverlay: {(_debugOverlayActive ? "ON" : "OFF")}");
+ }
+
+ if (keyboard.f2Key.wasPressedThisFrame)
+ {
+ _fpsCounterActive = !_fpsCounterActive;
+ if (_fpsCounterActive)
+ AttachExtension();
+ else
+ DetachExtension();
+ Debug.Log($"[Sample] FpsCounter: {(_fpsCounterActive ? "ON" : "OFF")}");
+ }
+ }
+
+ protected override void OnChangedState()
+ {
+ base.OnChangedState();
+ var chain = string.Join(" → ", System.Linq.Enumerable.Select(CurrentStates, s => s.GetType().Name));
+ Debug.Log($"[Sample] State chain: {chain}");
+ }
+
+ private void OnDestroy()
+ {
+ Dispose();
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleStateFactory.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleStateFactory.cs
new file mode 100644
index 0000000..8872f51
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/SampleStateFactory.cs
@@ -0,0 +1,24 @@
+using System;
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.Extensions;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+
+namespace Aspid.Core.HSM.Samples.GameLoop
+{
+ public class SampleStateFactory : StateFactory
+ {
+ protected override IState CreateStateInternal(Type type)
+ {
+ if (type == typeof(RootState)) return new RootState();
+ if (type == typeof(MainMenuState)) return new MainMenuState();
+ if (type == typeof(LoadingState)) return new LoadingState();
+ if (type == typeof(GameplayState)) return new GameplayState();
+ if (type == typeof(SinglePlayerState)) return new SinglePlayerState();
+ if (type == typeof(PauseState)) return new PauseState();
+ if (type == typeof(DebugOverlayExtension)) return new DebugOverlayExtension();
+ if (type == typeof(FpsCounterExtension)) return new FpsCounterExtension();
+
+ throw new ArgumentException($"Unknown state type: {type.Name}");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/GameplayState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/GameplayState.cs
new file mode 100644
index 0000000..e56712a
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/GameplayState.cs
@@ -0,0 +1,28 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.Controllers;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ [ControllerGroup]
+ [ScopeLifetime(ScopeLifetime.Cached)]
+ public partial class GameplayState : IState, IChildState
+ {
+ public GameplayState()
+ {
+ AddControllers(
+ new PlayerInputController(),
+ new GameHUDController());
+ }
+
+ public void Enter()
+ {
+ Debug.Log("[HSM] GameplayState.Enter — gameplay started");
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] GameplayState.Exit — gameplay stopped");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/LoadingState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/LoadingState.cs
new file mode 100644
index 0000000..d1b2f61
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/LoadingState.cs
@@ -0,0 +1,33 @@
+using System.Threading;
+using Aspid.Core.HSM;
+using Cysharp.Threading.Tasks;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ [ScopeLifetime(ScopeLifetime.Transient)]
+ public class LoadingState : IState, IChildState, IAsyncEnterController, IExitController
+ {
+ public void Enter()
+ {
+ Debug.Log("[HSM] LoadingState.Enter — loading screen shown");
+ }
+
+ public async UniTask OnEnterAsync(CancellationToken cancellationToken)
+ {
+ Debug.Log("[HSM] LoadingState: loading assets...");
+ await UniTask.Delay(1500, cancellationToken: cancellationToken);
+ Debug.Log("[HSM] LoadingState: assets loaded");
+ }
+
+ public void OnExit()
+ {
+ Debug.Log("[HSM] LoadingState.OnExit — loading screen hidden");
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] LoadingState.Exit");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/MainMenuState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/MainMenuState.cs
new file mode 100644
index 0000000..2fc4909
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/MainMenuState.cs
@@ -0,0 +1,27 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.Controllers;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ [ControllerGroup]
+ public partial class MainMenuState : IState, IChildState
+ {
+ public MainMenuState()
+ {
+ AddControllers(
+ new MenuInputController(),
+ new MenuUIController());
+ }
+
+ public void Enter()
+ {
+ Debug.Log("[HSM] MainMenuState.Enter — showing main menu");
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] MainMenuState.Exit — hiding main menu");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/PauseState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/PauseState.cs
new file mode 100644
index 0000000..2346b1c
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/PauseState.cs
@@ -0,0 +1,30 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ public class PauseState : IState, IChildState, IEnterController, IExitController
+ {
+ public void Enter()
+ {
+ Debug.Log("[HSM] PauseState.Enter — game paused");
+ }
+
+ public void OnEnter()
+ {
+ Time.timeScale = 0f;
+ Debug.Log("[HSM] PauseState: timeScale set to 0");
+ }
+
+ public void OnExit()
+ {
+ Time.timeScale = 1f;
+ Debug.Log("[HSM] PauseState: timeScale restored to 1");
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] PauseState.Exit — game resumed");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/RootState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/RootState.cs
new file mode 100644
index 0000000..ddbd74e
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/RootState.cs
@@ -0,0 +1,18 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ public class RootState : IState
+ {
+ public void Enter()
+ {
+ Debug.Log("[HSM] RootState.Enter");
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] RootState.Exit");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/SinglePlayerState.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/SinglePlayerState.cs
new file mode 100644
index 0000000..3619cdc
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/States/SinglePlayerState.cs
@@ -0,0 +1,36 @@
+using Aspid.Core.HSM;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.States
+{
+ public class SinglePlayerState : IState, IChildState, IUpdateController, IEnterController
+ {
+ private float _elapsed;
+
+ public void Enter()
+ {
+ Debug.Log("[HSM] SinglePlayerState.Enter");
+ _elapsed = 0f;
+ }
+
+ public void OnEnter()
+ {
+ Debug.Log("[HSM] SinglePlayerState.OnEnter — single player session started");
+ }
+
+ public void Update(float deltaTime)
+ {
+ _elapsed += deltaTime;
+ if (_elapsed >= 5f)
+ {
+ _elapsed = 0f;
+ Debug.Log("[HSM] SinglePlayerState: 5 seconds elapsed");
+ }
+ }
+
+ public void Exit()
+ {
+ Debug.Log("[HSM] SinglePlayerState.Exit");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/GameplayToPauseTransition.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/GameplayToPauseTransition.cs
new file mode 100644
index 0000000..88c2484
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/GameplayToPauseTransition.cs
@@ -0,0 +1,20 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Transitions
+{
+ [Transition(typeof(SinglePlayerState), typeof(PauseState))]
+ public class GameplayToPauseTransition : ITransition
+ {
+ public void OnBeforeTransition()
+ {
+ Debug.Log("[HSM] GameplayToPause: saving quick state...");
+ }
+
+ public void OnAfterTransition()
+ {
+ Debug.Log("[HSM] GameplayToPause: pause menu opened");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/MenuToGameplayTransition.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/MenuToGameplayTransition.cs
new file mode 100644
index 0000000..152ecda
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/MenuToGameplayTransition.cs
@@ -0,0 +1,38 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Transitions
+{
+ [Transition(typeof(MainMenuState), typeof(SinglePlayerState))]
+ public class MenuToGameplayTransition : ITransition
+ {
+ private bool _profileLoaded = true;
+
+ public bool ProfileLoaded
+ {
+ get => _profileLoaded;
+ set => _profileLoaded = value;
+ }
+
+ public bool CanTransition()
+ {
+ if (!_profileLoaded)
+ {
+ Debug.LogWarning("[HSM] MenuToGameplay: blocked — profile not loaded");
+ return false;
+ }
+ return true;
+ }
+
+ public void OnBeforeTransition()
+ {
+ Debug.Log("[HSM] MenuToGameplay: preparing gameplay session...");
+ }
+
+ public void OnAfterTransition()
+ {
+ Debug.Log("[HSM] MenuToGameplay: gameplay session ready");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/PauseToGameplayTransition.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/PauseToGameplayTransition.cs
new file mode 100644
index 0000000..a003995
--- /dev/null
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Samples~/GameLoop/Transitions/PauseToGameplayTransition.cs
@@ -0,0 +1,20 @@
+using Aspid.Core.HSM;
+using Aspid.Core.HSM.Samples.GameLoop.States;
+using UnityEngine;
+
+namespace Aspid.Core.HSM.Samples.GameLoop.Transitions
+{
+ [Transition(typeof(PauseState), typeof(SinglePlayerState))]
+ public class PauseToGameplayTransition : ITransition
+ {
+ public void OnBeforeTransition()
+ {
+ Debug.Log("[HSM] PauseToGameplay: resuming...");
+ }
+
+ public void OnAfterTransition()
+ {
+ Debug.Log("[HSM] PauseToGameplay: gameplay resumed");
+ }
+ }
+}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Source/StateFactory.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Source/StateFactory.cs
index 1b6e9d9..02e8264 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Source/StateFactory.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Source/StateFactory.cs
@@ -13,7 +13,6 @@ namespace Aspid.Core.HSM
public abstract class StateFactory
{
private readonly HashSet _initializedStates = new();
- private readonly List _chainBuffer = new(capacity: 4);
private IStateScope? _rootScope;
private readonly Dictionary _activeScopes = new();
@@ -25,30 +24,62 @@ public abstract class StateFactory
///
/// The target leaf state type.
/// The currently active state chain for reuse comparison.
- /// The new state chain ordered root-to-leaf.
+ ///
+ /// A newly allocated list holding the state chain ordered root-to-leaf. The factory keeps no
+ /// reference to it, so the caller may hold it across further factory calls. Use
+ /// to fill a pooled list instead.
+ ///
public IReadOnlyList CreateState(IReadOnlyList activeStates)
where TState : IState
{
- _chainBuffer.Clear();
- BuildChain(typeof(TState), activeStates, activeStates.Count - 1);
- return _chainBuffer;
+ var chain = new List(capacity: 4);
+ CreateState(typeof(TState), activeStates, chain);
+ return chain;
}
- private void BuildChain(Type type, IReadOnlyList activeStates, int index)
+ ///
+ /// The target leaf state type.
+ /// The currently active state chain for reuse comparison.
+ public IReadOnlyList CreateState(Type leafType, IReadOnlyList activeStates)
+ {
+ var chain = new List(capacity: 4);
+ CreateState(leafType, activeStates, chain);
+ return chain;
+ }
+
+ ///
+ /// Allocation-free variant of that clears
+ /// and fills it with the root-to-leaf chain.
+ ///
+ ///
+ /// The factory holds no reference to beyond this call, so a caller that
+ /// re-enters the factory while still iterating a previously filled list is safe as long as it passes a
+ /// different list each time. StateMachineBase rents one per in-flight transition for exactly this reason.
+ ///
+ /// The target leaf state type.
+ /// The currently active state chain for reuse comparison.
+ /// The list to fill. Cleared before use.
+ public void CreateState(Type leafType, IReadOnlyList activeStates, List destination)
+ {
+ destination.Clear();
+ BuildChain(leafType, activeStates, activeStates.Count - 1, destination);
+ }
+
+ private void BuildChain(Type type, IReadOnlyList activeStates, int index, List destination)
{
if (index >= 0 && type == activeStates[index].GetType())
{
for (var i = 0; i <= index; i++)
- _chainBuffer.Add(activeStates[i]);
+ destination.Add(activeStates[i]);
return;
}
var state = CreateStateInternal(type);
if (state is IChildState childState)
- BuildChain(childState.ParentState, activeStates, index - 1);
+ BuildChain(childState.ParentState, activeStates, index - 1, destination);
- _chainBuffer.Add(state);
+ destination.Add(state);
}
///
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachine.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachine.cs
index 7170956..89986b6 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachine.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachine.cs
@@ -127,6 +127,20 @@ protected virtual void OnFixedUpdating() { }
protected virtual void OnFixedUpdated() { }
#endregion
+ #region Extension Points
+ ///
+ protected virtual bool IsControllerEnabled(IController controller, IState state) => true;
+
+ ///
+ protected virtual bool IsStateEnabled(Type stateType) => true;
+
+ ///
+ protected virtual bool IsTransitionEnabled(Type sourceType, Type targetType) => true;
+
+ ///
+ protected virtual bool StrictTransitions => false;
+ #endregion
+
#region ChangeState hooks
///
protected virtual void OnChangingState() { }
@@ -161,6 +175,10 @@ protected virtual void Disposing() { }
protected virtual void Disposed() { }
#endregion
+ internal bool RaiseIsControllerEnabled(IController controller, IState state) => IsControllerEnabled(controller, state);
+ internal bool RaiseIsStateEnabled(Type stateType) => IsStateEnabled(stateType);
+ internal bool RaiseIsTransitionEnabled(Type sourceType, Type targetType) => IsTransitionEnabled(sourceType, targetType);
+ internal bool RaiseStrictTransitions() => StrictTransitions;
internal void RaiseChangingState() => OnChangingState();
internal void RaiseChangedState() => OnChangedState();
internal void RaiseEnteringState(IState state) => OnEnteringState(state);
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachineCore.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachineCore.cs
index f67741a..e439a8b 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachineCore.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/MonoStateMachineCore.cs
@@ -1,3 +1,5 @@
+using System;
+
// ReSharper disable once CheckNamespace
namespace Aspid.Core.HSM
{
@@ -17,6 +19,17 @@ public MonoStateMachineCore(MonoStateMachine owner, StateFactory factory)
public void InvokeFixedUpdate(float deltaTime) => FixedUpdate(deltaTime);
+ protected override bool IsControllerEnabled(IController controller, IState state) =>
+ _owner.RaiseIsControllerEnabled(controller, state);
+
+ protected override bool IsStateEnabled(Type stateType) =>
+ _owner.RaiseIsStateEnabled(stateType);
+
+ protected override bool IsTransitionEnabled(Type sourceType, Type targetType) =>
+ _owner.RaiseIsTransitionEnabled(sourceType, targetType);
+
+ protected override bool StrictTransitions => _owner.RaiseStrictTransitions();
+
protected override void OnChangingState() => _owner.RaiseChangingState();
protected override void OnChangedState() => _owner.RaiseChangedState();
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Async.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Async.cs
index 3f4c2aa..2871a6e 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Async.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Async.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading;
using Cysharp.Threading.Tasks;
@@ -11,6 +12,11 @@ public partial class StateMachineBase
// the superseder. default(UniTask) is an already-completed task, so no null check is needed.
private UniTask _activeTransitionTask;
+ // True only while the async core is running its exit/enter callbacks. A transition started from
+ // inside one of those callbacks would await the very task it is running on, so it is rejected
+ // with a diagnosable exception instead of deadlocking.
+ private bool _isChangingStateAsync;
+
///
/// Asynchronously transitions to . Cancels any in-progress
/// async transition and waits for it to unwind before mutating the state chain, so two
@@ -20,10 +26,26 @@ public partial class StateMachineBase
///
/// The target leaf state type.
/// Cancellation token for the transition.
- public async UniTask ChangeStateAsync(CancellationToken cancellationToken = default)
- where TState : IState
+ public UniTask ChangeStateAsync(CancellationToken cancellationToken = default)
+ where TState : IState =>
+ ChangeStateAsync(typeof(TState), cancellationToken);
+
+ ///
+ /// The target leaf state type.
+ /// Cancellation token for the transition.
+ ///
+ /// Called from inside an async enter/exit callback of the transition already running.
+ ///
+ public async UniTask ChangeStateAsync(Type stateType, CancellationToken cancellationToken = default)
{
- if (!IsStateEnabled(typeof(TState)))
+ if (_isChangingStateAsync)
+ throw new InvalidOperationException(
+ "ChangeStateAsync was called from inside the async enter/exit callbacks of the transition " +
+ "that is currently running. Awaiting it would deadlock, because the running transition " +
+ "cannot unwind until this call returns. Start the follow-up transition after the current " +
+ "one completes, or use the synchronous ChangeState, which queues re-entrant requests.");
+
+ if (!IsStateEnabled(stateType))
return;
var previous = _activeTransitionCts;
@@ -36,9 +58,14 @@ public async UniTask ChangeStateAsync(CancellationToken cancellationToke
catch { /* superseded transition's result belongs to its original caller */ }
}
+ // Re-read the leaf only after the superseded transition has unwound: the edge being guarded
+ // is the one actually taken, not the one that was current when this call was made.
+ if (!IsTransitionEnabled(_currentStates[^1].GetType(), stateType))
+ return;
+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_activeTransitionCts = linked;
- var task = ChangeStateCoreAsync(linked.Token).Preserve();
+ var task = ChangeStateCoreAsync(stateType, linked.Token).Preserve();
_activeTransitionTask = task;
try
{
@@ -54,26 +81,37 @@ public async UniTask ChangeStateAsync(CancellationToken cancellationToke
}
}
- private async UniTask ChangeStateCoreAsync(CancellationToken token)
- where TState : IState
+ private async UniTask ChangeStateCoreAsync(Type stateType, CancellationToken token)
{
OnChangingState();
{
- var newChain = _stateFactory.CreateState(_currentStates);
- var divergeIndex = FindDivergeIndex(newChain);
-
- for (var i = _currentStates.Count - 1; i >= divergeIndex; i--)
+ // Rented per in-flight transition: the chain is read across awaits, so a buffer shared
+ // with the factory or with another transition would be rewritten underneath this loop.
+ var newChain = RentChainBuffer();
+ _isChangingStateAsync = true;
+ try
{
- await ExitStateAsync(_currentStates[i], token);
- _currentStates.RemoveAt(i);
- }
+ _stateFactory.CreateState(stateType, _currentStates, newChain);
+ var divergeIndex = FindDivergeIndex(newChain);
- for (var i = divergeIndex; i < newChain.Count; i++)
+ for (var i = _currentStates.Count - 1; i >= divergeIndex; i--)
+ {
+ await ExitStateAsync(_currentStates[i], token);
+ _currentStates.RemoveAt(i);
+ }
+
+ for (var i = divergeIndex; i < newChain.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var state = newChain[i];
+ _currentStates.Add(state);
+ await EnterStateAsync(state, token);
+ }
+ }
+ finally
{
- token.ThrowIfCancellationRequested();
- var state = newChain[i];
- _currentStates.Add(state);
- await EnterStateAsync(state, token);
+ _isChangingStateAsync = false;
+ ReturnChainBuffer(newChain);
}
}
OnChangedState();
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Transitions.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Transitions.cs
index 3ed32ae..0e52819 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Transitions.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.Transitions.cs
@@ -36,16 +36,24 @@ public void RegisterTransition(TTransition transition)
#region TransitionTo (sync)
///
- public void TransitionTo() where TTarget : IState
+ public void TransitionTo() where TTarget : IState =>
+ TransitionTo(typeof(TTarget));
+
+ ///
+ /// The target leaf state type.
+ public void TransitionTo(Type targetType)
{
ThrowIfAsyncTransitionInProgress();
+ Request(new PendingRequest(targetType, PendingKind.TransitionTo));
+ }
- if (!IsStateEnabled(typeof(TTarget)))
- return;
-
- var targetType = typeof(TTarget);
+ private void ApplyTransitionTo(Type targetType)
+ {
var currentLeafType = _currentStates[^1].GetType();
+ if (!IsStateEnabled(targetType) || !IsTransitionEnabled(currentLeafType, targetType))
+ return;
+
var transition = ResolveTransition(currentLeafType, targetType);
if (transition is not null)
@@ -54,65 +62,83 @@ public void TransitionTo() where TTarget : IState
return;
transition.OnBeforeTransition();
- ChangeState();
+ ApplyChangeState(targetType);
transition.OnAfterTransition();
+ return;
}
- else
- {
- var chain = ResolveTransitionChain(targetType);
- if (chain is not null)
- {
- foreach (var t in chain)
- {
- if (!t.CanTransition())
- return;
- }
+ var chain = ResolveTransitionChain(targetType, out var isComplete);
- foreach (var t in chain)
- t.OnBeforeTransition();
+ if (!isComplete && StrictTransitions)
+ throw UnregisteredTransition(currentLeafType, targetType);
- ChangeState();
+ if (chain is null)
+ {
+ ApplyChangeState(targetType);
+ return;
+ }
- for (var i = chain.Count - 1; i >= 0; i--)
- chain[i].OnAfterTransition();
- }
- else
- {
- ChangeState();
- }
+ foreach (var t in chain)
+ {
+ if (!t.CanTransition())
+ return;
}
+
+ foreach (var t in chain)
+ t.OnBeforeTransition();
+
+ ApplyChangeState(targetType);
+
+ for (var i = chain.Count - 1; i >= 0; i--)
+ chain[i].OnAfterTransition();
}
#endregion
#region TransitionVia (sync)
///
- public void TransitionVia() where TTransition : ITransition
+ public void TransitionVia() where TTransition : ITransition =>
+ TransitionVia(typeof(TTransition));
+
+ ///
+ /// The registered transition type to execute.
+ public void TransitionVia(Type transitionType)
{
ThrowIfAsyncTransitionInProgress();
+ Request(new PendingRequest(transitionType, PendingKind.TransitionVia));
+ }
- var transition = FindTransitionByType();
+ private void ApplyTransitionVia(Type transitionType)
+ {
+ var transition = FindTransitionByType(transitionType);
+ var currentLeafType = _currentStates[^1].GetType();
- if (!IsStateEnabled(transition.TargetState) || !transition.CanTransition())
+ if (!IsStateEnabled(transition.TargetState) ||
+ !IsTransitionEnabled(currentLeafType, transition.TargetState) ||
+ !transition.CanTransition())
return;
transition.OnBeforeTransition();
- ChangeStateByType(transition.TargetState);
+ ApplyChangeState(transition.TargetState);
transition.OnAfterTransition();
}
#endregion
#region TransitionTo (async)
///
- public async UniTask TransitionToAsync(CancellationToken ct = default)
- where TTarget : IState
+ public UniTask TransitionToAsync(CancellationToken ct = default)
+ where TTarget : IState =>
+ TransitionToAsync(typeof(TTarget), ct);
+
+ ///
+ /// The target leaf state type.
+ /// Cancellation token for the transition.
+ public async UniTask TransitionToAsync(Type targetType, CancellationToken ct = default)
{
- if (!IsStateEnabled(typeof(TTarget)))
- return;
-
- var targetType = typeof(TTarget);
var currentLeafType = _currentStates[^1].GetType();
+ if (!IsStateEnabled(targetType) || !IsTransitionEnabled(currentLeafType, targetType))
+ return;
+
var transition = ResolveTransition(currentLeafType, targetType);
if (transition is not null)
@@ -121,49 +147,59 @@ public async UniTask TransitionToAsync(CancellationToken ct = default)
return;
transition.OnBeforeTransition();
- await ChangeStateAsync(ct);
+ await ChangeStateAsync(targetType, ct);
transition.OnAfterTransition();
+ return;
}
- else
- {
- var chain = ResolveTransitionChain(targetType);
- if (chain is not null)
- {
- foreach (var t in chain)
- {
- if (!t.CanTransition())
- return;
- }
+ var chain = ResolveTransitionChain(targetType, out var isComplete);
- foreach (var t in chain)
- t.OnBeforeTransition();
+ if (!isComplete && StrictTransitions)
+ throw UnregisteredTransition(currentLeafType, targetType);
- await ChangeStateAsync(ct);
+ if (chain is null)
+ {
+ await ChangeStateAsync(targetType, ct);
+ return;
+ }
- for (var i = chain.Count - 1; i >= 0; i--)
- chain[i].OnAfterTransition();
- }
- else
- {
- await ChangeStateAsync(ct);
- }
+ foreach (var t in chain)
+ {
+ if (!t.CanTransition())
+ return;
}
+
+ foreach (var t in chain)
+ t.OnBeforeTransition();
+
+ await ChangeStateAsync(targetType, ct);
+
+ for (var i = chain.Count - 1; i >= 0; i--)
+ chain[i].OnAfterTransition();
}
#endregion
#region TransitionVia (async)
///
- public async UniTask TransitionViaAsync(CancellationToken ct = default)
- where TTransition : ITransition
+ public UniTask TransitionViaAsync(CancellationToken ct = default)
+ where TTransition : ITransition =>
+ TransitionViaAsync(typeof(TTransition), ct);
+
+ ///
+ /// The registered transition type to execute.
+ /// Cancellation token for the transition.
+ public async UniTask TransitionViaAsync(Type transitionType, CancellationToken ct = default)
{
- var transition = FindTransitionByType();
+ var transition = FindTransitionByType(transitionType);
+ var currentLeafType = _currentStates[^1].GetType();
- if (!IsStateEnabled(transition.TargetState) || !transition.CanTransition())
+ if (!IsStateEnabled(transition.TargetState) ||
+ !IsTransitionEnabled(currentLeafType, transition.TargetState) ||
+ !transition.CanTransition())
return;
transition.OnBeforeTransition();
- await ChangeStateAsyncByType(transition.TargetState, ct);
+ await ChangeStateAsync(transition.TargetState, ct);
transition.OnAfterTransition();
}
#endregion
@@ -182,10 +218,8 @@ public async UniTask TransitionViaAsync(CancellationToken ct = defa
return _transitionRegistry.TryGetValue(key, out var transition) ? transition : null;
}
- private ITransition FindTransitionByType() where TTransition : ITransition
+ private ITransition FindTransitionByType(Type transitionType)
{
- var transitionType = typeof(TTransition);
-
foreach (var transition in _transitionRegistry.Values)
{
if (transition.GetType() == transitionType)
@@ -195,13 +229,29 @@ private ITransition FindTransitionByType() where TTransition : ITra
throw new InvalidOperationException(
$"Transition of type '{transitionType.Name}' is not registered.");
}
+
+ private static InvalidOperationException UnregisteredTransition(Type sourceType, Type targetType) =>
+ new($"No registered transition covers the full path from '{sourceType.Name}' to '{targetType.Name}'. " +
+ $"{nameof(StrictTransitions)} is enabled, so the transition registry declares which edges are legal. " +
+ $"Register a transition for the missing edge, or call ChangeState({targetType.Name}) to bypass the registry.");
#endregion
#region Chain Resolution
private readonly List _targetTypeChainBuffer = new(capacity: 4);
+ private readonly List _pathTypeBuffer = new(capacity: 8);
- private List? ResolveTransitionChain(Type targetType)
+ ///
+ /// Collects the registered transitions covering the exit/enter path to .
+ ///
+ /// The target leaf state type.
+ ///
+ /// true when a transition was found for every step of the path — which is what
+ /// requires. A partially covered path reports false while still
+ /// returning the transitions that were found, preserving the permissive default behaviour.
+ ///
+ /// The transitions found along the path, or null if none were.
+ private List? ResolveTransitionChain(Type targetType, out bool isComplete)
{
// Build the target type chain by walking IChildState.ParentState
_targetTypeChainBuffer.Clear();
@@ -222,7 +272,8 @@ private ITransition FindTransitionByType() where TTransition : ITra
}
// Build the traversal path: exiting states (leaf→diverge), then entering states (diverge→leaf)
- var pathTypes = new List();
+ var pathTypes = _pathTypeBuffer;
+ pathTypes.Clear();
for (var i = currentCount - 1; i >= divergeIndex; i--)
pathTypes.Add(_currentStates[i].GetType());
@@ -231,18 +282,22 @@ private ITransition FindTransitionByType() where TTransition : ITra
pathTypes.Add(_targetTypeChainBuffer[i]);
// Look up transitions for each consecutive pair
+ var requiredSegments = Math.Max(0, pathTypes.Count - 1);
+ var foundSegments = 0;
List? chain = null;
- for (var i = 0; i < pathTypes.Count - 1; i++)
+ for (var i = 0; i < requiredSegments; i++)
{
var key = (pathTypes[i], pathTypes[i + 1]);
if (_transitionRegistry.TryGetValue(key, out var segmentTransition))
{
chain ??= new List();
chain.Add(segmentTransition);
+ foundSegments++;
}
}
+ isComplete = foundSegments == requiredSegments;
return chain;
}
@@ -284,20 +339,6 @@ private void ThrowIfAsyncTransitionInProgress()
throw new InvalidOperationException(
"An asynchronous transition is in progress. Use the async transition methods or wait for it to complete.");
}
-
- private void ChangeStateByType(Type targetStateType)
- {
- var method = typeof(StateMachineBase).GetMethod(nameof(ChangeState))!
- .MakeGenericMethod(targetStateType);
- method.Invoke(this, null);
- }
-
- private UniTask ChangeStateAsyncByType(Type targetStateType, CancellationToken ct)
- {
- var method = typeof(StateMachineBase).GetMethod(nameof(ChangeStateAsync))!
- .MakeGenericMethod(targetStateType);
- return (UniTask)method.Invoke(this, new object[] { ct })!;
- }
#endregion
}
}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.cs b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.cs
index 0c0457e..f701009 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.cs
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/Unity/Runtime/StateMachines/StateMachineBase.cs
@@ -15,6 +15,10 @@ public partial class StateMachineBase : IStateMachine, IDisposable
private readonly StateFactory _stateFactory;
private readonly List _currentStates = new(capacity: 1);
+ private readonly Stack> _chainBufferPool = new();
+ private readonly Queue _pendingRequests = new();
+ private bool _isChangingState;
+
private CancellationTokenSource? _activeTransitionCts;
///
@@ -93,31 +97,51 @@ protected void FixedUpdate(float deltaTime)
///
/// An async transition is already in progress.
public void ChangeState()
- where TState : IState
+ where TState : IState =>
+ ChangeState(typeof(TState));
+
+ ///
+ /// The target leaf state type.
+ public void ChangeState(Type stateType)
{
- if (!IsStateEnabled(typeof(TState)))
+ ThrowIfAsyncTransitionInProgress();
+ Request(new PendingRequest(stateType, PendingKind.ChangeState));
+ }
+
+ private void ApplyChangeState(Type stateType)
+ {
+ if (!IsStateEnabled(stateType))
return;
- if (_activeTransitionCts is not null)
- throw new InvalidOperationException(
- "An asynchronous transition is in progress. Use ChangeStateAsync or wait for it to complete.");
+ if (!IsTransitionEnabled(_currentStates[^1].GetType(), stateType))
+ return;
OnChangingState();
{
- var newChain = _stateFactory.CreateState(_currentStates);
- var divergeIndex = FindDivergeIndex(newChain);
-
- for (var i = _currentStates.Count - 1; i >= divergeIndex; i--)
+ // Rented per in-flight transition: the chain must stay valid across Enter/Exit callbacks,
+ // which are free to re-enter the factory. A shared buffer would be rewritten underneath us.
+ var newChain = RentChainBuffer();
+ try
{
- ExitState(_currentStates[i]);
- _currentStates.RemoveAt(i);
- }
+ _stateFactory.CreateState(stateType, _currentStates, newChain);
+ var divergeIndex = FindDivergeIndex(newChain);
+
+ for (var i = _currentStates.Count - 1; i >= divergeIndex; i--)
+ {
+ ExitState(_currentStates[i]);
+ _currentStates.RemoveAt(i);
+ }
- for (var i = divergeIndex; i < newChain.Count; i++)
+ for (var i = divergeIndex; i < newChain.Count; i++)
+ {
+ var state = newChain[i];
+ _currentStates.Add(state);
+ EnterState(state);
+ }
+ }
+ finally
{
- var state = newChain[i];
- _currentStates.Add(state);
- EnterState(state);
+ ReturnChainBuffer(newChain);
}
}
OnChangedState();
@@ -147,6 +171,86 @@ protected virtual void OnChangingState() { }
protected virtual void OnChangedState() { }
#endregion
+ #region Run-to-completion
+ private enum PendingKind
+ {
+ ChangeState,
+ TransitionTo,
+ TransitionVia,
+ }
+
+ private readonly struct PendingRequest
+ {
+ public readonly Type Type;
+ public readonly PendingKind Kind;
+
+ public PendingRequest(Type type, PendingKind kind)
+ {
+ Type = type;
+ Kind = kind;
+ }
+ }
+
+ ///
+ /// Entry gate for every synchronous state change. A request made while another one is still
+ /// running — the usual case being a state that redirects from its own
+ /// or — is queued and applied once the running change
+ /// completes, rather than mutating the chain underneath it (run-to-completion semantics).
+ ///
+ private void Request(PendingRequest request)
+ {
+ if (_isChangingState)
+ {
+ _pendingRequests.Enqueue(request);
+ return;
+ }
+
+ _isChangingState = true;
+ try
+ {
+ Apply(request);
+
+ // Requests queued by Enter/Exit callbacks are drained here, in the order they were made.
+ // Guards are resolved at apply time, so each one sees the chain the previous one left behind.
+ while (_pendingRequests.Count > 0)
+ Apply(_pendingRequests.Dequeue());
+ }
+ finally
+ {
+ _isChangingState = false;
+ // A throwing state must not leak its queued follow-ups into an unrelated later transition.
+ _pendingRequests.Clear();
+ }
+ }
+
+ private void Apply(PendingRequest request)
+ {
+ switch (request.Kind)
+ {
+ case PendingKind.ChangeState:
+ ApplyChangeState(request.Type);
+ break;
+
+ case PendingKind.TransitionTo:
+ ApplyTransitionTo(request.Type);
+ break;
+
+ case PendingKind.TransitionVia:
+ ApplyTransitionVia(request.Type);
+ break;
+ }
+ }
+
+ private List RentChainBuffer() =>
+ _chainBufferPool.Count > 0 ? _chainBufferPool.Pop() : new List(capacity: 4);
+
+ private void ReturnChainBuffer(List buffer)
+ {
+ buffer.Clear();
+ _chainBufferPool.Push(buffer);
+ }
+ #endregion
+
#region Exit
private void ExitState(IState state)
{
@@ -240,12 +344,44 @@ protected virtual void Disposed() { }
protected virtual bool IsControllerEnabled(IController controller, IState state) => true;
///
- /// Determines whether a state change to is allowed.
- /// Override to block transitions conditionally.
+ /// Determines whether a state change to is allowed, regardless of
+ /// where it is coming from. Override to block entering a state conditionally.
///
+ ///
+ /// This is a node-level guard. To allow or deny a specific edge, override
+ /// , which also receives the source state type.
+ ///
/// The target state type.
/// true to allow the state change; false to silently suppress it.
protected virtual bool IsStateEnabled(Type stateType) => true;
+
+ ///
+ /// Determines whether moving from to
+ /// is allowed. Every state change funnels through this guard, including
+ /// , so it is the single point at which edge legality can be enforced.
+ ///
+ /// The current leaf state type the machine is moving away from.
+ /// The target leaf state type.
+ /// true to allow the state change; false to silently suppress it.
+ protected virtual bool IsTransitionEnabled(Type sourceType, Type targetType) => true;
+
+ ///
+ /// Controls whether a registered is required for
+ /// and .
+ ///
+ ///
+ ///
+ /// Default is false: an unregistered target is still entered, so [Transition] declares
+ /// guards and hooks for edges that have them and says nothing about edges that do not.
+ ///
+ ///
+ /// Override to true to treat the transition registry as the declaration of the allowed
+ /// edges. A target with no registered transition covering every step of the path then throws
+ /// instead of silently succeeding.
+ /// remains the deliberate escape hatch and is never subject to this check.
+ ///
+ ///
+ protected virtual bool StrictTransitions => false;
#endregion
}
}
diff --git a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/package.json b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/package.json
index 483c598..8a6b42b 100644
--- a/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/package.json
+++ b/Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/package.json
@@ -1,8 +1,10 @@
{
"name": "com.aspid.core.hsm",
- "version": "0.0.1-alpha.1",
+ "version": "0.0.1-alpha.2",
"displayName": "Aspid.Core.HSM",
+ "description": "Roslyn-powered Hierarchical State Machine for Unity, built from small composable abstractions: states with Enter/Exit hooks, a parent-to-child hierarchy, pluggable per-frame controllers, declarative guarded transitions, extension states, state scopes and async enter/exit.",
"unity": "2022.3",
+ "license": "MIT",
"author":
{
"name": "Vladislav Panin",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 037cede..a582afe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.0.1-alpha.2] — 2026-08-10
+
+Consumer-reported fixes from the first preview: the package is now legally installable, honest about its
+dependency, and no longer corrupts its state chain when a state redirects from its own `Enter`.
+
+### Added
+
+- **MIT license.** `LICENSE` at the repository root, `LICENSE.md` inside the package, and a `license` field in `package.json`. The first preview shipped without any of these, which by default reserves all rights and blocks shipping a game built on the package.
+- `StateMachineBase.IsTransitionEnabled(Type sourceType, Type targetType)` — an edge-level guard receiving both endpoints, complementing the node-level `IsStateEnabled(Type)`. Every state change funnels through it, `ChangeState` included, so it is a usable single point for enforcing edge legality.
+- `StateMachineBase.StrictTransitions` — opt-in strict mode. When enabled, `TransitionTo` / `TransitionToAsync` require a registered `ITransition` covering **every** step of the path and throw `InvalidOperationException` naming the missing edge instead of transitioning anyway. `ChangeState` stays outside the check as the deliberate escape hatch.
+- `MonoStateMachine` now exposes `IsStateEnabled`, `IsControllerEnabled`, `IsTransitionEnabled` and `StrictTransitions` as `protected virtual` members and forwards them to the internal core. Previously these existed only on `StateMachineBase`, which `MonoStateMachine` composes rather than inherits, so a subclass could not override them at all (CS0115).
+- Non-generic `ChangeState(Type)`, `ChangeStateAsync(Type, …)`, `TransitionTo(Type)`, `TransitionVia(Type)`, `TransitionToAsync(Type, …)` and `TransitionViaAsync(Type, …)` overloads, plus `StateFactory.CreateState(Type, …)`.
+
+### Fixed
+
+- **`StateFactory.CreateState` handed out its own chain buffer.** The returned `IReadOnlyList` was a live reference to a factory field that the next call cleared and rewrote. A `ChangeState` issued from a state's `Enter` / `IEnterController.OnEnter` therefore rewrote the chain the outer `ChangeState` was still iterating, entering states twice and leaving duplicates in `CurrentStates`. Affected the async path as well, where the chain is held across `await`. `CreateState` now returns a list the caller owns, and the machine rents a separate buffer per in-flight transition.
+- **Re-entrant `ChangeState` now runs to completion.** A state change requested while another one is still applying is queued and performed once the running change finishes, rather than mutating the chain underneath it. Guards are re-resolved at apply time, so each queued request sees the chain the previous one left behind.
+- **A partially registered transition path was treated as a fully registered one.** `TransitionTo` collected whatever segment transitions happened to exist and proceeded; under `StrictTransitions` a partial path is now rejected. Permissive (default) behaviour is unchanged.
+- **`ChangeStateAsync` called from an async enter/exit callback deadlocked**, waiting on the very transition it was running inside. It now throws `InvalidOperationException` explaining the situation.
+- **The `Game Loop` sample never reached the published package.** `package.json` advertised `Samples~/GameLoop`, but a `*~` pattern in a contributor's global gitignore matched the `Samples~` directory itself, so it was absent from every commit and from the `git subtree split` the release workflow publishes. The repository `.gitignore` now re-includes `~`-suffixed directories.
+
+### Changed
+
+- `TransitionVia` and the async transition entry points no longer dispatch through `MethodInfo.Invoke`; the reflection-based generic dispatch was replaced by the new `Type`-based overloads.
+- `ChangeState` now rejects a call made during an async transition before consulting `IsStateEnabled`, so an in-flight async transition throws regardless of the target. Previously a target that `IsStateEnabled` refused returned silently instead.
+- README no longer claims UniTask is "pulled in automatically as a package dependency" — UPM does not resolve git dependencies transitively, so it never was. Installation now documents UniTask as an explicit first step with a pinned git URL.
+- README no longer documents the `upm` branch and stable install URL as if they existed; they appear when the first non-prerelease version ships.
+
## [0.0.1-alpha.1] — 2026-07-08
Initial preview release of **Aspid.Core.HSM** — a Roslyn-powered Hierarchical State Machine for Unity 2022.3+, distributed as the UPM package `com.aspid.core.hsm`. The public API and generated boilerplate may still change before the first stable release.
@@ -42,5 +70,6 @@ Three Roslyn incremental generators, each triggered via an attribute on a `parti
- Dependency on [UniTask](https://github.com/Cysharp/UniTask) for the async enter/exit controllers.
- **Game Loop** sample: a full state hierarchy with guarded transitions, async loading, extensions, scopes and extension points.
-[Unreleased]: https://github.com/VPDPersonal/Aspid.Core.HSM/compare/v0.0.1-alpha.1...HEAD
+[Unreleased]: https://github.com/VPDPersonal/Aspid.Core.HSM/compare/v0.0.1-alpha.2...HEAD
+[0.0.1-alpha.2]: https://github.com/VPDPersonal/Aspid.Core.HSM/compare/v0.0.1-alpha.1...v0.0.1-alpha.2
[0.0.1-alpha.1]: https://github.com/VPDPersonal/Aspid.Core.HSM/releases/tag/v0.0.1-alpha.1
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5d9d908
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025-2026 Vladislav Panin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 1e9ab95..1ae9c02 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-
+
> [!WARNING]
@@ -13,43 +13,34 @@
## Integration
-Install Aspid.Core.HSM via UPM: in the Package Manager click **+ → Install package from git URL…** and paste one of the URLs below.
+Aspid.Core.HSM needs two packages installed: [UniTask](https://github.com/Cysharp/UniTask) first, then the HSM itself. In the Package Manager click **+ → Install package from git URL…** and paste each URL below.
-### Stable
+### 1. UniTask
-The `upm` branch always points to the latest **stable** release:
+The async enter/exit controllers are built on UniTask, so the package does not compile without it.
```
-https://github.com/VPDPersonal/Aspid.Core.HSM.git#upm
+https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.11
```
-To install a specific version, target the immutable per-release tag (see [Releases](https://github.com/VPDPersonal/Aspid.Core.HSM/releases) for the list of available versions):
+> **Why this is a manual step.** UPM does not resolve git dependencies transitively — a `dependencies` entry pointing at a git URL is simply ignored for anyone installing this package. Declaring UniTask in the manifest would therefore not install it, so it is installed explicitly instead. If you already get UniTask from the [OpenUPM](https://openupm.com/packages/com.cysharp.unitask/) scoped registry, that installation works just as well; nothing here depends on how UniTask arrived.
-```
-https://github.com/VPDPersonal/Aspid.Core.HSM.git#upm/0.0.1
-```
-
-
-Preview
+### 2. Aspid.Core.HSM
-
-
-The `upm-preview` branch always points to the latest **preview** release (rc, beta, alpha, …):
+The `upm-preview` branch always points to the latest **preview** release (alpha, beta, rc, …):
```
https://github.com/VPDPersonal/Aspid.Core.HSM.git#upm-preview
```
-To install a specific preview version, target the immutable per-release tag (see [Releases](https://github.com/VPDPersonal/Aspid.Core.HSM/releases) for the list of available versions):
+To pin a specific preview version, target the immutable per-release tag (see [Releases](https://github.com/VPDPersonal/Aspid.Core.HSM/releases) for the list of available versions):
```
-https://github.com/VPDPersonal/Aspid.Core.HSM.git#upm-preview/0.0.1-rc.1
+https://github.com/VPDPersonal/Aspid.Core.HSM.git#upm-preview/0.0.1-alpha.2
```
-
-
-> **Note.** The `upm` / `upm-preview` branches and their badges appear once the [Release workflow](.github/workflows/release.yml) publishes the first stable / preview version.
+> **Note.** There is no stable release yet, so the `upm` branch does not exist. The [Release workflow](.github/workflows/release.yml) creates it — along with `upm/` tags and a `#upm` install URL — when the first non-prerelease version ships.
-## Dependency
+## License
-Aspid.Core.HSM depends on [UniTask](https://github.com/Cysharp/UniTask) (for async enter/exit controllers), pulled in automatically as a package dependency.
+[MIT](LICENSE).