Implement deterministic simulation spine

This commit is contained in:
2026-04-16 11:29:41 +02:00
parent 8f5721462d
commit 5f11dfcdc5
41 changed files with 1406 additions and 203 deletions

View File

@@ -1,5 +0,0 @@
namespace SideScrollerGame.Sim;
public static class SimulationAssemblyMarker
{
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record GameDefinition
{
public GameDefinition(ImmutableArray<PlayerDefinition> players)
{
Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players;
}
public ImmutableArray<PlayerDefinition> Players { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record PlayerDefinition
{
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition)
{
PlayerId = playerId;
SpawnPosition = spawnPosition;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 SpawnPosition { get; init; }
}

View File

@@ -59,4 +59,4 @@ public readonly struct SFixPointQuaternionTransform
public SFixPointQuaternion m_Orientation { get; }
public SFixPointVector3 m_Size { get; }
}
}

View File

@@ -2,8 +2,6 @@
#define RANGE_CHECK
#endif
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
@@ -14,20 +12,24 @@ public static class FixPoint16Ext
{
public static FixPoint16 Average(this IEnumerable<FixPoint16> source)
{
return Average(source, f => f);
return source.Average(f => f);
}
public static FixPoint16 Average<TSource>(this IEnumerable<TSource> source, Func<TSource, FixPoint16> selector)
{
using IEnumerator<TSource> e = source.GetEnumerator();
using var e = source.GetEnumerator();
if (!e.MoveNext())
return FixPoint16.Zero;
long sum = selector(e.Current).m_Value;
int count = 1;
var count = 1;
while (e.MoveNext())
{
checked { sum += selector(e.Current).m_Value; }
checked
{
sum += selector(e.Current).m_Value;
}
count++;
}
@@ -36,15 +38,13 @@ public static class FixPoint16Ext
public static FixPoint16 Sum(this IEnumerable<FixPoint16> source)
{
using IEnumerator<FixPoint16> e = source.GetEnumerator();
using var e = source.GetEnumerator();
if (!e.MoveNext())
return FixPoint16.Zero;
var sum = e.Current;
while (e.MoveNext())
{
sum += e.Current;
}
return sum;
}
@@ -83,9 +83,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Integer to FixPoint argument out of range: {value}");
}
#endif
m_Value = value << c_Shift;
}
@@ -95,25 +93,17 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (divisor == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
long iResult;
if (((uint)dividend & 0x80000000U) == ((uint)divisor & 0x80000000U))
{
iResult = (((long)dividend << c_Shift) + (divisor / 2)) / divisor;
}
iResult = (((long)dividend << c_Shift) + divisor / 2) / divisor;
else
{
iResult = (((long)dividend << c_Shift) - (divisor / 2)) / divisor;
}
iResult = (((long)dividend << c_Shift) - divisor / 2) / divisor;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Division result out of range: {iResult}");
}
#endif
m_Value = (int)iResult;
}
@@ -125,7 +115,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (divisor == 0L)
throw new ArithmeticException("Divison by zero");
#endif
while (dividend > (long.MaxValue >> c_Shift) || dividend < (long.MinValue >> c_Shift))
while (dividend > long.MaxValue >> c_Shift || dividend < long.MinValue >> c_Shift)
{
dividend >>= 1;
divisor >>= 1;
@@ -134,9 +124,9 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
long iResult;
if (((ulong)dividend & 0x8000000000000000UL) == ((ulong)divisor & 0x8000000000000000UL))
iResult = ((dividend << c_Shift) + (divisor / 2)) / divisor;
iResult = ((dividend << c_Shift) + divisor / 2) / divisor;
else
iResult = ((dividend << c_Shift) - (divisor / 2)) / divisor;
iResult = ((dividend << c_Shift) - divisor / 2) / divisor;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
@@ -149,27 +139,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0)
{
m_Value = (int)((value * c_Multiplier) - 0.5);
}
m_Value = (int)(value * c_Multiplier - 0.5);
else
{
m_Value = (int)((value * c_Multiplier) + 0.5);
}
m_Value = (int)(value * c_Multiplier + 0.5);
}
public FixPoint16(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
}
#endif
m_Value = 0;
Update(value);
@@ -233,13 +215,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
case EParseState.TrimStartWhitespace:
{
if (char.IsWhiteSpace(character))
{
++i;
}
else
{
state = EParseState.ParseMantissa;
}
break;
}
case EParseState.ParseMantissa:
@@ -322,13 +301,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
case EParseState.TrimEndWhitespace:
{
if (char.IsWhiteSpace(character))
{
++i;
}
else
{
throw new FormatException($"Unexpected character '{character}' after the number.");
}
break;
}
}
@@ -346,27 +322,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (exponentValue > 0)
{
while (exponentValue-- > 0)
{
if (denominatorPower > 0)
{
denominatorPower -= 1;
}
else
{
numerator *= 10;
}
}
}
else
{
denominatorPower -= exponentValue;
}
}
}
numerator = mantissaNegative ? -numerator : numerator;
denominator = 1;
for (int i = 0; i < denominatorPower; ++i)
for (var i = 0; i < denominatorPower; ++i)
denominator *= 10;
}
@@ -439,9 +407,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
unchecked
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
@@ -499,13 +465,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
return ToDouble() * 360.0;
}
public override readonly string ToString()
public readonly override string ToString()
{
return $"{ToDouble()}[0x{m_Value:x}]";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override readonly int GetHashCode()
public readonly override int GetHashCode()
{
return m_Value.GetHashCode();
}
@@ -517,12 +483,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override readonly bool Equals(object? obj)
public readonly override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != typeof(FixPoint16))
{
return false;
}
return ((FixPoint16)obj).m_Value == m_Value;
}
@@ -596,13 +560,11 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value + b.m_Value;
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Addition result out of range: {iResult}");
}
return new() { m_Value = (int)iResult };
#else
return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
#endif
}
}
@@ -616,13 +578,11 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value - b.m_Value;
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Substraction result out of range: {iResult}");
}
return new() { m_Value = (int)iResult };
#else
return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
#endif
}
}
@@ -634,9 +594,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (a.m_Value < 0 && -a.m_Value < 0)
{
throw new ArithmeticException($"Negation result out of range: {a.m_Value}");
}
#endif
return new() { m_Value = -a.m_Value };
}
@@ -647,12 +605,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
unchecked
{
var iResult = (((long)a.m_Value * b.m_Value) + c_Half) >> c_Shift;
var iResult = ((long)a.m_Value * b.m_Value + c_Half) >> c_Shift;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
}
#endif
return new() { m_Value = (int)iResult };
}
@@ -667,13 +623,11 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value * value;
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
}
return new() { m_Value = (int)iResult };
#else
return new FixPoint16 { m_Value = a.m_Value * value };
return new FixPoint16 { m_Value = a.m_Value * value };
#endif
}
}
@@ -685,16 +639,12 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
if ((a.m_Value & 0x80000000) == (value & 0x80000000))
{
return new() { m_Value = (int)(((long)a.m_Value + (value / 2)) / value) };
}
return new() { m_Value = (int)(((long)a.m_Value + value / 2) / value) };
return new() { m_Value = (int)(((long)a.m_Value - (value / 2)) / value) };
return new() { m_Value = (int)(((long)a.m_Value - value / 2) / value) };
}
}
@@ -705,25 +655,17 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (b.m_Value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
long iResult;
if ((a.m_Value & 0x80000000) == (b.m_Value & 0x80000000))
{
iResult = (((long)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
}
iResult = (((long)a.m_Value << c_Shift) + b.m_Value / 2) / b.m_Value;
else
{
iResult = (((long)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
}
iResult = (((long)a.m_Value << c_Shift) - b.m_Value / 2) / b.m_Value;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
{
throw new ArithmeticException($"Division result out of range: {iResult}");
}
#endif
return new() { m_Value = (int)iResult };
}
@@ -865,9 +807,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 0x7fffffff)
{
throw new ArithmeticException($"Length out of range: {value}");
}
#endif
return new() { m_Value = (int)value };
}
@@ -880,9 +820,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
return value;
@@ -896,17 +834,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 0x7fffffff)
{
throw new ArithmeticException($"Length out of range: {value}");
}
#endif
return new() { m_Value = (int)value };
}
@@ -919,24 +853,18 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value += cSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 0x7fffffff)
{
throw new ArithmeticException($"Length out of range: {value}");
}
#endif
return new() { m_Value = (int)value };
}
@@ -967,17 +895,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value.m_Value < MinusOne.m_Value)
{
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
}
#endif
return new() { m_Value = -s_AsinTable[-value.m_Value] };
}
#if RANGE_CHECK
if (value.m_Value > One.m_Value)
{
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
}
#endif
return new() { m_Value = s_AsinTable[value.m_Value] };
}
@@ -994,17 +918,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (value.m_Value < 0)
{
if (-value.m_Value < s_AtanTable.Length)
{
return new() { m_Value = s_AtanTable[-value.m_Value] };
}
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(One / value).m_Value] };
}
if (value.m_Value < s_AtanTable.Length)
{
return new() { m_Value = -s_AtanTable[value.m_Value] };
}
return new() { m_Value = s_AtanTable[(One / value).m_Value] };
}
@@ -1017,9 +937,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (y.m_Value == 0)
{
if (x.m_Value >= 0)
{
return Zero;
}
return Pi;
}
@@ -1027,51 +945,39 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (y.m_Value > 0)
{
if (x.m_Value == 0)
{
return HalfPi;
}
if (x.m_Value > 0)
{
// x > 0, y > 0
if (y.m_Value <= x.m_Value)
{
return new() { m_Value = s_AtanTable[(y / x).m_Value] };
}
return new() { m_Value = HalfPi.m_Value - s_AtanTable[(x / y).m_Value] };
}
// x < 0, y > 0
if (y.m_Value <= -x.m_Value)
{
return new() { m_Value = Pi.m_Value - s_AtanTable[-(y / x).m_Value] };
}
return new() { m_Value = HalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
}
if (x.m_Value == 0)
{
return MinusHalfPi;
}
if (x.m_Value > 0)
{
// x > 0, y < 0
if (-y.m_Value <= x.m_Value)
{
return new() { m_Value = -s_AtanTable[-(y / x).m_Value] };
}
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
}
// x < 0, y < 0
if (y.m_Value >= x.m_Value)
{
return new() { m_Value = MinusPi.m_Value + s_AtanTable[(y / x).m_Value] };
}
return new() { m_Value = MinusHalfPi.m_Value - s_AtanTable[(x / y).m_Value] };
}
@@ -1083,9 +989,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var iResult = (b.m_Value - a.m_Value) & c_FractionMask;
if (iResult > Pi.m_Value)
{
iResult -= TwoPi.m_Value;
}
return new() { m_Value = iResult };
}
@@ -1106,7 +1010,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Lerp(FixPoint16 value1, FixPoint16 value2, FixPoint16 amount)
{
return value1 + (amount * (value2 - value1));
return value1 + amount * (value2 - value1);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -1175,13 +1079,9 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
public void Update(float value)
{
if (value < 0.0f)
{
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
}
m_Value = (int)(value * c_MultiplierFloat - 0.5f);
else
{
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
}
m_Value = (int)(value * c_MultiplierFloat + 0.5f);
}
public int m_Value;
@@ -1190,28 +1090,28 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new FixPoint16 { m_Value = m_Value * 2 };
get => new() { m_Value = m_Value * 2 };
}
public FixPoint16 Quadrupled
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new FixPoint16 { m_Value = m_Value * 4 };
get => new() { m_Value = m_Value * 4 };
}
public FixPoint16 Halved
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new FixPoint16 { m_Value = m_Value / 2 };
get => new() { m_Value = m_Value / 2 };
}
public FixPoint16 Quartered
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new FixPoint16 { m_Value = m_Value / 4 };
get => new() { m_Value = m_Value / 4 };
}
public static readonly FixPoint16 Zero = default;
@@ -1234,4 +1134,4 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
public static readonly FixPoint16 MinusHalfPi = -HalfPi;
public static readonly FixPoint16 MinusQuaterPi = -QuaterPi;
public static readonly FixPoint16 InvSqrt2 = One / Sqrt(2);
}
}

