diff --git a/src/input/GameInput.cs b/src/input/GameInput.cs index e2d0ec9..63aa122 100644 --- a/src/input/GameInput.cs +++ b/src/input/GameInput.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Diagnostics; -namespace PleaseResync.input +namespace PleaseResync.Input { internal class GameInput { diff --git a/src/input/InputQueue.cs b/src/input/InputQueue.cs index 7c70b31..897ea80 100644 --- a/src/input/InputQueue.cs +++ b/src/input/InputQueue.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.Diagnostics; +using System.Diagnostics; -namespace PleaseResync.input +namespace PleaseResync.Input { internal class InputQueue { @@ -73,4 +72,4 @@ public void ResetPrediction(int frame) private int PreviousFrame(int offset) => offset == 0 ? QueueSize - 1 : offset - 1; } -} \ No newline at end of file +} diff --git a/src/session/Device.cs b/src/session/Device.cs index 5030f6a..977c386 100644 --- a/src/session/Device.cs +++ b/src/session/Device.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System; -namespace PleaseResync.session +namespace PleaseResync.Session { public class Device { diff --git a/src/session/DeviceMessage.cs b/src/session/DeviceMessage.cs index 272e398..17c43dd 100644 --- a/src/session/DeviceMessage.cs +++ b/src/session/DeviceMessage.cs @@ -1,6 +1,6 @@ using MessagePack; -namespace PleaseResync.session +namespace PleaseResync.Session { [Union(0, typeof(DeviceSyncMessage))] [Union(1, typeof(DeviceSyncConfirmMessage))] diff --git a/src/session/Session.cs b/src/session/Session.cs index 5b24450..ed6b0a1 100644 --- a/src/session/Session.cs +++ b/src/session/Session.cs @@ -1,9 +1,7 @@ using System.Diagnostics; using System.Collections.Generic; -using System.Net; -using System; -namespace PleaseResync.session +namespace PleaseResync.Session { /// /// Session is responsible for managing a pool of devices wanting to play your game together. @@ -59,8 +57,11 @@ public abstract class Session /// The size in bits of the input for one player. /// The number of devices taking part in this session. /// The total number of players accross all devices taking part in this session. - public Session(uint inputSize, uint deviceCount, uint totalPlayerCount, bool offline) + public Session(uint inputSize, uint deviceCount, uint totalPlayerCount, bool offline, bool replay = false) { + // we dont care about any of this when we are replaying + if (replay) return; + Debug.Assert(inputSize > 0); Debug.Assert(inputSize <= LIMIT_INPUT_SIZE); Debug.Assert(deviceCount >= 1); @@ -118,5 +119,7 @@ public Session(uint inputSize, uint deviceCount, uint totalPlayerCount, bool off public abstract uint RollbackFrames(); public abstract uint AverageRollbackFrames(); public abstract int State(); + + public abstract void SaveToReplayFile(); } -} \ No newline at end of file +} diff --git a/src/session/SessionAction.cs b/src/session/SessionAction.cs index 7becc28..2299ca0 100644 --- a/src/session/SessionAction.cs +++ b/src/session/SessionAction.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using PleaseResync.synchronization; +using PleaseResync.Synchronization; -namespace PleaseResync.session +namespace PleaseResync.Session { /// /// SessionAction is an action you must fulfill to give a chance to the Session to synchronize with other sessions. diff --git a/src/session/SessionAdapter.cs b/src/session/SessionAdapter.cs index 13a0121..c996211 100644 --- a/src/session/SessionAdapter.cs +++ b/src/session/SessionAdapter.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PleaseResync.session +namespace PleaseResync.Session { /// /// SessionAdapter is the interface used to implement a way for the Session to communicate with remote devices. diff --git a/src/session/SessionEvent.cs b/src/session/SessionEvent.cs index b425a17..dd6b0e2 100644 --- a/src/session/SessionEvent.cs +++ b/src/session/SessionEvent.cs @@ -1,4 +1,4 @@ -namespace PleaseResync.session +namespace PleaseResync.Session { public abstract class SessionEvent { diff --git a/src/session/adapters/LiteNetLibSessionAdapter.cs b/src/session/adapters/LiteNetLibSessionAdapter.cs index ea38201..3b8781f 100644 --- a/src/session/adapters/LiteNetLibSessionAdapter.cs +++ b/src/session/adapters/LiteNetLibSessionAdapter.cs @@ -6,7 +6,7 @@ using System.Linq; using MessagePack; -namespace PleaseResync.session.adapters +namespace PleaseResync.Session.Adapters { public class LiteNetLibSessionAdapter : SessionAdapter, INetEventListener { diff --git a/src/session/backends/Peer2PeerSession.cs b/src/session/backends/Peer2PeerSession.cs index 110f251..21a910d 100644 --- a/src/session/backends/Peer2PeerSession.cs +++ b/src/session/backends/Peer2PeerSession.cs @@ -2,8 +2,9 @@ using System.Diagnostics; using System.Collections.Generic; using PleaseResync.synchronization; +using PleaseResync.Session.Backends.Utility; -namespace PleaseResync.session.backends +namespace PleaseResync.Session.Backends { /// /// Peer2PeerSession implements a session for devices wanting to play your game together via network. @@ -126,6 +127,8 @@ public override List AdvanceFrame(byte[] localInput) Debug.Assert(IsRunning(), "Session must be running before calling AdvanceFrame"); Debug.Assert(localInput != null); + if (Frame() == 1000) SaveToReplayFile(); + Poll(); return _sync.AdvanceSync(_localDevice.Id, localInput); } @@ -168,5 +171,7 @@ internal protected override void AddRemoteInput(uint deviceId, DeviceInputMessag public override uint RollbackFrames() => _sync.RollbackFrames(); public override uint AverageRollbackFrames() => _sync.AverageRollbackFrames(); public override int State() => (int)_sync.State(); + + public override void SaveToReplayFile() => _sync.SaveToReplayFile(); } } diff --git a/src/session/backends/ReplaySession.cs b/src/session/backends/ReplaySession.cs new file mode 100644 index 0000000..0a07635 --- /dev/null +++ b/src/session/backends/ReplaySession.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using PleaseResync.Session.Backends.Utility; +using PleaseResync.Synchronization; + +namespace PleaseResync.Session.Backends +{ + public sealed class ReplaySession : Session + { + private enum PlaybackState + { + NoFile, + FileLoaded, + Running, + Paused + } + + private readonly Queue>> _commandQueue = new(); + private readonly BroadcastStream _broadcastStream; + private readonly StateStorage _stateStorage; + + private PlaybackState _currentState = PlaybackState.NoFile; + private int _currentFrame; + private int _targetFrame; + + public ReplaySession(): base(0, 0, 0, false, true) + { + _broadcastStream = new BroadcastStream(); + _stateStorage = new StateStorage(0); + _currentFrame = 0; + _targetFrame = -1; + } + + public void LoadFile(string filePath) + { + Enqueue(actions => + { + _broadcastStream.LoadReplayFile(filePath); + var initialState = _broadcastStream.GetInitialState(); + _stateStorage.SaveFrame(0, initialState); + + _currentFrame = 0; + _targetFrame = -1; + _currentState = PlaybackState.FileLoaded; + + // Immediately emit a load-game action so the engine knows to load state 0 + actions.Add(new SessionLoadGameAction(0, _stateStorage)); + }); + } + + public void Restart() + { + Enqueue(actions => + { + if (_currentState == PlaybackState.NoFile) return; + + _currentFrame = 0; + _broadcastStream.SetCurrentFrame(0); + _currentState = PlaybackState.Running; + + actions.Add(new SessionLoadGameAction(0, _stateStorage)); + }); + } + + public void Pause() + { + Enqueue(actions => + { + if (_currentState != PlaybackState.NoFile) + _currentState = PlaybackState.Paused; + }); + } + + public void Resume() + { + Enqueue(actions => + { + if (_currentState == PlaybackState.NoFile) return; + _currentState = PlaybackState.Running; + }); + } + + public void Step() + { + Enqueue(actions => + { + if (_currentState == PlaybackState.NoFile) return; + + if (_broadcastStream.GetFrameInput(out var frame, out var input)) + { + _currentFrame = frame; + actions.Add(new SessionAdvanceFrameAction(frame, input)); + } + + _currentState = PlaybackState.Paused; + }); + } + + public void GoToFrame(int frame) + { + Enqueue(actions => + { + if (_currentState == PlaybackState.NoFile) return; + + _targetFrame = frame; + if (_currentFrame > _targetFrame) + { + actions.Add(new SessionLoadGameAction(0, _stateStorage)); + _currentFrame = 0; + } + + _broadcastStream.SetCurrentFrame(_currentFrame); + + while (_currentFrame < _targetFrame) + { + if (!_broadcastStream.GetFrameInput(out var nextFrame, out var nextInput)) + break; + + _currentFrame = nextFrame; + actions.Add(new SessionAdvanceFrameAction(nextFrame, nextInput)); + } + + _currentState = PlaybackState.Paused; + _targetFrame = -1; + }); + } + + private void Enqueue(Action> command) => + _commandQueue.Enqueue(command); + + public override List AdvanceFrame(byte[] localInput = null) + { + var actions = new List(); + + while (_commandQueue.Count > 0) + { + var cmd = _commandQueue.Dequeue(); + cmd.Invoke(actions); + } + + if (_currentState == PlaybackState.Running) + { + if (_broadcastStream.GetFrameInput(out var frame, out var input)) + { + _currentFrame = frame; + actions.Add(new SessionAdvanceFrameAction(frame, input)); + } + } + + return actions; + } + + protected internal override Device LocalDevice => + throw new NotImplementedException(); + + protected internal override Device[] AllDevices => + throw new NotImplementedException(); + + public override void AddRemoteDevice(uint deviceId, uint playerCount, object remoteConfiguration) => + throw new NotImplementedException(); + + public override void AddSpectatorDevice(object remoteConfiguration) => + throw new NotImplementedException(); + + public override uint AverageRollbackFrames() => + throw new NotImplementedException(); + + public override int Frame() => _currentFrame; + + public override int FrameAdvantage() => + throw new NotImplementedException(); + + public override int FrameAdvantageDifference() => + throw new NotImplementedException(); + + public override bool IsRunning() => + _currentState != PlaybackState.NoFile; + + public override void Poll() + { + // No-op for replay + } + + public override int RemoteFrame() => + throw new NotImplementedException(); + + public override int RemoteFrameAdvantage() => + throw new NotImplementedException(); + + public override uint RollbackFrames() => + throw new NotImplementedException(); + + public override void SetLocalDevice(uint deviceId, uint playerCount, uint frameDelay) => + throw new NotImplementedException(); + + public override int State() => + throw new NotImplementedException(); + + protected internal override void AddRemoteInput(uint deviceId, DeviceInputMessage message) => + throw new NotImplementedException(); + + protected internal override uint SendMessageTo(uint deviceId, DeviceMessage message) => + throw new NotImplementedException(); + + public override void SaveToReplayFile() => + throw new NotImplementedException(); + } +} diff --git a/src/session/backends/SpectatorSession.cs b/src/session/backends/SpectatorSession.cs index 6b8658a..b969129 100644 --- a/src/session/backends/SpectatorSession.cs +++ b/src/session/backends/SpectatorSession.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using PleaseResync.session.backends.utility; +using PleaseResync.Session.Backends.Utility; -namespace PleaseResync.session.backends +namespace PleaseResync.Session.Backends { public class SpectatorSession : Session { @@ -46,11 +46,6 @@ public override List AdvanceFrame(byte[] localInput = null) { _currentFrame = frame; actions.Add(new SessionAdvanceFrameAction(frame, input)); - - if (frame == 1000|| frame == 5000 || frame == 10000) - { - _broadcastStream.SaveToFile(); - } } return actions; } @@ -130,7 +125,7 @@ protected internal override void AddRemoteInput(uint deviceId, DeviceInputMessag { if (deviceId != _broadcastDevice.Id) return; // discard messages from other devices var count = message.EndFrame - message.StartFrame + 1; - var inpSize = (int)_broadcastStream.InputSize; + var inpSize = (int)_broadcastStream.InputSize(); for (var i = 0; i < count; i++) { @@ -150,5 +145,7 @@ public override void AddSpectatorDevice(object remoteConfiguration) { throw new NotImplementedException(); } + + public override void SaveToReplayFile() => _broadcastStream.SaveReplayFile(); } } diff --git a/src/session/backends/SyncTestSession.cs b/src/session/backends/SyncTestSession.cs index e69de29..bc1cebe 100644 --- a/src/session/backends/SyncTestSession.cs +++ b/src/session/backends/SyncTestSession.cs @@ -0,0 +1 @@ +// Todo In the future. diff --git a/src/session/backends/utility/BroadcastStream.cs b/src/session/backends/utility/BroadcastStream.cs index b18d597..4c0a218 100644 --- a/src/session/backends/utility/BroadcastStream.cs +++ b/src/session/backends/utility/BroadcastStream.cs @@ -1,17 +1,24 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; -namespace PleaseResync.session.backends.utility +namespace PleaseResync.Session.Backends.Utility { public class BroadcastStream { - public readonly uint InputSize; + private uint _inputSize; private readonly int _initialFrameBuffer; private int _currentFrame, _availableFrame; - private readonly List _frameBuffer; + private List _frameBuffer; + + private ReplayFile _replay; + private List _initialState; public BroadcastStream(int initialBuffer = 30, uint inputSize = 1) { - InputSize = inputSize; + _initialState = []; + _replay = new ReplayFile(); + + _inputSize = inputSize; _initialFrameBuffer = initialBuffer; _currentFrame = 0; _availableFrame = -1; @@ -23,7 +30,7 @@ public void AddFrameInput(int frame, byte[] input) if (frame != _availableFrame + 1) return; // Append input to flat buffer - for (int i = 0; i < InputSize; i++) + for (var i = 0; i < _inputSize; i++) { _frameBuffer.Add(input[i]); } @@ -47,10 +54,10 @@ public bool GetFrameInput(out int frame, out byte[] input) } frame = _currentFrame; - input = new byte[InputSize]; + input = new byte[_inputSize]; - int startIndex = (int)(_currentFrame * InputSize); - for (int i = 0; i < InputSize; i++) + var startIndex = (int)(_currentFrame * _inputSize); + for (var i = 0; i < _inputSize; i++) { input[i] = _frameBuffer[startIndex + i]; } @@ -59,9 +66,42 @@ public bool GetFrameInput(out int frame, out byte[] input) return true; } - public void SaveToFile() + public string SaveReplayFile() + { + _replay.Init(_inputSize, _initialState); + _replay.SetData(_availableFrame, _frameBuffer); + var path = _replay.Save(); + return path; + } + + public void LoadReplayFile(string filepath) + { + _replay.LoadFromFile(filepath); + + _inputSize = _replay.InputSize; + + _currentFrame = 0; + _availableFrame = _replay.NumFrames; + + _frameBuffer.InsertRange(0, _replay.InputFrames); + + _initialState.Clear(); + _initialState.AddRange(_replay.InitialState); + } + + public uint InputSize() => _inputSize; + + public byte[] GetInitialState() => _initialState.ToArray(); + + public void SetInitialState(byte[] state) + { + _initialState.Clear(); + _initialState.AddRange(state); + } + + public void SetCurrentFrame(int frame) { - ReplayFile.SaveToFile(InputSize, _availableFrame, [], _frameBuffer); + _currentFrame = Math.Clamp(frame, 0, _availableFrame); } } } diff --git a/src/session/backends/utility/ReplayFile.cs b/src/session/backends/utility/ReplayFile.cs index b8a73f8..01d37c6 100644 --- a/src/session/backends/utility/ReplayFile.cs +++ b/src/session/backends/utility/ReplayFile.cs @@ -1,36 +1,69 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using MessagePack; -namespace PleaseResync.session.backends.utility +namespace PleaseResync.Session.Backends.Utility { [MessagePackObject] public class ReplayFile { [Key(0)] public uint InputSize; + [Key(1)] public int NumFrames; + [Key(2)] public List InitialState; + [Key(3)] public List InputFrames; - public static void SaveToFile(uint inpSize, int numFrames, List initState, List inpFrames) + public void Init(uint inputSize, List initialState) + { + InputSize = inputSize; + InitialState = new(initialState); + } + + public void SetData(int numFrames, List inputFrames) { - var file = new ReplayFile - { - InputSize = inpSize, - NumFrames = numFrames, - InitialState = Platform.RLEEncode(initState), - InputFrames = Platform.RLEEncode(inpFrames) - }; - - var fileData = MessagePackSerializer.Serialize(file); - string fileName = $"{Guid.NewGuid().ToString("N")}.PRReplay"; - - File.WriteAllBytesAsync(fileName, fileData); + NumFrames = numFrames; + InputFrames = new(inputFrames); + } + + public string Save(string folderPath = null) + { + if (string.IsNullOrWhiteSpace(folderPath)) + folderPath = "PRReplays"; + + Directory.CreateDirectory(folderPath); + + var rawData = MessagePackSerializer.Serialize(this); + + var compressed = Platform.RLEEncode(rawData.ToList()); + + var fileName = $"{Guid.NewGuid():N}.PRReplay"; + var fullPath = Path.Combine(folderPath, fileName); + + File.WriteAllBytes(fullPath, compressed.ToArray()); + return fullPath; + } + + public void LoadFromFile(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException("Replay file not found.", filePath); + + var compressed = File.ReadAllBytes(filePath); + + var rawData = Platform.RLEDecode(compressed.ToList()); + + var file = MessagePackSerializer.Deserialize(rawData.ToArray()); + + Init(file.InputSize, file.InitialState); + SetData(file.NumFrames, file.InputFrames); } } } diff --git a/src/synchronization/StateStorage.cs b/src/synchronization/StateStorage.cs index b451ff7..7cba1d8 100644 --- a/src/synchronization/StateStorage.cs +++ b/src/synchronization/StateStorage.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace PleaseResync.synchronization +namespace PleaseResync.Synchronization { internal class StateStorage { diff --git a/src/synchronization/Sync.cs b/src/synchronization/Sync.cs index 442da77..dc32068 100644 --- a/src/synchronization/Sync.cs +++ b/src/synchronization/Sync.cs @@ -1,8 +1,10 @@ using System.Diagnostics; using System.Collections.Generic; using System; -using PleaseResync.input; -using PleaseResync.session; +using PleaseResync.Input; +using PleaseResync.Session; +using PleaseResync.Synchronization; +using PleaseResync.Session.Backends.Utility; namespace PleaseResync.synchronization { @@ -28,6 +30,8 @@ public enum SyncState { SYNCING, RUNNING, DEVICE_LOST, DESYNCED } private uint _lastSentChecksum; private uint[] rollbackFrames; + private BroadcastStream _broadcastStream; + public Sync(Device[] devices, uint inputSize, bool offline, List spectators = null) { _devices = devices; @@ -39,6 +43,7 @@ public Sync(Device[] devices, uint inputSize, bool offline, List spectat _syncState = SyncState.SYNCING; _spectators = spectators ?? new List(); rollbackFrames = new uint[16]; + _broadcastStream = new BroadcastStream(); } public void AddRemoteInput(uint deviceId, int frame, int advantage, byte[] deviceInput) @@ -85,7 +90,6 @@ public List AdvanceSync(uint localDeviceId, byte[] deviceInput) UpdateSyncFrame(); var actions = new List(); - if (!_offlinePlay) { // create savestate at the initialFrame to support rolling back to it @@ -95,6 +99,13 @@ public List AdvanceSync(uint localDeviceId, byte[] deviceInput) actions.Add(new SessionSaveGameAction(_timeSync.LocalFrame, _stateStorage)); } + // for replay store the initial gamestate + if (_timeSync.LocalFrame == TimeSync.InitialFrame + 1) + { + var initialState = _stateStorage.LoadFrame(TimeSync.InitialFrame).Buffer; + _broadcastStream.SetInitialState(initialState); + } + // rollback update if (_timeSync.ShouldRollback()) { @@ -157,9 +168,6 @@ private void HandleDisconnectedDevices() private void SendSpectatorInputs() { - // no spectators? dont send inputs - if (_spectators.Count == 0) return; - var maxFrame = _timeSync.SyncFrame; var minFrame = Math.Max(0, maxFrame - (TimeSync.MaxRollbackFrames - 1)); @@ -173,7 +181,7 @@ private void SendSpectatorInputs() } } - if(minAck != int.MaxValue) + if (minAck != int.MaxValue) { minFrame = Math.Max(minFrame, minAck); } @@ -182,7 +190,10 @@ private void SendSpectatorInputs() var sendInput = new List(); for (var i = minFrame; i <= maxFrame; i++) { - sendInput.AddRange(GetFrameInput(i).Inputs); + var inputs = GetFrameInput(i).Inputs; + + sendInput.AddRange(inputs); + _broadcastStream.AddFrameInput(i, inputs); } foreach (var spectator in _spectators) @@ -411,5 +422,6 @@ public GameInput GetFrameInput(int frame) public uint RollbackFrames() => (uint)Math.Max(0, _timeSync.LocalFrame - (_timeSync.SyncFrame + 1)); public uint AverageRollbackFrames() => GetAverageRollbackFrames(); public SyncState State() => _syncState; + public void SaveToReplayFile() => _broadcastStream.SaveReplayFile(); } } diff --git a/src/synchronization/TimeSync.cs b/src/synchronization/TimeSync.cs index d30b73e..7943140 100644 --- a/src/synchronization/TimeSync.cs +++ b/src/synchronization/TimeSync.cs @@ -1,6 +1,6 @@ -using PleaseResync.session; +using PleaseResync.Session; -namespace PleaseResync.synchronization +namespace PleaseResync.Synchronization { internal class TimeSync {