diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml
new file mode 100644
index 0000000..aa7fa47
--- /dev/null
+++ b/.github/workflows/unity-tests.yml
@@ -0,0 +1,72 @@
+name: Unity Tests
+
+on:
+ push:
+ branches: [ main, develop, feature/** ]
+ pull_request:
+ branches: [ main, develop ]
+
+jobs:
+ test:
+ name: Run Unity Tests
+ runs-on: self-hosted
+
+ steps:
+ # Checkout repository
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ lfs: true
+
+ # Run EditMode tests using local Unity installation
+ - name: Run EditMode Tests
+ run: |
+ & "C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" `
+ -batchmode `
+ -nographics `
+ -silent-crashes `
+ -logFile - `
+ -projectPath "${{ github.workspace }}" `
+ -runTests `
+ -testPlatform EditMode `
+ -testResults "${{ github.workspace }}\test-results\editmode\results.xml"
+ shell: powershell
+
+ # Run PlayMode tests using local Unity installation
+ - name: Run PlayMode Tests
+ run: |
+ & "C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" `
+ -batchmode `
+ -nographics `
+ -silent-crashes `
+ -logFile - `
+ -projectPath "${{ github.workspace }}" `
+ -runTests `
+ -testPlatform PlayMode `
+ -testResults "${{ github.workspace }}\test-results\playmode\results.xml"
+ shell: powershell
+
+ # Upload test results as artifacts
+ - name: Upload EditMode Test Results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: EditMode-Test-Results
+ path: test-results/editmode
+
+ - name: Upload PlayMode Test Results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: PlayMode-Test-Results
+ path: test-results/playmode
+
+ # Report test results in PR
+ - name: Comment Test Results on PR
+ if: github.event_name == 'pull_request' && always()
+ uses: dorny/test-reporter@v1
+ with:
+ name: Unity Test Results
+ path: 'test-results/**/*.xml'
+ reporter: java-junit
+ fail-on-error: true
diff --git a/Assets/Scripts/Core/RingBuffer.cs b/Assets/Scripts/Core/RingBuffer.cs
index d28a0ba..c79cecd 100644
--- a/Assets/Scripts/Core/RingBuffer.cs
+++ b/Assets/Scripts/Core/RingBuffer.cs
@@ -54,6 +54,11 @@ public bool Contains(uint tick)
return slot.isValid && slot.tick == tick;
}
+ ///
+ /// Expose capacity for validation and bounds checks.
+ ///
+ public uint Capacity => _capacity;
+
///
/// Invalidate range of ticks (inclusive).
/// Used during hard snap to clear diverged speculative data.
diff --git a/Assets/Scripts/Entities/ClientEntity.cs b/Assets/Scripts/Entities/ClientEntity.cs
new file mode 100644
index 0000000..e397664
--- /dev/null
+++ b/Assets/Scripts/Entities/ClientEntity.cs
@@ -0,0 +1,170 @@
+using System;
+using UnityEngine;
+using DeterministicRollback.Core;
+using DeterministicRollback.Networking;
+
+namespace DeterministicRollback.Entities
+{
+ ///
+ /// Non-Mono-client simulation core. Designed to be testable in EditMode (constructed with `new`).
+ /// Runs a fixed-step simulation at 60Hz using a time accumulator pattern.
+ ///
+ ///
+ /// Testable client-side simulation core. Runs a fixed-step 60Hz simulation using an accumulator.
+ /// Handles input capture, batch sending, deterministic integration and StateBuffer writes.
+ ///
+ public class ClientEntity
+ {
+ public const int MAX_TICKS_PER_FRAME = 10;
+ public const float FIXED_DELTA_TIME = 1f / 60f;
+
+ // Tick counter: number of ticks simulated so far. Starts at 0 (spawn state at tick 0).
+ public uint CurrentTick { get; private set; }
+
+ // Buffers (pre-allocated ring buffers)
+ public RingBuffer InputBuffer { get; private set; }
+ public RingBuffer StateBuffer { get; private set; }
+
+ // Batch container reused every tick (value-type snapshot when sent)
+ private InputBatch _batchContainer;
+
+ private float _timer = 0f;
+ public int ticksProcessed = 0;
+
+ // Network parameters (configurable from wrapper or tests)
+ public float latencyMs = 0f;
+ public float lossChance = 0f; // 0.0 .. 1.0
+
+ // Input provider (in tests or wrapper set to supply input vector): returns Vector2 for current tick
+ public Func InputProvider = () => Vector2.zero;
+
+ public ClientEntity()
+ {
+ Initialize();
+ }
+
+ ///
+ /// Initialize buffers and spawn state. Safe to call multiple times to reset client state.
+ ///
+ public void Initialize()
+ {
+ InputBuffer = new RingBuffer(4096);
+ StateBuffer = new RingBuffer(4096);
+
+ var spawnState = new StatePayload
+ {
+ tick = 0,
+ position = Vector2.zero,
+ velocity = Vector2.zero,
+ confirmedInputTick = 0
+ };
+
+ StateBuffer[0] = spawnState;
+ CurrentTick = 0; // nothing simulated yet; tick 0 is spawn
+ _batchContainer = new InputBatch();
+ _timer = 0f;
+ ticksProcessed = 0;
+ }
+
+ ///
+ /// Main update to be called every frame. Accumulates Time.deltaTime and simulates fixed ticks.
+ /// Designed to be called from both tests (EditMode) and a MonoBehaviour wrapper in PlayMode.
+ ///
+ ///
+ /// Advance simulation by consuming accumulated time (call every frame).
+ /// Runs up to ticks to prevent spiral-of-death.
+ /// This method is a hot path and must not allocate GC memory per tick.
+ ///
+ ///
+ /// Advance simulation by consuming accumulated time (call every frame).
+ /// Runs up to ticks to prevent spiral-of-death.
+ /// This method is a hot path and must not allocate GC memory per tick.
+ ///
+ public void Update()
+ {
+ UpdateWithDelta(Time.deltaTime);
+ }
+
+ ///
+ /// Advance simulation using an explicit deltaTime. Useful for deterministic EditMode tests.
+ ///
+ public void UpdateWithDelta(float deltaTime)
+ {
+ _timer += deltaTime;
+
+ // Compute available whole ticks deterministically using floor on double to avoid
+ // round-off errors that can drop one tick in edge cases. Use a small absolute
+ // epsilon large enough to cover float division underflow without affecting
+ // normal fractional behavior.
+ const double EPS = 1e-6; // 1 microsecond
+ int availableTicks = (int)Math.Floor(((double)_timer + EPS) / FIXED_DELTA_TIME);
+ int toProcess = Math.Min(availableTicks, MAX_TICKS_PER_FRAME);
+
+ ticksProcessed = 0;
+
+ for (int i = 0; i < toProcess; i++)
+ {
+ uint nextTick = CurrentTick + 1; // tick to simulate
+
+ // Capture input
+ var input = new InputPayload
+ {
+ tick = nextTick,
+ inputVector = InputProvider()
+ };
+
+ // Always write input for this tick (even if zero)
+ InputBuffer[nextTick] = input;
+
+ // Build redundant batch (1-3 inputs)
+ int batchSize = (int)Math.Min(3, (float)nextTick);
+ _batchContainer.count = batchSize;
+ if (batchSize >= 1) _batchContainer.i0 = InputBuffer[nextTick];
+ if (batchSize >= 2) _batchContainer.i1 = InputBuffer[nextTick - 1];
+ if (batchSize >= 3) _batchContainer.i2 = InputBuffer[nextTick - 2];
+
+ // Send batch (copied by value into list - zero allocations here)
+ FakeNetworkPipe.SendInput(_batchContainer, latencyMs, lossChance);
+
+ // Retrieve previous state (tick 0 for first simulation)
+ StatePayload prev = StateBuffer[nextTick - 1];
+
+ // Integrate - deterministic, pure function
+ SimulationMath.Integrate(ref prev, ref input);
+
+ // Store post-integration state
+ StateBuffer[nextTick] = prev;
+
+ // Advance
+ CurrentTick = nextTick;
+ ticksProcessed++;
+ }
+
+ // Subtract the total processed time in one operation to avoid cumulative float
+ // subtract-rounding artifacts and preserve residual accumulator precisely.
+ _timer -= toProcess * FIXED_DELTA_TIME;
+
+ // If residual is extremely close to zero (due to float rounding), snap to zero so
+ // the next floor calculation does not undercount by one tick.
+ if (_timer < (float)EPS) _timer = 0f;
+
+ if (availableTicks > MAX_TICKS_PER_FRAME)
+ {
+ Debug.LogWarning($"Client simulation spiral: clamped to {MAX_TICKS_PER_FRAME} ticks. Backlog remaining: {_timer:F3}s");
+ }
+ }
+
+ ///
+ /// Helper to access a stored state for assertions in tests.
+ /// Returns default StatePayload if not present.
+ ///
+ ///
+ /// Retrieve recorded post-integration StatePayload for a given tick.
+ /// Returns default(StatePayload) if history does not contain that tick.
+ ///
+ public StatePayload GetState(uint tick)
+ {
+ return StateBuffer.Contains(tick) ? StateBuffer[tick] : default;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Scripts/Entities/ClientEntity.cs.meta b/Assets/Scripts/Entities/ClientEntity.cs.meta
new file mode 100644
index 0000000..a4abc36
--- /dev/null
+++ b/Assets/Scripts/Entities/ClientEntity.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 678de3980a77a744f91577c4d6c43d8d
\ No newline at end of file
diff --git a/Assets/Scripts/Entities/ClientEntityBehaviour.cs b/Assets/Scripts/Entities/ClientEntityBehaviour.cs
new file mode 100644
index 0000000..6f51ec6
--- /dev/null
+++ b/Assets/Scripts/Entities/ClientEntityBehaviour.cs
@@ -0,0 +1,61 @@
+using UnityEngine;
+using DeterministicRollback.Entities;
+
+namespace DeterministicRollback.Behaviours
+{
+ ///
+ /// MonoBehaviour wrapper for ClientEntity. Supplies input and mirrors inspector-configurable network parameters.
+ /// Provides deterministic Auto-Move mode for tests and demo scenarios.
+ ///
+ public class ClientEntityBehaviour : MonoBehaviour
+ {
+ ///
+ /// One-way latency in milliseconds used for FakeNetworkPipe.SendInput()
+ ///
+ public float latencyMs = 0f;
+ [Range(0f, 1f)] public float lossChance = 0f;
+ public bool autoMove = false;
+ private ClientEntity _client;
+
+ ///
+ /// Initialize the underlying ClientEntity and set up input provider.
+ ///
+ void Start()
+ {
+ _client = new ClientEntity();
+ _client.latencyMs = latencyMs;
+ _client.lossChance = lossChance;
+ _client.InputProvider = ReadInput;
+ }
+
+ ///
+ /// Forward Unity Update() to the testable ClientEntity core.
+ /// Keeps runtime-configurable parameters in sync.
+ ///
+ void Update()
+ {
+ // Mirror inspector values if changed at runtime
+ _client.latencyMs = latencyMs;
+ _client.lossChance = lossChance;
+ _client.Update();
+ }
+
+ private Vector2 ReadInput()
+ {
+ if (autoMove)
+ {
+ // Deterministic Auto-Move based on current tick
+ float time = _client.CurrentTick * ClientEntity.FIXED_DELTA_TIME;
+ float angle = 36f * time * Mathf.Deg2Rad;
+ return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
+ }
+
+ Vector2 v = Vector2.zero;
+ if (UnityEngine.Input.GetKey(KeyCode.W)) v.y += 1f;
+ if (UnityEngine.Input.GetKey(KeyCode.S)) v.y -= 1f;
+ if (UnityEngine.Input.GetKey(KeyCode.A)) v.x -= 1f;
+ if (UnityEngine.Input.GetKey(KeyCode.D)) v.x += 1f;
+ return v;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta b/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta
new file mode 100644
index 0000000..9e10fdd
--- /dev/null
+++ b/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e4a7ac0de8a19e5429d1c687d9e33b82
\ No newline at end of file
diff --git a/Assets/Scripts/Entities/ServerEntity.cs b/Assets/Scripts/Entities/ServerEntity.cs
new file mode 100644
index 0000000..e6b3179
--- /dev/null
+++ b/Assets/Scripts/Entities/ServerEntity.cs
@@ -0,0 +1,141 @@
+using System;
+using UnityEngine;
+using DeterministicRollback.Core;
+using DeterministicRollback.Networking;
+
+namespace DeterministicRollback.Entities
+{
+ ///
+ /// Authoritative server simulation core. Runs a fixed-step simulation at 60Hz using an accumulator.
+ /// Testable in EditMode by calling UpdateWithDelta(float).
+ ///
+ public class ServerEntity : MonoBehaviour
+ {
+ public const int MAX_TICKS_PER_FRAME = 10;
+ public const float FIXED_DELTA_TIME = 1f / 60f;
+
+ public uint ServerTick { get; private set; }
+
+ public RingBuffer ServerInputBuffer { get; private set; }
+ public StatePayload CurrentState { get; private set; }
+
+ private float _timer = 0f;
+ public int ticksProcessed = 0;
+
+ public uint lastConfirmedInputTick = 0;
+
+ // Network parameters (configurable from wrapper or tests)
+ public float latencyMs = 0f;
+ public float lossChance = 0f; // 0.0 .. 1.0
+
+ void Awake()
+ {
+ Initialize();
+ }
+
+ public void Initialize()
+ {
+ ServerInputBuffer = new RingBuffer(4096);
+ CurrentState = new StatePayload { tick = 0, position = Vector2.zero, velocity = Vector2.zero, confirmedInputTick = 0 };
+ ServerTick = 1;
+ lastConfirmedInputTick = 0;
+
+ // Ensure subscription is active for tests that call ProcessPackets immediately
+ FakeNetworkPipe.OnInputBatchReceived -= OnInputBatchReceived;
+ FakeNetworkPipe.OnInputBatchReceived += OnInputBatchReceived;
+ }
+
+ void OnEnable()
+ {
+ FakeNetworkPipe.OnInputBatchReceived += OnInputBatchReceived;
+ }
+
+ void OnDisable()
+ {
+ FakeNetworkPipe.OnInputBatchReceived -= OnInputBatchReceived;
+ }
+
+ private void OnInputBatchReceived(InputBatch batch)
+ {
+ for (int i = 0; i < batch.count; i++)
+ {
+ var input = batch.Get(i);
+ // Validate tick range and avoid overwriting newer data
+ if (input.tick >= ServerTick && input.tick < ServerTick + (uint)ServerInputBuffer.Capacity)
+ {
+ if (!ServerInputBuffer.Contains(input.tick))
+ {
+ ServerInputBuffer[input.tick] = input;
+ }
+ lastConfirmedInputTick = Math.Max(lastConfirmedInputTick, input.tick);
+ }
+ }
+ }
+
+ void Update()
+ {
+ UpdateWithDelta(Time.deltaTime);
+ }
+
+ public void UpdateWithDelta(float deltaTime)
+ {
+ _timer += deltaTime;
+
+ const double EPS = 1e-6;
+ int availableTicks = (int)Math.Floor(((double)_timer + EPS) / FIXED_DELTA_TIME);
+ int toProcess = Math.Min(availableTicks, MAX_TICKS_PER_FRAME);
+
+ ticksProcessed = 0;
+
+ for (int i = 0; i < toProcess; i++)
+ {
+ // Retrieve input for this tick or predict
+ InputPayload input;
+ if (ServerInputBuffer.Contains(ServerTick))
+ {
+ input = ServerInputBuffer[ServerTick];
+ }
+ else if (ServerInputBuffer.Contains(ServerTick - 1))
+ {
+ input = ServerInputBuffer[ServerTick - 1]; // repeat last
+ input.tick = ServerTick; // ensure tick matches current simulation
+ }
+ else
+ {
+ input = new InputPayload { tick = ServerTick, inputVector = Vector2.zero };
+ }
+
+ // Integrate
+ var prev = CurrentState;
+ SimulationMath.Integrate(ref prev, ref input);
+
+ // Update current state and piggyback confirmation
+ prev.confirmedInputTick = lastConfirmedInputTick;
+ CurrentState = prev;
+
+ // Send state (unreliable)
+ FakeNetworkPipe.SendState(CurrentState, latencyMs, lossChance);
+
+ // Advance
+ ServerTick++;
+ ticksProcessed++;
+ }
+
+ // Subtract processed time
+ _timer -= toProcess * FIXED_DELTA_TIME;
+
+ if (ticksProcessed >= MAX_TICKS_PER_FRAME)
+ {
+ Debug.LogWarning($"Server simulation spiral: clamped to {MAX_TICKS_PER_FRAME} ticks. Backlog remaining: {_timer:F3}s");
+ }
+ }
+
+ public WelcomePacket GenerateWelcomePacket()
+ {
+ return new WelcomePacket { startTick = ServerTick, startState = CurrentState };
+ }
+
+ // Helper for tests to inspect internal timer (not part of public API)
+ public float GetTimer() => _timer;
+ }
+}
\ No newline at end of file
diff --git a/Assets/Scripts/Entities/ServerEntity.cs.meta b/Assets/Scripts/Entities/ServerEntity.cs.meta
new file mode 100644
index 0000000..fff204f
--- /dev/null
+++ b/Assets/Scripts/Entities/ServerEntity.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6295794d79c702348b7c76d024471bac
\ No newline at end of file
diff --git a/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef b/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef
index b739f53..015eec3 100644
--- a/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef
+++ b/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef
@@ -7,7 +7,8 @@
"GUID:e66b6e0ac2b1fbb4e83d0a5052b69efe",
"GUID:75469ad4d38634e559750d17036d5f7c",
"GUID:419a949562f3e254286b18f50facdc7c",
- "GUID:c04d3943eda9bb04980532b6b11b1fa7"
+ "GUID:c04d3943eda9bb04980532b6b11b1fa7",
+ "DeterministicRollback.Entities"
],
"includePlatforms": [
"Editor"
diff --git a/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs
new file mode 100644
index 0000000..8683277
--- /dev/null
+++ b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs
@@ -0,0 +1,42 @@
+using NUnit.Framework;
+using DeterministicRollback.Core;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ ///
+ /// Phase 1 negative/boundary tests for core data structures.
+ ///
+ public class Phase1EditModeNegativeTests
+ {
+ [Test]
+ public void RingBuffer_Write_OverwriteThenReadOldTick_ThrowsGhostDataException()
+ {
+ var buffer = new RingBuffer(4);
+
+ // Fill capacity
+ for (uint t = 0; t < 4; t++) buffer[t] = (int)t;
+
+ // Overwrite index 0 by writing tick 4 (wrap-around)
+ buffer[4] = 4;
+
+ // Contains should report false for the old tick
+ Assert.IsFalse(buffer.Contains(0));
+
+ // Accessing old tick 0 should throw ghost-data exception
+ Assert.Throws(() => { var v = buffer[0]; });
+ }
+
+ [Test]
+ public void RingBuffer_Contains_ReturnsFalseAfterWrap()
+ {
+ var buffer = new RingBuffer(8);
+
+ // Write two ticks spaced by buffer size to force wrap
+ buffer[5] = 123;
+ Assert.IsTrue(buffer.Contains(5));
+
+ buffer[5 + 8] = 456; // Overwrites slot for tick 5
+ Assert.IsFalse(buffer.Contains(5));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta
new file mode 100644
index 0000000..1755f6e
--- /dev/null
+++ b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d07317abb24ae47428001983bf7b847a
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase1Tests.cs b/Assets/Tests/Editor/Phase1EditModeTests.cs
similarity index 99%
rename from Assets/Tests/Editor/Phase1Tests.cs
rename to Assets/Tests/Editor/Phase1EditModeTests.cs
index a32d2c9..88bc7de 100644
--- a/Assets/Tests/Editor/Phase1Tests.cs
+++ b/Assets/Tests/Editor/Phase1EditModeTests.cs
@@ -8,7 +8,7 @@ namespace DeterministicRollback.Tests.Editor
/// Phase 1 tests - Pure unit tests for core data structures and deterministic physics.
/// All tests are Edit Mode compatible (no scene/MonoBehaviour dependencies).
///
- public class Phase1Tests
+ public class Phase1EditModeTests
{
// ========== Step 1.2: RingBuffer Tests ==========
diff --git a/Assets/Tests/Editor/Phase1EditModeTests.cs.meta b/Assets/Tests/Editor/Phase1EditModeTests.cs.meta
new file mode 100644
index 0000000..56c3e4c
--- /dev/null
+++ b/Assets/Tests/Editor/Phase1EditModeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: daf757c5f4f416c45946a34154ea98c5
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase1Tests.cs.meta b/Assets/Tests/Editor/Phase1Tests.cs.meta
deleted file mode 100644
index 37c4605..0000000
--- a/Assets/Tests/Editor/Phase1Tests.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 393454a5e97bc5743acf1d5c02a35d5b
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs
new file mode 100644
index 0000000..9850e2b
--- /dev/null
+++ b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs
@@ -0,0 +1,39 @@
+using NUnit.Framework;
+using UnityEngine;
+using DeterministicRollback.Core;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ ///
+ /// Phase 2 negative/boundary tests for deterministic simulation math.
+ ///
+ public class Phase2EditModeNegativeTests
+ {
+ [Test]
+ public void SimulationMath_Integrate_ExtremeInput_DoesNotProduceNaN()
+ {
+ var state = new StatePayload { position = Vector2.zero, velocity = Vector2.zero, tick = 0, confirmedInputTick = 0 };
+ var input = new InputPayload { tick = 1, inputVector = new Vector2(1e30f, -1e30f) };
+
+ SimulationMath.Integrate(ref state, ref input);
+
+ // Ensure resulting components are finite (not NaN or Infinity)
+ Assert.IsFalse(float.IsNaN(state.position.x) || float.IsInfinity(state.position.x));
+ Assert.IsFalse(float.IsNaN(state.position.y) || float.IsInfinity(state.position.y));
+ Assert.IsFalse(float.IsNaN(state.velocity.x) || float.IsInfinity(state.velocity.x));
+ Assert.IsFalse(float.IsNaN(state.velocity.y) || float.IsInfinity(state.velocity.y));
+ }
+
+ [Test]
+ public void SimulationMath_Integrate_PreservesConfirmedInputTick()
+ {
+ var state = new StatePayload { position = Vector2.zero, velocity = Vector2.zero, tick = 0, confirmedInputTick = 123 };
+ var input = new InputPayload { tick = 10, inputVector = Vector2.right };
+
+ SimulationMath.Integrate(ref state, ref input);
+
+ // confirmedInputTick should remain unchanged by pure Integrate
+ Assert.AreEqual(123u, state.confirmedInputTick);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta
new file mode 100644
index 0000000..9c70c87
--- /dev/null
+++ b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f5f42b6330ca49c468deb86b96986452
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs
new file mode 100644
index 0000000..4120cd5
--- /dev/null
+++ b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs
@@ -0,0 +1,55 @@
+using System;
+using NUnit.Framework;
+using UnityEngine;
+using DeterministicRollback.Entities;
+using DeterministicRollback.Networking;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ ///
+ /// Phase 3 negative and boundary tests.
+ /// These tests focus on failure modes and edge cases (ghost data, accumulator backlog).
+ ///
+ public class Phase3EditModeNegativeTests
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ // Ensure deterministic starting conditions
+ FakeNetworkPipe.Clear();
+ Time.timeScale = 1f;
+ }
+
+ [Test]
+ public void ClientEntity_SpiralGuard_PreservesAccumulator()
+ {
+ var client = new ClientEntity();
+ client.Initialize();
+
+ // Simulate 20 ticks worth of deltaTime in one frame
+ client.UpdateWithDelta(20f / 60f);
+
+ // Should clamp to MAX_TICKS_PER_FRAME (10)
+ Assert.AreEqual(10u, client.CurrentTick);
+
+ // Next call with no additional time should process the backlog (remaining 10 ticks)
+ client.UpdateWithDelta(0f);
+ Assert.AreEqual(20u, client.CurrentTick);
+ }
+
+ [Test]
+ public void RingBuffer_Wraparound_ThrowsGhostDataException()
+ {
+ var buffer = new DeterministicRollback.Core.RingBuffer(4);
+
+ // Fill capacity
+ for (uint t = 0; t < 4; t++) buffer[t] = (int)t;
+
+ // Overwrite index 0 by writing tick 4 (wrap-around)
+ buffer[4] = 4;
+
+ // Accessing old tick 0 should throw ghost-data exception
+ Assert.Throws(() => { var v = buffer[0]; });
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta
new file mode 100644
index 0000000..5cfeea7
--- /dev/null
+++ b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b6441562ca75ed248add018345e5d15a
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase3EditModeTests.cs b/Assets/Tests/Editor/Phase3EditModeTests.cs
new file mode 100644
index 0000000..fc4d473
--- /dev/null
+++ b/Assets/Tests/Editor/Phase3EditModeTests.cs
@@ -0,0 +1,108 @@
+using System;
+using NUnit.Framework;
+using UnityEngine;
+using DeterministicRollback.Entities;
+using DeterministicRollback.Networking;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ ///
+ /// Phase 3 tests - Client entity simulation with time accumulator and redundant input.
+ ///
+ public class Phase3EditModeTests
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ // Ensure deterministic starting conditions
+ FakeNetworkPipe.Clear();
+ Time.timeScale = 1f;
+ }
+
+ [Test]
+ public void ClientEntity_TickRate()
+ {
+ var client = new ClientEntity();
+ client.Initialize();
+
+ // Run 6000 frames (100 seconds at 60Hz)
+ for (int i = 0; i < 6000; i++)
+ {
+ client.UpdateWithDelta(1f / 60f);
+ }
+
+ // currentTick should be 6000 (0 is spawn, 1-6000 simulated)
+ Assert.AreEqual(6000u, client.CurrentTick);
+ }
+
+ [Test]
+ public void ClientEntity_DeterministicPath()
+ {
+ var client1 = new ClientEntity();
+ var client2 = new ClientEntity();
+
+ // Provide deterministic identical input
+ client1.InputProvider = () => Vector2.right;
+ client2.InputProvider = () => Vector2.right;
+
+ int ticks = 300; // 5 seconds
+ for (int i = 0; i < ticks; i++)
+ {
+ client1.UpdateWithDelta(1f / 60f);
+ client2.UpdateWithDelta(1f / 60f);
+ }
+
+ // Verify both clients have identical StateBuffer contents
+ for (uint t = 0; t <= (uint)ticks; t++)
+ {
+ var s1 = client1.GetState(t);
+ var s2 = client2.GetState(t);
+
+ Assert.AreEqual(s1.position.x, s2.position.x, 0.0001f);
+ Assert.AreEqual(s1.position.y, s2.position.y, 0.0001f);
+ Assert.AreEqual(s1.velocity.x, s2.velocity.x, 0.0001f);
+ Assert.AreEqual(s1.velocity.y, s2.velocity.y, 0.0001f);
+ }
+ }
+
+ [Test]
+ public void ClientEntity_SendsBatchesToNetwork()
+ {
+ var client = new ClientEntity();
+ int received = 0;
+ FakeNetworkPipe.Clear();
+ FakeNetworkPipe.OnInputBatchReceived += (batch) => received++;
+
+ // Run a few ticks
+ for (int i = 0; i < 10; i++)
+ {
+ client.UpdateWithDelta(1f / 60f);
+ // Process pending packets immediately (simulated network)
+ FakeNetworkPipe.ProcessPackets();
+ }
+
+ Assert.Greater(received, 0);
+ }
+
+ // Negative tests moved to `Assets/Tests/Editor/Phase3EditModeNegativeTests.cs`
+ // See: ClientEntity_SpiralGuard_PreservesAccumulator (boundary/backlog) and RingBuffer_Wraparound_ThrowsGhostDataException (ghost-data protection) for negative test coverage.
+
+ // Negative tests moved to `Assets/Tests/Editor/Phase3EditModeNegativeTests.cs`
+ // See: RingBuffer_Wraparound_ThrowsGhostDataException (ghost-data protection) and related tests.
+
+#if UNITY_EDITOR
+ [Test]
+ public void ClientEntity_ZeroGC_PerTick()
+ {
+ // Verify per-tick Update does not allocate memory in hot path
+ long allocBefore = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
+
+ var client = new ClientEntity();
+ client.UpdateWithDelta(1f / 60f);
+
+ long allocAfter = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
+ Assert.AreEqual(0L, allocAfter - allocBefore, "Per-tick Update allocated GC memory");
+ }
+#endif
+ }
+}
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase3EditModeTests.cs.meta b/Assets/Tests/Editor/Phase3EditModeTests.cs.meta
new file mode 100644
index 0000000..e49f2f0
--- /dev/null
+++ b/Assets/Tests/Editor/Phase3EditModeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3bcf9058b4d257b4e8e79c56726d54a7
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs
new file mode 100644
index 0000000..6f0a6db
--- /dev/null
+++ b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs
@@ -0,0 +1,94 @@
+using NUnit.Framework;
+using UnityEngine;
+using DeterministicRollback.Entities;
+using DeterministicRollback.Networking;
+using DeterministicRollback.Core;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ public class Phase4EditModeNegativeTests
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ FakeNetworkPipe.Clear();
+ Time.timeScale = 1f;
+ }
+
+ [Test]
+ public void ServerEntity_SpiralGuard_PreservesAccumulator()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ // Provide a huge delta that would require > MAX_TICKS_PER_FRAME
+ server.UpdateWithDelta(1.0f); // 1 second ~ 60 ticks
+
+ // Should have processed at most MAX_TICKS_PER_FRAME ticks
+ Assert.LessOrEqual(server.ticksProcessed, ServerEntity.MAX_TICKS_PER_FRAME);
+
+ // Timer should not be reset to zero (some backlog remains)
+ Assert.Greater(server.GetTimer(), 0f);
+
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_InputBuffer_RejectsOldTicks()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ // Attempt to insert an old tick (less than serverTick)
+ var oldInput = new InputPayload { tick = 0, inputVector = Vector2.right };
+ // Simulate receiving directly via event
+ var batch = new InputBatch { i0 = oldInput, count = 1 };
+ FakeNetworkPipe.SendInput(batch, 0f, 0f);
+ FakeNetworkPipe.ProcessPackets();
+ FakeNetworkPipe.ProcessPackets();
+
+ Assert.IsFalse(server.ServerInputBuffer.Contains(0));
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_InputBuffer_RejectsFutureTicks()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ // Insert a tick far in the future (beyond buffer capacity)
+ var futureInput = new InputPayload { tick = server.ServerTick + 5000, inputVector = Vector2.right };
+ var batch = new InputBatch { i0 = futureInput, count = 1 };
+ FakeNetworkPipe.SendInput(batch, 0f, 0f);
+ FakeNetworkPipe.ProcessPackets();
+
+ Assert.IsFalse(server.ServerInputBuffer.Contains(futureInput.tick));
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_ConfirmedInputTick_NeverDecreases()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ var batch1 = new InputBatch { i0 = new InputPayload { tick = 5 }, count = 1 };
+ var batch2 = new InputBatch { i0 = new InputPayload { tick = 3 }, count = 1 };
+
+ FakeNetworkPipe.SendInput(batch1, 0f, 0f);
+ FakeNetworkPipe.SendInput(batch2, 0f, 0f);
+ FakeNetworkPipe.ProcessPackets();
+
+ // Process a few ticks to update lastConfirmedInputTick
+ for (int i = 0; i < 3; i++) server.UpdateWithDelta(1f / 60f);
+
+ Assert.GreaterOrEqual(server.lastConfirmedInputTick, 5u);
+ Object.DestroyImmediate(go);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta
new file mode 100644
index 0000000..dfeb6c6
--- /dev/null
+++ b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a65107c7157d65241bbf5a6a5aa718f6
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase4EditModeTests.cs b/Assets/Tests/Editor/Phase4EditModeTests.cs
new file mode 100644
index 0000000..6eb07e7
--- /dev/null
+++ b/Assets/Tests/Editor/Phase4EditModeTests.cs
@@ -0,0 +1,112 @@
+using NUnit.Framework;
+using UnityEngine;
+using DeterministicRollback.Entities;
+using DeterministicRollback.Networking;
+using DeterministicRollback.Core;
+
+namespace DeterministicRollback.Tests.Editor
+{
+ public class Phase4EditModeTests
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ // Ensure FakeNetworkPipe is clean and time is running
+ FakeNetworkPipe.Clear();
+ Time.timeScale = 1f;
+ }
+
+ [Test]
+ public void ServerEntity_TickRate()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ uint initial = server.ServerTick;
+ for (int i = 0; i < 6000; i++)
+ {
+ server.UpdateWithDelta(1f / 60f);
+ }
+
+ Assert.AreEqual(6000u, server.ServerTick - initial);
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_ReceiveInputBatch()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ var batch = new InputBatch { i0 = new InputPayload { tick = 5, inputVector = Vector2.right }, count = 1 };
+ FakeNetworkPipe.SendInput(batch, 0f, 0f);
+ // Deliver immediately in EditMode tests (run twice to avoid timing flakiness)
+ FakeNetworkPipe.ProcessPackets();
+ FakeNetworkPipe.ProcessPackets();
+
+ Assert.IsTrue(server.ServerInputBuffer.Contains(5));
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_InputPrediction()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ var batch = new InputBatch { i0 = new InputPayload { tick = 10, inputVector = Vector2.right }, count = 1 };
+ FakeNetworkPipe.SendInput(batch, 0f, 0f);
+ FakeNetworkPipe.ProcessPackets();
+ FakeNetworkPipe.ProcessPackets();
+
+ // Simulate 15 ticks
+ for (int i = 0; i < 15; i++) server.UpdateWithDelta(1f / 60f);
+
+ Assert.Greater(server.ServerTick, 10u);
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_InputConfirmation()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ var batch1 = new InputBatch { i0 = new InputPayload { tick = 5, inputVector = Vector2.right }, count = 1 };
+ var batch2 = new InputBatch { i0 = new InputPayload { tick = 7, inputVector = Vector2.right }, count = 1 };
+ var batch3 = new InputBatch { i0 = new InputPayload { tick = 9, inputVector = Vector2.right }, count = 1 };
+
+ FakeNetworkPipe.SendInput(batch1, 0f, 0f);
+ FakeNetworkPipe.SendInput(batch2, 0f, 0f);
+ FakeNetworkPipe.SendInput(batch3, 0f, 0f);
+ FakeNetworkPipe.ProcessPackets();
+
+ // Process a few ticks to let server apply confirmations
+ for (int i = 0; i < 5; i++) server.UpdateWithDelta(1f / 60f);
+
+ Assert.AreEqual(9u, server.lastConfirmedInputTick);
+ Object.DestroyImmediate(go);
+ }
+
+ [Test]
+ public void ServerEntity_ZeroGC_PerTick()
+ {
+ var go = new GameObject("server");
+ var server = go.AddComponent();
+ server.Initialize();
+
+ long before = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
+ for (int i = 0; i < 100; i++) server.UpdateWithDelta(1f / 60f);
+ long after = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
+
+ // Allow small noise, but ensure no large allocations in hot path
+ Assert.IsTrue(after - before < 1024);
+
+ Object.DestroyImmediate(go);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Tests/Editor/Phase4EditModeTests.cs.meta b/Assets/Tests/Editor/Phase4EditModeTests.cs.meta
new file mode 100644
index 0000000..18820e9
--- /dev/null
+++ b/Assets/Tests/Editor/Phase4EditModeTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 324b64b66774fae4882eea6b000a1b80
\ No newline at end of file
diff --git a/Packages/manifest.json b/Packages/manifest.json
index 63b8061..aa6a672 100644
--- a/Packages/manifest.json
+++ b/Packages/manifest.json
@@ -1,5 +1,8 @@
{
"dependencies": {
+ "com.unity.ai.assistant": "1.0.0-pre.12",
+ "com.unity.ai.generators": "1.0.0-pre.20",
+ "com.unity.ai.inference": "2.4.1",
"com.unity.multiplayer.center": "1.0.1",
"com.unity.test-framework": "1.6.0",
"com.unity.modules.accessibility": "1.0.0",
diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json
index c56545c..4d4c03d 100644
--- a/Packages/packages-lock.json
+++ b/Packages/packages-lock.json
@@ -1,11 +1,98 @@
{
"dependencies": {
+ "com.unity.ai.assistant": {
+ "version": "1.0.0-pre.12",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.ai.toolkit": "1.0.0-pre.18",
+ "com.unity.serialization": "3.1.1",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.modules.unitywebrequest": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.ai.generators": {
+ "version": "1.0.0-pre.20",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.ai.toolkit": "1.0.0-pre.20",
+ "com.unity.mathematics": "1.3.2",
+ "com.unity.nuget.newtonsoft-json": "3.2.1"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.ai.inference": {
+ "version": "2.4.1",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.burst": "1.8.17",
+ "com.unity.dt.app-ui": "1.3.1",
+ "com.unity.collections": "2.4.3",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.modules.imageconversion": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.ai.toolkit": {
+ "version": "1.0.0-pre.20",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.nuget.newtonsoft-json": "3.2.1"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.burst": {
+ "version": "1.8.26",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.mathematics": "1.2.1",
+ "com.unity.modules.jsonserialize": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.collections": {
+ "version": "2.6.2",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.burst": "1.8.23",
+ "com.unity.mathematics": "1.3.2",
+ "com.unity.test-framework": "1.4.6",
+ "com.unity.nuget.mono-cecil": "1.11.5",
+ "com.unity.test-framework.performance": "3.0.3"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.dt.app-ui": {
+ "version": "2.1.1",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.modules.physics": "1.0.0",
+ "com.unity.modules.androidjni": "1.0.0",
+ "com.unity.modules.uielements": "1.0.0",
+ "com.unity.modules.screencapture": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.ext.nunit": {
"version": "2.0.5",
"depth": 1,
"source": "builtin",
"dependencies": {}
},
+ "com.unity.mathematics": {
+ "version": "1.3.3",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.com"
+ },
"com.unity.multiplayer.center": {
"version": "1.0.1",
"depth": 0,
@@ -14,6 +101,30 @@
"com.unity.modules.uielements": "1.0.0"
}
},
+ "com.unity.nuget.mono-cecil": {
+ "version": "1.11.6",
+ "depth": 2,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.nuget.newtonsoft-json": {
+ "version": "3.2.2",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.serialization": {
+ "version": "3.1.3",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.burst": "1.7.2",
+ "com.unity.collections": "2.4.2"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.test-framework": {
"version": "1.6.0",
"depth": 0,
@@ -24,6 +135,16 @@
"com.unity.modules.jsonserialize": "1.0.0"
}
},
+ "com.unity.test-framework.performance": {
+ "version": "3.2.0",
+ "depth": 2,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.test-framework": "1.1.33",
+ "com.unity.modules.jsonserialize": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.modules.accessibility": {
"version": "1.0.0",
"depth": 0,
diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset
index 7df81c6..819692d 100644
--- a/ProjectSettings/EditorBuildSettings.asset
+++ b/ProjectSettings/EditorBuildSettings.asset
@@ -5,5 +5,6 @@ EditorBuildSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Scenes: []
- m_configObjects: {}
+ m_configObjects:
+ com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2}
m_UseUCBPForAssetBundles: 0
diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset
index 2493a3b..bb0b2e6 100644
--- a/ProjectSettings/ProjectSettings.asset
+++ b/ProjectSettings/ProjectSettings.asset
@@ -17,8 +17,8 @@ PlayerSettings:
defaultCursor: {fileID: 0}
cursorHotspot: {x: 0, y: 0}
m_SplashScreenBackgroundColor: {r: 0.12156863, g: 0.12156863, b: 0.1254902, a: 1}
- m_ShowUnitySplashScreen: 0
- m_ShowUnitySplashLogo: 0
+ m_ShowUnitySplashScreen: 1
+ m_ShowUnitySplashLogo: 1
m_SplashScreenOverlayOpacity: 1
m_SplashScreenAnimation: 1
m_SplashScreenLogoStyle: 1
@@ -83,7 +83,7 @@ PlayerSettings:
androidApplicationEntry: 2
defaultIsNativeResolution: 1
macRetinaSupport: 1
- runInBackground: 1
+ runInBackground: 0
muteOtherAudioSources: 0
Prepare IOS For Recording: 0
Force IOS Speakers When Recording: 0
@@ -96,7 +96,7 @@ PlayerSettings:
bakeCollisionMeshes: 0
forceSingleInstance: 0
useFlipModelSwapchain: 1
- resizableWindow: 1
+ resizableWindow: 0
useMacAppStoreValidation: 0
macAppStoreCategory: public.app-category.games
gpuSkinning: 0
@@ -108,7 +108,7 @@ PlayerSettings:
xboxEnableFitness: 0
visibleInBackground: 1
allowFullscreenSwitch: 1
- fullscreenMode: 3
+ fullscreenMode: 1
xboxSpeechDB: 0
xboxEnableHeadOrientation: 0
xboxEnableGuest: 0
@@ -583,7 +583,8 @@ PlayerSettings:
webGLCloseOnQuit: 0
webWasm2023: 0
webEnableSubmoduleStrippingCompatibility: 0
- scriptingDefineSymbols: {}
+ scriptingDefineSymbols:
+ Standalone: APP_UI_EDITOR_ONLY
additionalCompilerArguments: {}
platformArchitecture: {}
scriptingBackend: {}
diff --git a/SELF_HOSTED_RUNNER_SETUP.md b/SELF_HOSTED_RUNNER_SETUP.md
new file mode 100644
index 0000000..f7a8f2b
--- /dev/null
+++ b/SELF_HOSTED_RUNNER_SETUP.md
@@ -0,0 +1,111 @@
+# Self-Hosted GitHub Actions Runner Setup
+
+## Why Self-Hosted Runner?
+
+Unity Personal licenses can't be activated programmatically in cloud CI runners. A self-hosted runner uses your machine where Unity is already activated.
+
+## Setup Steps
+
+### 1. Install GitHub Actions Runner on Your Machine
+
+1. Go to: https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners/new
+
+2. Follow the displayed instructions, or use these commands in PowerShell:
+
+```powershell
+# Create a folder
+cd C:\
+mkdir actions-runner; cd actions-runner
+
+# Download the latest runner package
+Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-win-x64-2.321.0.zip -OutFile actions-runner-win-x64-2.321.0.zip
+
+# Extract the installer
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+[System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/actions-runner-win-x64-2.321.0.zip", "$PWD")
+```
+
+### 2. Configure the Runner
+
+```powershell
+# Get your token from https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners/new
+.\config.cmd --url https://github.com/harmandeeppal/deterministic-rollback-toy --token YOUR_TOKEN_HERE
+
+# When prompted:
+# - Runner group: Default
+# - Runner name: (press Enter to use default, e.g., "DESKTOP-XYZ")
+# - Labels: unity-windows (or press Enter for default)
+# - Work folder: (press Enter for default)
+```
+
+### 3. Run the Runner
+
+```powershell
+# Start the runner (keep this terminal open)
+.\run.cmd
+```
+
+**Or install as a Windows Service** (recommended for always-on):
+
+```powershell
+# Run as Administrator:
+.\svc.cmd install
+.\svc.cmd start
+```
+
+### 4. Update Workflow to Use Self-Hosted Runner
+
+The workflow needs a small change to target your runner instead of `ubuntu-latest`.
+
+**Current:**
+```yaml
+runs-on: ubuntu-latest
+```
+
+**Updated:**
+```yaml
+runs-on: self-hosted
+```
+
+## Workflow Changes Needed
+
+Update `.github/workflows/unity-tests.yml`:
+
+1. Change `runs-on: ubuntu-latest` to `runs-on: self-hosted`
+2. Remove Unity activation steps (not needed - Unity already activated)
+3. Use Windows paths if needed
+
+## Verify Runner is Online
+
+Check: https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners
+
+You should see your runner listed with a green dot (online).
+
+## Pros/Cons
+
+### Pros
+✅ Works immediately with Personal license
+✅ No license secrets needed
+✅ Faster builds (no Docker overhead)
+✅ Can test Windows-specific features
+
+### Cons
+❌ Machine must be running for CI to work
+❌ Ties up your machine during builds
+❌ Only tests on your OS/Unity version
+
+## Alternative: Use Ubuntu VM
+
+If you want a dedicated CI machine:
+1. Set up an Ubuntu VM (VirtualBox/Hyper-V)
+2. Install Unity on VM and activate Personal license
+3. Install GitHub Actions runner on VM
+4. Leave VM running 24/7
+
+## Next Steps
+
+1. Choose: self-hosted runner vs. cloud runner with Pro license
+2. If self-hosted: follow steps above
+3. Update workflow file
+4. Test with a push to the branch
+