View File

@@ -2,7 +2,6 @@
#define RANGE_CHECK
#endif
using System;
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.Math;
@@ -23,9 +22,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
}
#endif
m_Value = value << c_Shift;
}
@@ -34,36 +31,24 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0)
{
m_Value = (int)((value * c_Multiplier) - 0.5);
}
m_Value = (int)(value * c_Multiplier - 0.5);
else
{
m_Value = (int)((value * c_Multiplier) + 0.5);
}
m_Value = (int)(value * c_Multiplier + 0.5);
}
public FixPoint16Long(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0f)
{
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
}
m_Value = (int)(value * c_MultiplierFloat - 0.5f);
else
{
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
}
m_Value = (int)(value * c_MultiplierFloat + 0.5f);
}
public long ToLongFloor()
@@ -79,9 +64,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
public long ToLongRound()
{
if (m_Value < 0)
{
return -((-m_Value + c_Half) >> c_Shift);
}
return (m_Value + c_Half) >> c_Shift;
}
@@ -89,9 +72,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
public long ToLong()
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
@@ -120,7 +101,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
if (obj is not FixPoint16Long other)
return -1;
return m_Value.CompareTo(other.m_Value);
}
@@ -132,9 +113,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
public override bool Equals(object? obj)
{
if (obj == null)
{
return false;
}
return ((FixPoint16Long)obj).m_Value == m_Value;
}
@@ -203,12 +182,10 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
Int128 bigA = a.m_Value;
Int128 bigB = b.m_Value;
var result = ((bigA * bigB) + c_Half) >> c_Shift;
var result = (bigA * bigB + c_Half) >> c_Shift;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Multiplication result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
@@ -217,25 +194,17 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
#if RANGE_CHECK
if (b.m_Value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
Int128 result;
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
{
result = (((Int128)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
}
result = (((Int128)a.m_Value << c_Shift) + b.m_Value / 2) / b.m_Value;
else
{
result = (((Int128)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
}
result = (((Int128)a.m_Value << c_Shift) - b.m_Value / 2) / b.m_Value;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Division result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
@@ -254,16 +223,12 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
{
#if RANGE_CHECK
if (value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
{
return new() { m_Value = (long)(((Int128)a.m_Value + (value / 2)) / value) };
}
return new() { m_Value = (long)(((Int128)a.m_Value + value / 2) / value) };
return new() { m_Value = (long)(((Int128)a.m_Value - (value / 2)) / value) };
return new() { m_Value = (long)(((Int128)a.m_Value - value / 2) / value) };
}
public static implicit operator FixPoint16Long(int value)
@@ -354,9 +319,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
return new() { m_Value = IntMath.Sqrt(value) };
@@ -383,4 +346,4 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
public static readonly FixPoint16Long MinusOne = new(-1);
public static readonly FixPoint16Long Half = new() { m_Value = One.m_Value / 2 };
public static readonly FixPoint16Long MinusHalf = -Half;
}
}

View File

@@ -0,0 +1,9 @@
namespace SideScrollerGame.Sim.Input;
public enum InputButton
{
Jump = 0,
FirePrimary = 1,
FireSecondary = 2,
Dash = 3
}

View File

@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public abstract record SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record MoveAxisChanged(PlayerId PlayerId, sbyte X, sbyte Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record AimAxisChanged(PlayerId PlayerId, short X, short Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record ButtonChanged(PlayerId PlayerId, InputButton Button, bool IsPressed) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record WeaponSlotSelected(PlayerId PlayerId, int SlotIndex) : SimulationAction;

View File

@@ -0,0 +1,23 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public sealed record TickActionBatch
{
public TickActionBatch(int tick, ImmutableArray<SimulationAction> actions)
{
Tick = tick;
Actions = actions.IsDefault ? ImmutableArray<SimulationAction>.Empty : actions;
}
public static TickActionBatch Empty(int tick)
{
return new(tick, ImmutableArray<SimulationAction>.Empty);
}
public int Tick { get; init; }
public ImmutableArray<SimulationAction> Actions { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Sim;
public readonly record struct PlayerId(int Value);

View File

@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record RecordedTick
{
public RecordedTick(TickActionBatch actionBatch, int expectedStateHash)
{
ActionBatch = actionBatch;
ExpectedStateHash = expectedStateHash;
}
public TickActionBatch ActionBatch { get; init; }
public int ExpectedStateHash { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public static class ReplayPlayer
{
public static ImmutableArray<int> Play(ReplayRecord replay, GameDefinition gameDefinition, SimulationConfig config)
{
if (replay.ContentHash != GameDefinitionHasher.Compute(gameDefinition))
throw new InvalidOperationException("Replay content hash does not match the supplied game definition.");
if (replay.TicksPerSecond != config.TicksPerSecond)
throw new InvalidOperationException("Replay tick rate does not match the supplied simulation config.");
var hashes = ImmutableArray.CreateBuilder<int>(replay.Ticks.Length);
Simulation simulation = new(gameDefinition, config, replay.Seed);
foreach (var recordedTick in replay.Ticks)
{
var result = simulation.Step(recordedTick.ActionBatch);
if (result.StateHash != recordedTick.ExpectedStateHash)
throw new InvalidOperationException($"Replay diverged at tick {recordedTick.ActionBatch.Tick}.");
hashes.Add(result.StateHash);
}
return hashes.MoveToImmutable();
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record ReplayRecord
{
public ReplayRecord(int contentHash, int seed, int ticksPerSecond, ImmutableArray<RecordedTick> ticks)
{
ContentHash = contentHash;
Seed = seed;
TicksPerSecond = ticksPerSecond;
Ticks = ticks.IsDefault ? ImmutableArray<RecordedTick>.Empty : ticks;
}
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTick> Ticks { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public sealed class ReplayRecorder
{
public ReplayRecorder()
{
m_Ticks = new();
}
public void Append(TickActionBatch actionBatch, TickResult tickResult)
{
m_Ticks.Add(new(actionBatch, tickResult.StateHash));
}
public ReplayRecord Build(GameDefinition gameDefinition, SimulationConfig config, int seed)
{
return new(GameDefinitionHasher.Compute(gameDefinition), seed, config.TicksPerSecond, m_Ticks.ToImmutableArray());
}
private readonly List<RecordedTick> m_Ticks;
}

View File

@@ -0,0 +1,24 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record PlayerSnapshot
{
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 Position { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
}

View File

@@ -0,0 +1,68 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
public sealed class PlayerState
{
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
AimAxisX = aimAxisX;
AimAxisY = aimAxisY;
SelectedWeaponSlot = selectedWeaponSlot;
ButtonMask = buttonMask;
}
public PlayerState Clone()
{
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask);
}
public void SetMoveAxis(sbyte x, sbyte y)
{
MoveAxisX = x;
MoveAxisY = y;
}
public void SetAimAxis(short x, short y)
{
AimAxisX = x;
AimAxisY = y;
}
public void SetButton(InputButton button, bool isPressed)
{
var mask = 1 << (int)button;
ButtonMask = isPressed ? ButtonMask | mask : ButtonMask & ~mask;
}
public void SelectWeaponSlot(int slotIndex)
{
SelectedWeaponSlot = slotIndex;
}
public void Advance()
{
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
}
public PlayerId PlayerId { get; }
public FixPointVector2 Position { get; private set; }
public sbyte MoveAxisX { get; private set; }
public sbyte MoveAxisY { get; private set; }
public short AimAxisX { get; private set; }
public short AimAxisY { get; private set; }
public int SelectedWeaponSlot { get; private set; }
public int ButtonMask { get; private set; }
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record SimulationEvent
{
public SimulationEvent(string kind, int tick, PlayerId playerId)
{
Kind = kind;
Tick = tick;
PlayerId = playerId;
}
public string Kind { get; init; }
public int Tick { get; init; }
public PlayerId PlayerId { get; init; }
}

View File

@@ -0,0 +1,52 @@
using System.Collections.Immutable;
namespace SideScrollerGame.Sim.Runtime;
public sealed class SimulationState
{
public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players)
{
Tick = tick;
Seed = seed;
RandomState = randomState;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players;
}
public SimulationState Clone()
{
var builder = ImmutableArray.CreateBuilder<PlayerState>(Players.Length);
foreach (var player in Players)
builder.Add(player.Clone());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable());
}
public PlayerState GetRequiredPlayer(PlayerId playerId)
{
foreach (var player in Players)
{
if (player.PlayerId == playerId)
return player;
}
throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
}
public void AdvanceTick(int tick, ulong randomState, ulong lastRandomValue)
{
Tick = tick;
RandomState = randomState;
LastRandomValue = lastRandomValue;
}
public int Tick { get; private set; }
public int Seed { get; }
public ulong RandomState { get; private set; }
public ulong LastRandomValue { get; private set; }
public ImmutableArray<PlayerState> Players { get; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record TickResult
{
public TickResult(ImmutableArray<SimulationEvent> events, int stateHash, WorldSnapshot previousSnapshot, WorldSnapshot currentSnapshot)
{
Events = events.IsDefault ? ImmutableArray<SimulationEvent>.Empty : events;
StateHash = stateHash;
PreviousSnapshot = previousSnapshot;
CurrentSnapshot = currentSnapshot;
}
public ImmutableArray<SimulationEvent> Events { get; init; }
public int StateHash { get; init; }
public WorldSnapshot PreviousSnapshot { get; init; }
public WorldSnapshot CurrentSnapshot { get; init; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record WorldSnapshot
{
public WorldSnapshot(int tick, int stateHash, ulong lastRandomValue, ImmutableArray<PlayerSnapshot> players)
{
Tick = tick;
StateHash = stateHash;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerSnapshot>.Empty : players;
}
public int Tick { get; init; }
public int StateHash { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerSnapshot> Players { get; init; }
}

View File

@@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class DeterministicHash
{
public static int Compute(byte[] data)
{
unchecked
{
const uint offset = 2166136261;
const uint prime = 16777619;
var hash = offset;
foreach (var value in data)
{
hash ^= value;
hash *= prime;
}
return (int)hash;
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Definitions;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class GameDefinitionHasher
{
[ExcludeFromCodeCoverage]
private sealed record GameDefinitionDocument
{
public ImmutableArray<PlayerDefinitionDocument> Players { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerDefinitionDocument
{
public int PlayerId { get; init; }
public int SpawnX { get; init; }
public int SpawnY { get; init; }
}
public static int Compute(GameDefinition gameDefinition)
{
List<PlayerDefinitionDocument> players = new(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
SpawnX = player.SpawnPosition.m_X.m_Value,
SpawnY = player.SpawnPosition.m_Y.m_Value
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument { Players = players.ToImmutableArray() });
return DeterministicHash.Compute(bytes);
}
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
public static class ReplayRecordSerializer
{
[ExcludeFromCodeCoverage]
private sealed record ReplayRecordDocument
{
public int Version { get; init; }
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTickDocument> Ticks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record RecordedTickDocument
{
public int Tick { get; init; }
public int ExpectedStateHash { get; init; }
public ImmutableArray<SimulationActionDocument> Actions { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record SimulationActionDocument
{
public string Kind { get; init; } = string.Empty;
public int PlayerId { get; init; }
public int X { get; init; }
public int Y { get; init; }
public InputButton Button { get; init; }
public bool IsPressed { get; init; }
public int SlotIndex { get; init; }
}
public static byte[] Serialize(ReplayRecord replayRecord)
{
List<RecordedTickDocument> ticks = new(replayRecord.Ticks.Length);
foreach (var tick in replayRecord.Ticks)
{
List<SimulationActionDocument> actions = new(tick.ActionBatch.Actions.Length);
foreach (var action in tick.ActionBatch.Actions)
actions.Add(ToDocument(action));
ticks.Add(new()
{
Tick = tick.ActionBatch.Tick,
ExpectedStateHash = tick.ExpectedStateHash,
Actions = actions.ToImmutableArray()
});
}
return JsonSerializer.SerializeToUtf8Bytes(new ReplayRecordDocument
{
Version = SimulationDefaults.ReplayFormatVersion,
ContentHash = replayRecord.ContentHash,
Seed = replayRecord.Seed,
TicksPerSecond = replayRecord.TicksPerSecond,
Ticks = ticks.ToImmutableArray()
});
}
public static ReplayRecord Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<ReplayRecordDocument>(data) ?? throw new InvalidOperationException("Replay payload was empty.");
if (document.Version != SimulationDefaults.ReplayFormatVersion)
throw new NotSupportedException($"Unsupported replay version {document.Version}.");
var ticks = ImmutableArray.CreateBuilder<RecordedTick>(document.Ticks.Length);
foreach (var tick in document.Ticks)
{
var actions = ImmutableArray.CreateBuilder<SimulationAction>(tick.Actions.Length);
foreach (var action in tick.Actions)
actions.Add(ToAction(action));
ticks.Add(new(new(tick.Tick, actions.MoveToImmutable()), tick.ExpectedStateHash));
}
return new(document.ContentHash, document.Seed, document.TicksPerSecond, ticks.MoveToImmutable());
}
private static SimulationActionDocument ToDocument(SimulationAction action)
{
return action switch
{
MoveAxisChanged moveAxisChanged => new()
{
Kind = nameof(MoveAxisChanged),
PlayerId = moveAxisChanged.PlayerId.Value,
X = moveAxisChanged.X,
Y = moveAxisChanged.Y
},
AimAxisChanged aimAxisChanged => new()
{
Kind = nameof(AimAxisChanged),
PlayerId = aimAxisChanged.PlayerId.Value,
X = aimAxisChanged.X,
Y = aimAxisChanged.Y
},
ButtonChanged buttonChanged => new()
{
Kind = nameof(ButtonChanged),
PlayerId = buttonChanged.PlayerId.Value,
Button = buttonChanged.Button,
IsPressed = buttonChanged.IsPressed
},
WeaponSlotSelected weaponSlotSelected => new()
{
Kind = nameof(WeaponSlotSelected),
PlayerId = weaponSlotSelected.PlayerId.Value,
SlotIndex = weaponSlotSelected.SlotIndex
},
_ => throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.")
};
}
private static SimulationAction ToAction(SimulationActionDocument action)
{
return action.Kind switch
{
nameof(MoveAxisChanged) => new MoveAxisChanged(new(action.PlayerId), checked((sbyte)action.X), checked((sbyte)action.Y)),
nameof(AimAxisChanged) => new AimAxisChanged(new(action.PlayerId), checked((short)action.X), checked((short)action.Y)),
nameof(ButtonChanged) => new ButtonChanged(new(action.PlayerId), action.Button, action.IsPressed),
nameof(WeaponSlotSelected) => new WeaponSlotSelected(new(action.PlayerId), action.SlotIndex),
_ => throw new NotSupportedException($"Unsupported action kind {action.Kind}.")
};
}
}

View File

@@ -0,0 +1,94 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class SimulationStateSerializer
{
[ExcludeFromCodeCoverage]
private sealed record SimulationStateDocument
{
public int Version { get; init; }
public int Tick { get; init; }
public int Seed { get; init; }
public ulong RandomState { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerStateDocument> Players { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerStateDocument
{
public int PlayerId { get; init; }
public int PositionX { get; init; }
public int PositionY { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public short AimAxisX { get; init; }
public short AimAxisY { get; init; }
public int SelectedWeaponSlot { get; init; }
public int ButtonMask { get; init; }
}
public static byte[] Serialize(SimulationState state)
{
List<PlayerStateDocument> players = new(state.Players.Length);
foreach (var player in state.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
PositionX = player.Position.m_X.m_Value,
PositionY = player.Position.m_Y.m_Value,
MoveAxisX = player.MoveAxisX,
MoveAxisY = player.MoveAxisY,
AimAxisX = player.AimAxisX,
AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot,
ButtonMask = player.ButtonMask
});
}
return JsonSerializer.SerializeToUtf8Bytes(new SimulationStateDocument
{
Version = SimulationDefaults.StateFormatVersion,
Tick = state.Tick,
Seed = state.Seed,
RandomState = state.RandomState,
LastRandomValue = state.LastRandomValue,
Players = players.ToImmutableArray()
});
}
public static SimulationState Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<SimulationStateDocument>(data) ?? throw new InvalidOperationException("Simulation state payload was empty.");
if (document.Version != SimulationDefaults.StateFormatVersion)
throw new NotSupportedException($"Unsupported simulation state version {document.Version}.");
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
foreach (var player in document.Players)
{
players.Add(new(new(player.PlayerId), new(new() { m_Value = player.PositionX }, new FixPoint16 { m_Value = player.PositionY }), player.MoveAxisX, player.MoveAxisY, player.AimAxisX, player.AimAxisY, player.SelectedWeaponSlot, player.ButtonMask));
}
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable());
}
}

View File

@@ -0,0 +1,202 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
public sealed class Simulation
{
public Simulation(GameDefinition gameDefinition, SimulationConfig config, int seed) : this(gameDefinition, config, CreateInitialState(gameDefinition, seed), true)
{
}
private Simulation(GameDefinition gameDefinition, SimulationConfig config, SimulationState initialState, bool enableVerification)
{
m_GameDefinition = gameDefinition ?? throw new ArgumentNullException(nameof(gameDefinition));
m_Config = config ?? throw new ArgumentNullException(nameof(config));
m_EnableVerification = enableVerification;
ValidateDefinitions(gameDefinition);
CurrentState = initialState;
var initialHash = ComputeStateHash(initialState);
PreviousSnapshot = CreateSnapshot(initialState, initialHash);
CurrentSnapshot = PreviousSnapshot;
}
public TickResult Step(in TickActionBatch actions)
{
if (actions.Tick != CurrentTick + 1)
throw new InvalidOperationException($"Expected tick {CurrentTick + 1} but received {actions.Tick}.");
TickResult? cloneResult = null;
if (m_EnableVerification && m_Config.VerificationMode == VerificationMode.RoundTripAndStepClone)
{
var clone = LoadStateCore(SaveState(), m_GameDefinition, m_Config, false);
cloneResult = clone.Step(actions);
}
PreviousSnapshot = CurrentSnapshot;
List<SimulationEvent> events = new();
ApplyActions(actions);
var nextRandomState = AdvanceRandom();
AdvancePlayers(actions.Tick, events);
CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState);
var stateHash = ComputeStateHash(CurrentState);
CurrentSnapshot = CreateSnapshot(CurrentState, stateHash);
ValidateRoundTripState(stateHash);
ValidateCloneResult(cloneResult, stateHash, actions.Tick);
return new(events.ToImmutableArray(), stateHash, PreviousSnapshot, CurrentSnapshot);
}
public byte[] SaveState()
{
return SimulationStateSerializer.Serialize(CurrentState);
}
public static Simulation LoadState(byte[] data, GameDefinition gameDefinition, SimulationConfig config)
{
return LoadStateCore(data, gameDefinition, config, true);
}
private static Simulation LoadStateCore(byte[] data, GameDefinition gameDefinition, SimulationConfig config, bool enableVerification)
{
var state = SimulationStateSerializer.Deserialize(data);
return new(gameDefinition, config, state, enableVerification);
}
private static SimulationState CreateInitialState(GameDefinition gameDefinition, int seed)
{
ArgumentNullException.ThrowIfNull(gameDefinition);
ValidateDefinitions(gameDefinition);
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0));
var normalizedSeed = NormalizeSeed(seed);
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable());
}
private static ulong NormalizeSeed(int seed)
{
return seed == 0 ? 1UL : unchecked((uint)seed);
}
private static void ValidateDefinitions(GameDefinition gameDefinition)
{
HashSet<int> seen = new();
foreach (var player in gameDefinition.Players)
{
if (!seen.Add(player.PlayerId.Value))
throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}.");
}
}
private static int ComputeStateHash(SimulationState state)
{
return DeterministicHash.Compute(SimulationStateSerializer.Serialize(state));
}
private static WorldSnapshot CreateSnapshot(SimulationState state, int stateHash)
{
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
foreach (var player in state.Players)
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY));
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
}
private void ApplyActions(TickActionBatch actions)
{
foreach (var action in actions.Actions)
{
switch (action)
{
case MoveAxisChanged moveAxisChanged:
CurrentState.GetRequiredPlayer(moveAxisChanged.PlayerId).SetMoveAxis(moveAxisChanged.X, moveAxisChanged.Y);
break;
case AimAxisChanged aimAxisChanged:
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
break;
case ButtonChanged buttonChanged:
CurrentState.GetRequiredPlayer(buttonChanged.PlayerId).SetButton(buttonChanged.Button, buttonChanged.IsPressed);
break;
case WeaponSlotSelected weaponSlotSelected:
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
break;
default:
throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.");
}
}
}
private void AdvancePlayers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
{
player.Advance();
events.Add(new("PlayerMoved", tick, player.PlayerId));
}
}
}
private ulong AdvanceRandom()
{
SIntRandom random = new(CurrentState.RandomState);
random.Next();
return random.Seed;
}
[ExcludeFromCodeCoverage]
private void ValidateRoundTripState(int expectedHash)
{
if (!m_EnableVerification || m_Config.VerificationMode == VerificationMode.None)
return;
var currentBytes = SaveState();
var roundTrip = LoadStateCore(currentBytes, m_GameDefinition, m_Config, false);
var roundTrippedBytes = roundTrip.SaveState();
if (!currentBytes.AsSpan().SequenceEqual(roundTrippedBytes))
throw new SimulationVerificationException("Round-trip serialization changed the saved state payload.");
var roundTripHash = ComputeStateHash(roundTrip.CurrentState);
if (roundTripHash != expectedHash)
throw new SimulationVerificationException("Round-trip serialization changed the state hash.");
}
[ExcludeFromCodeCoverage]
private static void ValidateCloneResult(TickResult? cloneResult, int stateHash, int tick)
{
if (cloneResult is null)
return;
if (cloneResult.StateHash != stateHash)
throw new SimulationVerificationException($"Clone replay diverged at tick {tick}.");
}
public int CurrentTick => CurrentState.Tick;
public SimulationState CurrentState { get; }
public WorldSnapshot PreviousSnapshot { get; private set; }
public WorldSnapshot CurrentSnapshot { get; private set; }
private readonly SimulationConfig m_Config;
private readonly bool m_EnableVerification;
private readonly GameDefinition m_GameDefinition;
}

View File

@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public sealed record SimulationConfig
{
public SimulationConfig(int ticksPerSecond, VerificationMode verificationMode)
{
if (ticksPerSecond <= 0)
throw new ArgumentOutOfRangeException(nameof(ticksPerSecond));
TicksPerSecond = ticksPerSecond;
VerificationMode = verificationMode;
}
public int TicksPerSecond { get; init; }
public VerificationMode VerificationMode { get; init; }
public static SimulationConfig Default { get; } = new(SimulationDefaults.DefaultTicksPerSecond, VerificationMode.None);
}

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public static class SimulationDefaults
{
public const int DefaultTicksPerSecond = 60;
public const int ReplayFormatVersion = 1;
public const int StateFormatVersion = 1;
}

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Verification;
[ExcludeFromCodeCoverage]
public sealed class SimulationVerificationException : Exception
{
public SimulationVerificationException(string message) : base(message)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Sim.Verification;
public enum VerificationMode
{
None,
RoundTripState,
RoundTripAndStepClone
}