diff --git a/.run/SpaceWar (P1) (save).run.xml b/.run/SpaceWar (P1) (save).run.xml
new file mode 100644
index 00000000..fd9b2308
--- /dev/null
+++ b/.run/SpaceWar (P1) (save).run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/SpaceWar (replay).run.xml b/.run/SpaceWar (replay).run.xml
new file mode 100644
index 00000000..1bc24898
--- /dev/null
+++ b/.run/SpaceWar (replay).run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/SpaceWar/GameSessionFactory.cs b/samples/SpaceWar/GameSessionFactory.cs
index e18c091f..8c1d0d46 100644
--- a/samples/SpaceWar/GameSessionFactory.cs
+++ b/samples/SpaceWar/GameSessionFactory.cs
@@ -1,6 +1,7 @@
using System.Net;
using Backdash;
using Backdash.Sync.Input;
+using Backdash.Sync.Input.Confirmed;
using SpaceWar.Logic;
namespace SpaceWar;
@@ -11,16 +12,15 @@ public static IRollbackSession ParseArgs(
string[] args, RollbackOptions options
)
{
- if (args is not [{ } portArg, { } playerCountArg, .. { } endpoints]
+ if (args is not [{ } portArg, { } playerCountArg, .. { } lastArgs]
|| !int.TryParse(portArg, out var port)
- || !int.TryParse(playerCountArg, out var playerCount)
- )
+ || !int.TryParse(playerCountArg, out var playerCount))
throw new InvalidOperationException("Invalid port argument");
if (playerCount > Config.MaxShips)
throw new InvalidOperationException("Too many players");
- if (endpoints is ["sync-test"])
+ if (lastArgs is ["sync-test"])
return RollbackNetcode.CreateSyncTestSession(
options: options,
services: new()
@@ -29,12 +29,33 @@ public static IRollbackSession ParseArgs(
}
);
- if (endpoints is ["spectate", { } hostArg] && IPEndPoint.TryParse(hostArg, out var host))
+ if (lastArgs is ["spectate", { } hostArg] && IPEndPoint.TryParse(hostArg, out var host))
return RollbackNetcode.CreateSpectatorSession(
port, host, playerCount, options
);
- var players = endpoints.Select((x, i) => ParsePlayer(playerCount, i + 1, x)).ToArray();
+ if (lastArgs is ["replay", { } replayFile])
+ {
+ if (!File.Exists(replayFile))
+ throw new InvalidOperationException("Invalid replay file");
+
+ var inputs = SaveInputsToFileListener.GetInputs(playerCount, replayFile).ToArray();
+
+ return RollbackNetcode.CreateReplaySession(
+ playerCount, inputs
+ );
+ }
+
+
+ // save confirmed inputs to file
+ IInputListener? saveInputsListener = null;
+ if (lastArgs is ["--save-to", { } filename, .. var argsAfterSave])
+ {
+ saveInputsListener = new SaveInputsToFileListener(filename);
+ lastArgs = argsAfterSave;
+ }
+
+ var players = lastArgs.Select((x, i) => ParsePlayer(playerCount, i + 1, x)).ToArray();
var localPlayer = players.FirstOrDefault(x => x.IsLocal());
if (localPlayer is null)
@@ -43,6 +64,7 @@ public static IRollbackSession ParseArgs(
var session = RollbackNetcode.CreateSession(port, options, new()
{
// LogWriter = new FileLogWriter($"log_{localPlayer.Number}.log"),
+ InputListener = saveInputsListener,
});
session.AddPlayers(players);
diff --git a/samples/SpaceWar/SaveInputsToFileListener.cs b/samples/SpaceWar/SaveInputsToFileListener.cs
new file mode 100644
index 00000000..7b1daaaf
--- /dev/null
+++ b/samples/SpaceWar/SaveInputsToFileListener.cs
@@ -0,0 +1,51 @@
+using Backdash.Data;
+using Backdash.Sync.Input.Confirmed;
+using SpaceWar.Logic;
+
+namespace SpaceWar;
+
+sealed class SaveInputsToFileListener(string filename) : IInputListener
+{
+ const int InputSize = sizeof(PlayerInputs);
+ readonly FileStream fileStream = File.Create(filename);
+ readonly byte[] inputBuffer = new byte[InputSize];
+
+ public void OnConfirmed(in Frame frame, in ConfirmedInputs inputs)
+ {
+ for (var i = 0; i < inputs.Count; i++)
+ {
+ var input = (ushort)inputs.Inputs[i];
+ Array.Clear(inputBuffer);
+ if (!input.TryFormat(inputBuffer, out _))
+ throw new InvalidOperationException("unable to save input");
+
+ fileStream.Write(inputBuffer);
+ }
+
+ fileStream.Write("\n"u8);
+ }
+
+ public void Dispose() => fileStream.Dispose();
+
+ public static IEnumerable> GetInputs(int players, string file)
+ {
+ using var replayStream = File.OpenRead(file);
+ var buffer = new byte[InputSize * players];
+ var inputsBuffer = new PlayerInputs[players];
+ var lineBreak = new byte[1];
+
+ while (replayStream.Read(buffer) > 0)
+ {
+ for (var i = 0; i < players; i++)
+ {
+ if (ushort.TryParse(buffer.AsSpan().Slice(i * InputSize, InputSize), out var value))
+ inputsBuffer[i] = (PlayerInputs)value;
+ }
+
+ yield return new ConfirmedInputs(inputsBuffer.AsSpan()[..players]);
+
+ if (replayStream.Read(lineBreak) is 0 || lineBreak[0] != '\n')
+ throw new InvalidOperationException("invalid replay file content");
+ }
+ }
+}
diff --git a/samples/SpaceWar/SpaceWar.csproj b/samples/SpaceWar/SpaceWar.csproj
index 9592b2df..9f467b6d 100644
--- a/samples/SpaceWar/SpaceWar.csproj
+++ b/samples/SpaceWar/SpaceWar.csproj
@@ -13,6 +13,9 @@
+
+ PreserveNewest
+
diff --git a/samples/SpaceWar/replay.inputs b/samples/SpaceWar/replay.inputs
new file mode 100644
index 00000000..320c418a
Binary files /dev/null and b/samples/SpaceWar/replay.inputs differ
diff --git a/samples/SpaceWar/scripts/linux/start_2players_save.sh b/samples/SpaceWar/scripts/linux/start_2players_save.sh
new file mode 100644
index 00000000..3709244e
--- /dev/null
+++ b/samples/SpaceWar/scripts/linux/start_2players_save.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+dotnet build -c Release "$(dirname "$0")/../.."
+pushd "$(dirname "$0")/../../bin/Release/net8.0" || exit
+rm ./*.log
+dotnet SpaceWar.dll 9000 2 --save-to replay.inputs local 127.0.0.1:9001 &
+dotnet SpaceWar.dll 9001 2 127.0.0.1:9000 local &
+popd || exit
diff --git a/samples/SpaceWar/scripts/linux/start_replay.sh b/samples/SpaceWar/scripts/linux/start_replay.sh
new file mode 100644
index 00000000..f2956244
--- /dev/null
+++ b/samples/SpaceWar/scripts/linux/start_replay.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+dotnet build -c Release "$(dirname "$0")/../.."
+pushd "$(dirname "$0")/../../bin/Release/net8.0" || exit
+rm ./*.log
+dotnet SpaceWar.dll 9000 2 replay replay.inputs &
+popd || exit
diff --git a/samples/SpaceWar/scripts/windows/start_2players_save.cmd b/samples/SpaceWar/scripts/windows/start_2players_save.cmd
new file mode 100644
index 00000000..e86ae418
--- /dev/null
+++ b/samples/SpaceWar/scripts/windows/start_2players_save.cmd
@@ -0,0 +1,6 @@
+dotnet build -c Release %~dp0\..\..
+pushd %~dp0\..\..\bin\Release\net8.0
+del *.log
+start SpaceWar 9000 2 --save-to replay.inputs local 127.0.0.1:9001
+start SpaceWar 9001 2 127.0.0.1:9000 local
+popd
diff --git a/samples/SpaceWar/scripts/windows/start_replay.cmd b/samples/SpaceWar/scripts/windows/start_replay.cmd
new file mode 100644
index 00000000..2c16fefe
--- /dev/null
+++ b/samples/SpaceWar/scripts/windows/start_replay.cmd
@@ -0,0 +1,5 @@
+dotnet build -c Release %~dp0\..\..
+pushd %~dp0\..\..\bin\Release\net8.0
+del *.log
+start SpaceWar 9000 2 replay replay.inputs
+popd
diff --git a/src/Backdash/Backends/BackendServices.cs b/src/Backdash/Backends/BackendServices.cs
index 7a908791..73658b1a 100644
--- a/src/Backdash/Backends/BackendServices.cs
+++ b/src/Backdash/Backends/BackendServices.cs
@@ -4,6 +4,7 @@
using Backdash.Network.Protocol;
using Backdash.Serialization;
using Backdash.Sync.Input;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Sync.State;
using Backdash.Sync.State.Stores;
@@ -23,11 +24,13 @@ sealed class BackendServices
public IInputGenerator? InputGenerator { get; }
public IRandomNumberGenerator Random { get; }
public IDelayStrategy DelayStrategy { get; }
+ public IInputListener? InputListener { get; }
public BackendServices(RollbackOptions options, SessionServices? services)
{
ChecksumProvider = services?.ChecksumProvider ?? ChecksumProviderFactory.Create();
StateStore = services?.StateStore ?? StateStoreFactory.Create(services?.StateSerializer);
+ InputListener = services?.InputListener;
Random = new DefaultRandomNumberGenerator(services?.Random ?? System.Random.Shared);
DelayStrategy = DelayStrategyFactory.Create(Random, options.Protocol.DelayStrategy);
InputGenerator = services?.InputGenerator;
diff --git a/src/Backdash/Backends/Peer2PeerBackend.cs b/src/Backdash/Backends/Peer2PeerBackend.cs
index 9e8d85d1..59a0b2ce 100644
--- a/src/Backdash/Backends/Peer2PeerBackend.cs
+++ b/src/Backdash/Backends/Peer2PeerBackend.cs
@@ -8,7 +8,7 @@
using Backdash.Network.Protocol.Comm;
using Backdash.Serialization;
using Backdash.Sync.Input;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Sync.State;
namespace Backdash.Backends;
@@ -19,7 +19,7 @@ sealed class Peer2PeerBackend : IRollbackSession inputSerializer;
- readonly IBinarySerializer> inputGroupSerializer;
+ readonly IBinarySerializer> inputGroupSerializer;
readonly Logger logger;
readonly IStateStore stateStore;
readonly IProtocolClient udp;
@@ -28,15 +28,18 @@ sealed class Peer2PeerBackend : IRollbackSession peerInputEventQueue;
- readonly IProtocolInputEventPublisher> peerCombinedInputsEventPublisher;
+ readonly IProtocolInputEventPublisher> peerCombinedInputsEventPublisher;
readonly PeerConnectionFactory peerConnectionFactory;
- readonly List>> spectators;
+ readonly List>> spectators;
readonly List?> endpoints;
readonly HashSet addedPlayers = [];
readonly HashSet addedSpectators = [];
+ readonly IInputListener? inputListener;
+
bool isSynchronizing = true;
int nextRecommendedInterval;
Frame nextSpectatorFrame = Frame.Zero;
+ Frame nextListenerFrame = Frame.Zero;
IRollbackHandler callbacks;
SynchronizedInput[] syncInputBuffer = [];
Task backgroundJobTask = Task.CompletedTask;
@@ -61,9 +64,11 @@ BackendServices services
stateStore = services.StateStore;
backgroundJobManager = services.JobManager;
logger = services.Logger;
+ inputListener = services.InputListener;
+
peerInputEventQueue = new ProtocolInputEventQueue();
peerCombinedInputsEventPublisher = new ProtocolCombinedInputsEventPublisher(peerInputEventQueue);
- inputGroupSerializer = new CombinedInputsSerializer(inputSerializer);
+ inputGroupSerializer = new ConfirmedInputsSerializer(inputSerializer);
localConnections = new(Max.NumberOfPlayers);
spectators = [];
endpoints = [];
@@ -107,6 +112,7 @@ public void Dispose()
stateStore.Dispose();
logger.Dispose();
backgroundJobManager.Dispose();
+ inputListener?.Dispose();
}
public void Close()
@@ -431,19 +437,36 @@ void DoSync()
{
if (NumberOfSpectators > 0)
{
- GameInput> confirmed = new(nextSpectatorFrame);
+ GameInput> confirmed = new(nextSpectatorFrame);
while (nextSpectatorFrame <= minConfirmedFrame)
{
- logger.Write(LogLevel.Debug, $"pushing frame {nextSpectatorFrame} to spectators");
if (!synchronizer.GetConfirmedInputGroup(in nextSpectatorFrame, ref confirmed))
break;
+
+ logger.Write(LogLevel.Debug, $"pushing frame {nextSpectatorFrame} to spectators");
for (var s = 0; s < spectators.Count; s++)
if (spectators[s].IsRunning)
spectators[s].SendInput(in confirmed);
+
nextSpectatorFrame++;
}
}
+ if (inputListener is not null)
+ {
+ GameInput> confirmed = new(nextListenerFrame);
+ while (nextListenerFrame <= minConfirmedFrame)
+ {
+ if (!synchronizer.GetConfirmedInputGroup(in nextListenerFrame, ref confirmed))
+ break;
+
+ logger.Write(LogLevel.Debug, $"pushing frame {nextListenerFrame} to listener");
+ inputListener.OnConfirmed(in confirmed.Frame, in confirmed.Data);
+
+ nextListenerFrame++;
+ }
+ }
+
logger.Write(LogLevel.Debug, $"setting confirmed frame in sync to {minConfirmedFrame}");
synchronizer.SetLastConfirmedFrame(minConfirmedFrame);
}
diff --git a/src/Backdash/Backends/ReplayBackend.cs b/src/Backdash/Backends/ReplayBackend.cs
new file mode 100644
index 00000000..859d1bed
--- /dev/null
+++ b/src/Backdash/Backends/ReplayBackend.cs
@@ -0,0 +1,131 @@
+using System.Diagnostics;
+using Backdash.Core;
+using Backdash.Data;
+using Backdash.Network;
+using Backdash.Sync.Input.Confirmed;
+
+namespace Backdash.Backends;
+
+sealed class ReplayBackend : IRollbackSession
+ where TInput : struct
+ where TGameState : notnull, new()
+{
+ readonly Logger logger;
+ readonly PlayerHandle[] fakePlayers;
+ IRollbackHandler callbacks;
+
+ bool isSynchronizing = true;
+ SynchronizedInput[] syncInputBuffer = [];
+
+ bool disposed;
+ bool closed;
+
+ readonly IReadOnlyList> inputList;
+
+ public ReplayBackend(
+ int numberOfPlayers,
+ IReadOnlyList> inputList,
+ BackendServices services
+ )
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ this.inputList = inputList;
+ logger = services.Logger;
+ NumberOfPlayers = numberOfPlayers;
+ fakePlayers = Enumerable.Range(0, numberOfPlayers)
+ .Select(x => new PlayerHandle(PlayerType.Remote, x + 1, x)).ToArray();
+
+ callbacks = new EmptySessionHandler(logger);
+ }
+
+ public void Dispose()
+ {
+ if (disposed) return;
+ disposed = true;
+ Close();
+ logger.Dispose();
+ }
+
+ public void Close()
+ {
+ if (closed) return;
+ closed = true;
+ logger.Write(LogLevel.Information, "Shutting down connections");
+ callbacks.OnSessionClose();
+ }
+
+ public Frame CurrentFrame { get; private set; } = Frame.Zero;
+ public FrameSpan RollbackFrames => FrameSpan.Zero;
+ public FrameSpan FramesBehind => FrameSpan.Zero;
+ public int NumberOfPlayers { get; private set; }
+ public int NumberOfSpectators => 0;
+ public bool IsSpectating => true;
+ public void DisconnectPlayer(in PlayerHandle player) { }
+ public ResultCode AddLocalInput(PlayerHandle player, TInput localInput) => ResultCode.Ok;
+ public IReadOnlyCollection GetPlayers() => fakePlayers;
+ public IReadOnlyCollection GetSpectators() => [];
+
+ public void BeginFrame() { }
+
+ public void AdvanceFrame()
+ {
+ logger.Write(LogLevel.Debug, $"[End Frame {CurrentFrame}]");
+ }
+
+ public PlayerConnectionStatus GetPlayerStatus(in PlayerHandle player) => PlayerConnectionStatus.Connected;
+ public ResultCode AddPlayer(Player player) => ResultCode.NotSupported;
+
+ public IReadOnlyList AddPlayers(IReadOnlyList players) =>
+ Enumerable.Repeat(ResultCode.NotSupported, players.Count).ToArray();
+
+ public bool GetNetworkStatus(in PlayerHandle player, ref PeerNetworkStats info) => true;
+
+ public void SetFrameDelay(PlayerHandle player, int delayInFrames) { }
+
+ public void Start(CancellationToken stoppingToken = default)
+ {
+ callbacks.OnSessionStart();
+ isSynchronizing = false;
+ }
+
+ public Task WaitToStop(CancellationToken stoppingToken = default) => Task.CompletedTask;
+
+ public void SetHandler(IRollbackHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ callbacks = handler;
+ }
+
+ public ResultCode SynchronizeInputs()
+ {
+ if (isSynchronizing)
+ return ResultCode.NotSynchronized;
+
+ if (CurrentFrame.Number >= inputList.Count)
+ return ResultCode.NotSynchronized;
+
+ var confirmed = inputList[CurrentFrame.Number];
+
+ if (confirmed.Count is 0 && CurrentFrame == Frame.Zero)
+ return ResultCode.NotSynchronized;
+
+ Trace.Assert(confirmed.Count > 0);
+ NumberOfPlayers = confirmed.Count;
+
+ if (syncInputBuffer.Length != NumberOfPlayers)
+ Array.Resize(ref syncInputBuffer, NumberOfPlayers);
+
+ for (var i = 0; i < NumberOfPlayers; i++)
+ syncInputBuffer[i] = new(confirmed.Inputs[i], false);
+
+ CurrentFrame++;
+ return ResultCode.Ok;
+ }
+
+ public ref readonly SynchronizedInput GetInput(int index) =>
+ ref syncInputBuffer[index];
+
+ public ref readonly SynchronizedInput GetInput(in PlayerHandle player) =>
+ ref syncInputBuffer[player.Number - 1];
+}
diff --git a/src/Backdash/Backends/SpectatorBackend.cs b/src/Backdash/Backends/SpectatorBackend.cs
index ae8c969f..543e8ef7 100644
--- a/src/Backdash/Backends/SpectatorBackend.cs
+++ b/src/Backdash/Backends/SpectatorBackend.cs
@@ -8,14 +8,14 @@
using Backdash.Network.Protocol;
using Backdash.Serialization;
using Backdash.Sync.Input;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
namespace Backdash.Backends;
sealed class SpectatorBackend :
IRollbackSession,
IProtocolNetworkEventHandler,
- IProtocolInputEventPublisher>
+ IProtocolInputEventPublisher>
where TInput : struct
where TGameState : notnull, new()
{
@@ -26,8 +26,8 @@ sealed class SpectatorBackend :
readonly IBackgroundJobManager backgroundJobManager;
readonly IClock clock;
readonly ConnectionsState localConnections = new(0);
- readonly GameInput>[] inputs;
- readonly PeerConnection> host;
+ readonly GameInput>[] inputs;
+ readonly PeerConnection> host;
readonly PlayerHandle[] fakePlayers;
IRollbackHandler callbacks;
bool isSynchronizing;
@@ -55,11 +55,11 @@ public SpectatorBackend(int port,
NumberOfPlayers = numberOfPlayers;
fakePlayers = Enumerable.Range(0, numberOfPlayers)
.Select(x => new PlayerHandle(PlayerType.Remote, x + 1, x)).ToArray();
- IBinarySerializer> inputGroupSerializer =
- new CombinedInputsSerializer(services.InputSerializer);
+ IBinarySerializer> inputGroupSerializer =
+ new ConfirmedInputsSerializer(services.InputSerializer);
PeerObserverGroup peerObservers = new();
callbacks = new EmptySessionHandler(logger);
- inputs = new GameInput>[options.SpectatorInputBufferLength];
+ inputs = new GameInput>[options.SpectatorInputBufferLength];
udp = services.ProtocolClientFactory.CreateProtocolClient(port, peerObservers);
backgroundJobManager.Register(udp);
@@ -234,7 +234,7 @@ public ref readonly SynchronizedInput GetInput(int index) =>
public ref readonly SynchronizedInput GetInput(in PlayerHandle player) =>
ref syncInputBuffer[player.Number - 1];
- void IProtocolInputEventPublisher>.Publish(in GameInputEvent> evt)
+ void IProtocolInputEventPublisher>.Publish(in GameInputEvent> evt)
{
lastReceivedInputTime = clock.GetTimeStamp();
var (_, input) = evt;
diff --git a/src/Backdash/Network/Protocol/Comm/InputEncoder.cs b/src/Backdash/Network/Protocol/Comm/InputEncoder.cs
index 7da1cdda..baf4cbab 100644
--- a/src/Backdash/Network/Protocol/Comm/InputEncoder.cs
+++ b/src/Backdash/Network/Protocol/Comm/InputEncoder.cs
@@ -1,10 +1,13 @@
using Backdash.Network.Messages;
using Backdash.Serialization.Encoding;
+
namespace Backdash.Network.Protocol.Comm;
+
static class InputEncoder
{
public static DeltaXorRle.Encoder GetCompressor(ref InputMessage inputMsg, Span lastBuffer) =>
new(inputMsg.Bits, lastBuffer);
+
public static DeltaXorRle.Decoder GetDecompressor(ref InputMessage inputMsg) =>
new(inputMsg.Bits, inputMsg.NumBits);
}
diff --git a/src/Backdash/Network/Protocol/Comm/ProtocolInbox.cs b/src/Backdash/Network/Protocol/Comm/ProtocolInbox.cs
index c18a9f03..3342f121 100644
--- a/src/Backdash/Network/Protocol/Comm/ProtocolInbox.cs
+++ b/src/Backdash/Network/Protocol/Comm/ProtocolInbox.cs
@@ -6,6 +6,7 @@
using Backdash.Network.Messages;
using Backdash.Serialization;
using Backdash.Sync.Input;
+
namespace Backdash.Network.Protocol.Comm;
interface IProtocolInbox : IPeerObserver where TInput : struct
@@ -119,6 +120,7 @@ bool HandleMessage(ref ProtocolMessage message, out ProtocolMessage replyMsg)
return handled;
}
+
bool OnInput(ref InputMessage msg)
{
logger.Write(LogLevel.Trace, $"Acked Frame: {LastAckedFrame}");
diff --git a/src/Backdash/Network/Protocol/Comm/ProtocolInputBuffer.cs b/src/Backdash/Network/Protocol/Comm/ProtocolInputBuffer.cs
index 481dc914..57a8eae0 100644
--- a/src/Backdash/Network/Protocol/Comm/ProtocolInputBuffer.cs
+++ b/src/Backdash/Network/Protocol/Comm/ProtocolInputBuffer.cs
@@ -5,7 +5,9 @@
using Backdash.Serialization;
using Backdash.Sync;
using Backdash.Sync.Input;
+
namespace Backdash.Network.Protocol.Comm;
+
interface IProtocolInputBuffer where TInput : struct
{
int PendingNumber { get; }
@@ -13,6 +15,7 @@ interface IProtocolInputBuffer where TInput : struct
SendInputResult SendInput(in GameInput input);
SendInputResult SendPendingInputs();
}
+
enum SendInputResult : byte
{
Ok = 0,
@@ -20,6 +23,7 @@ enum SendInputResult : byte
MessageBodyOverflow,
AlreadyAcked,
}
+
sealed class ProtocolInputBuffer : IProtocolInputBuffer
where TInput : struct
{
@@ -38,6 +42,7 @@ sealed class ProtocolInputBuffer : IProtocolInputBuffer
readonly IMessageSender sender;
readonly IProtocolInbox inbox;
public int PendingNumber => pendingOutput.Count;
+
public ProtocolInputBuffer(ProtocolOptions options,
IBinaryWriter inputSerializer,
ProtocolState state,
@@ -58,8 +63,10 @@ public ProtocolInputBuffer(ProtocolOptions options,
workingBufferMemory = Mem.CreatePinnedMemory(WorkingBufferFactor * inputSize);
pendingOutput = new(options.MaxPendingInputs);
}
+
const int WorkingBufferFactor = 3;
bool IsQueueFull() => pendingOutput.Count >= options.MaxPendingInputs;
+
public SendInputResult SendInput(in GameInput input)
{
if (state.CurrentStatus is ProtocolStatus.Running)
@@ -69,14 +76,17 @@ public SendInputResult SendInput(in GameInput input)
timeSync.AdvanceFrame(in input, in state.Fairness);
pendingOutput.Enqueue(input);
}
+
return SendPendingInputs();
}
+
public SendInputResult SendPendingInputs()
{
var createMessageResult = CreateInputMessage(out var inputMessage);
sender.SendMessage(in inputMessage);
return createMessageResult;
}
+
SendInputResult CreateInputMessage(out ProtocolMessage protocolMessage)
{
Span workingBuffer = workingBufferMemory.Span;
@@ -99,6 +109,7 @@ SendInputResult CreateInputMessage(out ProtocolMessage protocolMessage)
lastAckedInput = acked;
lastAckSize = inputSerializer.Serialize(in lastAckedInput.Data, lastAckBytes);
}
+
Trace.Assert(lastAckedInput.Frame.IsNull || lastAckedInput.Frame.Next() == lastAckFrame);
var current = pendingOutput.Peek();
var currentSize = inputSerializer.Serialize(in current.Data, currentBytes);
@@ -134,10 +145,12 @@ SendInputResult CreateInputMessage(out ProtocolMessage protocolMessage)
break;
}
}
+
inputMessage.InputSize = (byte)lastSentSize;
inputMessage.NumBits = compressor.BitOffset;
inputMessage.DisconnectRequested = state.CurrentStatus is ProtocolStatus.Disconnected;
}
+
inputMessage.AckFrame = inbox.LastReceivedInput.Frame;
state.LocalConnectStatuses.CopyTo(inputMessage.PeerConnectStatus);
Trace.Assert(inputMessage.NumBits <= Max.CompressedBytes * ByteSize.ByteToBits);
diff --git a/src/Backdash/Network/Protocol/Comm/ProtocolOutbox.cs b/src/Backdash/Network/Protocol/Comm/ProtocolOutbox.cs
index f7415fcb..b7508f53 100644
--- a/src/Backdash/Network/Protocol/Comm/ProtocolOutbox.cs
+++ b/src/Backdash/Network/Protocol/Comm/ProtocolOutbox.cs
@@ -74,7 +74,9 @@ public async Task Start(CancellationToken cancellationToken)
message.Header.Magic = magicNumber;
message.Header.SequenceNumber = (ushort)nextSendSeq;
nextSendSeq++;
+
logger.Write(LogLevel.Trace, $"send {message} on {state.Player}");
+
if (sendLatency > TimeSpan.Zero)
{
var jitter = delayStrategy.Jitter(sendLatency);
@@ -90,6 +92,7 @@ public async Task Start(CancellationToken cancellationToken)
var bytesSent = await peer
.SendTo(entry.Recipient, message, buffer, cancellationToken)
.ConfigureAwait(false);
+
state.Stats.Send.LastTime = clock.GetTimeStamp();
state.Stats.Send.TotalBytes += (ByteSize)bytesSent;
state.Stats.Send.TotalPackets++;
diff --git a/src/Backdash/Network/ProtocolInputEventQueue.cs b/src/Backdash/Network/ProtocolInputEventQueue.cs
index 85928e3f..19229ad5 100644
--- a/src/Backdash/Network/ProtocolInputEventQueue.cs
+++ b/src/Backdash/Network/ProtocolInputEventQueue.cs
@@ -1,7 +1,8 @@
using System.Diagnostics;
using System.Threading.Channels;
using Backdash.Sync.Input;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
+
namespace Backdash.Network;
readonly record struct GameInputEvent(PlayerHandle Player, GameInput Input)
where TInput : struct
@@ -45,10 +46,10 @@ public void Dispose()
}
}
sealed class ProtocolCombinedInputsEventPublisher(IProtocolInputEventPublisher peerInputEventPublisher)
- : IProtocolInputEventPublisher>
+ : IProtocolInputEventPublisher>
where TInput : struct
{
- public void Publish(in GameInputEvent> evt)
+ public void Publish(in GameInputEvent> evt)
{
var player = evt.Player;
var frame = evt.Input.Frame;
diff --git a/src/Backdash/RollbackNetcode.cs b/src/Backdash/RollbackNetcode.cs
index 0827f0d1..47ee4b47 100644
--- a/src/Backdash/RollbackNetcode.cs
+++ b/src/Backdash/RollbackNetcode.cs
@@ -3,6 +3,7 @@
using Backdash.Core;
using Backdash.Data;
using Backdash.Network.Client;
+using Backdash.Sync.Input.Confirmed;
namespace Backdash;
@@ -58,6 +59,24 @@ public static IRollbackSession CreateSpectatorSession
+ /// Initializes new replay session.
+ ///
+ /// Session player count
+ /// Inputs to be replayed
+ /// Session customizable dependencies
+ /// Game input type
+ /// Game state type
+ public static IRollbackSession CreateReplaySession(
+ int numberOfPlayers,
+ IReadOnlyList> inputs,
+ SessionServices? services = null)
+ where TInput : struct
+ where TGameState : notnull, new() =>
+ new ReplayBackend(
+ numberOfPlayers, inputs,
+ BackendServices.Create(new RollbackOptions(), services));
+
///
/// Initializes new sync test session.
///
diff --git a/src/Backdash/SessionServices.cs b/src/Backdash/SessionServices.cs
index d11145a0..49984891 100644
--- a/src/Backdash/SessionServices.cs
+++ b/src/Backdash/SessionServices.cs
@@ -2,6 +2,7 @@
using Backdash.Network.Client;
using Backdash.Serialization;
using Backdash.Sync.Input;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Sync.State;
using Backdash.Sync.State.Stores;
@@ -56,4 +57,9 @@ public sealed class SessionServices
/// Default random service
///
public Random? Random { get; set; }
+
+ ///
+ /// Service to listen for confirmed inputs
+ ///
+ public IInputListener? InputListener { get; set; }
}
diff --git a/src/Backdash/Sync/Input/Confirmed/ConfirmedInputs.cs b/src/Backdash/Sync/Input/Confirmed/ConfirmedInputs.cs
new file mode 100644
index 00000000..9ce9d08e
--- /dev/null
+++ b/src/Backdash/Sync/Input/Confirmed/ConfirmedInputs.cs
@@ -0,0 +1,51 @@
+using System.Runtime.CompilerServices;
+using Backdash.Core;
+
+namespace Backdash.Sync.Input.Confirmed;
+
+///
+/// All confirmed inputs for all players
+///
+///
+public record struct ConfirmedInputs where TInput : struct
+{
+ ///
+ /// Number of inputs
+ ///
+ public byte Count = InputArray.Capacity;
+
+ ///
+ /// Input array
+ ///
+ public InputArray Inputs = new();
+
+ ///
+ /// Initialized with full size
+ ///
+ public ConfirmedInputs() => Count = InputArray.Capacity;
+
+ ///
+ /// Initialized from span
+ ///
+ public ConfirmedInputs(ReadOnlySpan inputs)
+ {
+ Count = (byte)inputs.Length;
+ inputs.CopyTo(Inputs);
+ }
+}
+
+///
+/// Array of inputs for all players
+///
+///
+[InlineArray(Capacity)]
+public struct InputArray where TInput : struct
+{
+ ///
+ /// Max size of
+ ///
+ ///
+ public const int Capacity = Max.NumberOfPlayers;
+
+ TInput element0;
+}
diff --git a/src/Backdash/Sync/Input/Spectator/InputGroupSerializer.cs b/src/Backdash/Sync/Input/Confirmed/ConfirmedInputsSerializer.cs
similarity index 73%
rename from src/Backdash/Sync/Input/Spectator/InputGroupSerializer.cs
rename to src/Backdash/Sync/Input/Confirmed/ConfirmedInputsSerializer.cs
index b3dac11f..e92645e2 100644
--- a/src/Backdash/Sync/Input/Spectator/InputGroupSerializer.cs
+++ b/src/Backdash/Sync/Input/Confirmed/ConfirmedInputsSerializer.cs
@@ -1,10 +1,12 @@
using Backdash.Serialization;
using Backdash.Serialization.Buffer;
-namespace Backdash.Sync.Input.Spectator;
-sealed class CombinedInputsSerializer(IBinarySerializer inputSerializer)
- : BinarySerializer> where T : struct
+
+namespace Backdash.Sync.Input.Confirmed;
+
+sealed class ConfirmedInputsSerializer(IBinarySerializer inputSerializer)
+ : BinarySerializer> where T : struct
{
- protected override void Serialize(in BinarySpanWriter binaryWriter, in CombinedInputs data)
+ protected override void Serialize(in BinarySpanWriter binaryWriter, in ConfirmedInputs data)
{
binaryWriter.Write(data.Count);
for (var i = 0; i < data.Count; i++)
@@ -13,7 +15,8 @@ protected override void Serialize(in BinarySpanWriter binaryWriter, in CombinedI
binaryWriter.Advance(size);
}
}
- protected override void Deserialize(in BinarySpanReader binaryReader, ref CombinedInputs result)
+
+ protected override void Deserialize(in BinarySpanReader binaryReader, ref ConfirmedInputs result)
{
result.Count = binaryReader.ReadByte();
for (var i = 0; i < result.Count; i++)
diff --git a/src/Backdash/Sync/Input/Confirmed/IInputListener.cs b/src/Backdash/Sync/Input/Confirmed/IInputListener.cs
new file mode 100644
index 00000000..6534340d
--- /dev/null
+++ b/src/Backdash/Sync/Input/Confirmed/IInputListener.cs
@@ -0,0 +1,14 @@
+using Backdash.Data;
+
+namespace Backdash.Sync.Input.Confirmed;
+
+///
+/// Listen for confirmed input
+///
+public interface IInputListener : IDisposable where TInput : struct
+{
+ ///
+ /// New confirmed input event handler
+ ///
+ void OnConfirmed(in Frame frame, in ConfirmedInputs inputs);
+}
diff --git a/src/Backdash/Sync/Input/Spectator/CombinedInputs.cs b/src/Backdash/Sync/Input/Spectator/CombinedInputs.cs
deleted file mode 100644
index 455a60a2..00000000
--- a/src/Backdash/Sync/Input/Spectator/CombinedInputs.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.Runtime.CompilerServices;
-using Backdash.Core;
-namespace Backdash.Sync.Input.Spectator;
-record struct CombinedInputs where TInput : struct
-{
- public byte Count = InputArray.Capacity;
- public InputArray Inputs = new();
- public CombinedInputs() => Count = InputArray.Capacity;
- public CombinedInputs(ReadOnlySpan inputs)
- {
- Count = (byte)inputs.Length;
- inputs.CopyTo(Inputs);
- }
-}
-[InlineArray(Capacity)]
-struct InputArray where TInput : struct
-{
- public const int Capacity = Max.NumberOfPlayers;
- public TInput element0;
-}
diff --git a/src/Backdash/Sync/Input/Synchronizer.cs b/src/Backdash/Sync/Input/Synchronizer.cs
index ccb98fcf..906359a1 100644
--- a/src/Backdash/Sync/Input/Synchronizer.cs
+++ b/src/Backdash/Sync/Input/Synchronizer.cs
@@ -2,7 +2,7 @@
using Backdash.Core;
using Backdash.Data;
using Backdash.Network;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Sync.State;
namespace Backdash.Sync.Input;
@@ -88,7 +88,7 @@ public bool AddLocalInput(in PlayerHandle queue, ref GameInput input)
public void AddRemoteInput(in PlayerHandle player, GameInput input) => AddInput(in player, ref input);
- public bool GetConfirmedInputGroup(in Frame frame, ref GameInput> confirmed)
+ public bool GetConfirmedInputGroup(in Frame frame, ref GameInput> confirmed)
{
confirmed.Data.Count = (byte)NumberOfPlayers;
confirmed.Frame = frame;
diff --git a/tests/Backdash.Tests/Specs/Unit/Input/InputGroupTests.cs b/tests/Backdash.Tests/Specs/Unit/Input/InputGroupTests.cs
index 611c7c5e..bdb9bc11 100644
--- a/tests/Backdash.Tests/Specs/Unit/Input/InputGroupTests.cs
+++ b/tests/Backdash.Tests/Specs/Unit/Input/InputGroupTests.cs
@@ -1,22 +1,22 @@
using Backdash.Network;
using Backdash.Serialization;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Tests.TestUtils;
namespace Backdash.Tests.Specs.Unit.Input;
-public class CombinedInputsTests
+public class ConfirmedInputsTests
{
[PropertyTest]
- internal void ShouldSerializeAndDeserializeGroupSamples(CombinedInputs inputData, bool network)
+ internal void ShouldSerializeAndDeserializeGroupSamples(ConfirmedInputs inputData, bool network)
{
- IBinarySerializer> serializer =
- new CombinedInputsSerializer(new IntegerBinarySerializer(Platform.GetEndianness(network)))
+ IBinarySerializer> serializer =
+ new ConfirmedInputsSerializer(new IntegerBinarySerializer(Platform.GetEndianness(network)))
{
Network = network,
};
Span buffer = stackalloc byte[(inputData.Count * sizeof(int)) + 1];
var writtenCount = serializer.Serialize(inputData, buffer);
- CombinedInputs result = new();
+ ConfirmedInputs result = new();
var readCount = serializer.Deserialize(buffer, ref result);
readCount.Should().Be(writtenCount);
result.Should().BeEquivalentTo(inputData);
diff --git a/tests/Backdash.Tests/TestUtils/PropertyTestGenerators.cs b/tests/Backdash.Tests/TestUtils/PropertyTestGenerators.cs
index c397a8c1..e88e5b22 100644
--- a/tests/Backdash.Tests/TestUtils/PropertyTestGenerators.cs
+++ b/tests/Backdash.Tests/TestUtils/PropertyTestGenerators.cs
@@ -4,7 +4,7 @@
using Backdash.Data;
using Backdash.Network.Messages;
using Backdash.Sync.Input;
-using Backdash.Sync.Input.Spectator;
+using Backdash.Sync.Input.Confirmed;
using Backdash.Tests.TestUtils.Types;
namespace Backdash.Tests.TestUtils;
@@ -323,13 +323,13 @@ from w in Arb.Generate()
select new Quaternion(x, y, z, w)
);
- public static Arbitrary> InputGroupGenerator() where T : struct =>
+ public static Arbitrary> InputGroupGenerator() where T : struct =>
Gen.Sized(testSize =>
{
var size = Math.Min(testSize, InputArray.Capacity);
return Gen.ArrayOf(size, Arb.Generate());
})
- .Select(arr => new CombinedInputs(arr))
+ .Select(arr => new ConfirmedInputs(arr))
.ToArbitrary();
public static Arbitrary> EquatableArrayGenerator() where T : IEquatable =>