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 =>