using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using MinecraftClient.Mapping;
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.PacketPalettes;
namespace MinecraftClient.Protocol
{
///
/// Record and save replay files that can be used by Replay Mod.
///
public class ReplayHandler : IDisposable
{
private const string DefaultReplayDirectory = "replay_recordings";
private const string WorkingRootDirectory = "recording_cache";
private const string RecordingEntryName = "recording.tmcpr";
private const string BackupFileName = "REPLAY_BACKUP.mcpr";
private static readonly bool logOutput = true;
private readonly Lock _sync = new();
private readonly DataTypes _dataTypes;
private readonly PacketTypePalette _packetType;
private readonly int _protocolVersion;
private readonly string _instanceToken;
private readonly string _workingDirectory;
private readonly string _recordingFilePath;
private readonly string _backupReplayPath;
private readonly EventHandler _processExitHandler;
private readonly FileStream _recordStream;
private readonly DateTime _recordStartTime;
private ReplayRecordingState _state = ReplayRecordingState.Recording;
private bool _recordStreamClosed;
private bool _disposed;
private DateTime _lastPacketTime;
private int _playerEntityId = -1;
private Guid _playerUuid;
private Location _playerLastPosition;
private float _playerLastYaw;
private float _playerLastPitch;
public string ReplayFileName { get; private set; } = string.Empty;
public string ReplayFileDirectory { get; }
public MetaDataHandler MetaData { get; }
public bool RecordRunning
{
get
{
lock (_sync)
return _state == ReplayRecordingState.Recording;
}
}
public ReplayHandler(int protocolVersion)
: this(protocolVersion, null, DefaultReplayDirectory)
{
}
public ReplayHandler(int protocolVersion, string? serverName, string recordingDirectory = DefaultReplayDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(recordingDirectory);
_dataTypes = new DataTypes(protocolVersion);
_packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion);
_protocolVersion = protocolVersion;
ReplayFileDirectory = recordingDirectory;
Directory.CreateDirectory(ReplayFileDirectory);
_instanceToken = Path.GetRandomFileName().Replace(".", string.Empty, StringComparison.Ordinal);
_workingDirectory = Path.Combine(WorkingRootDirectory, $"{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Environment.ProcessId}_{_instanceToken}");
Directory.CreateDirectory(_workingDirectory);
_recordingFilePath = Path.Combine(_workingDirectory, RecordingEntryName);
_backupReplayPath = Path.Combine(_workingDirectory, BackupFileName);
_recordStream = new FileStream(_recordingFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
_processExitHandler = (_, _) => FinalizeOnProcessExit();
_recordStartTime = DateTime.UtcNow;
_lastPacketTime = _recordStartTime;
MetaData = new MetaDataHandler(_workingDirectory)
{
serverName = serverName,
date = new DateTimeOffset(_recordStartTime).ToUnixTimeMilliseconds(),
protocol = protocolVersion,
mcversion = ProtocolHandler.ProtocolVersion2MCVer(protocolVersion)
};
MetaData.SaveToFile();
_playerLastPosition = new Location(0, 0, 0);
AppDomain.CurrentDomain.ProcessExit += _processExitHandler;
WriteLog("Start recording.");
}
public void Dispose()
{
if (_disposed)
return;
try
{
OnShutDown();
}
finally
{
AppDomain.CurrentDomain.ProcessExit -= _processExitHandler;
_disposed = true;
GC.SuppressFinalize(this);
}
}
public void SetClientEntityID(int entityID)
{
lock (_sync)
{
_playerEntityId = entityID;
if (entityID >= 0)
MetaData.selfId = entityID;
}
}
public void SetClientPlayerUUID(Guid uuid)
{
lock (_sync)
{
_playerUuid = uuid;
MetaData.AddPlayerUUID(uuid);
}
}
public string GetBackupReplayPath() => _backupReplayPath;
///
/// Stop recording and save the replay file.
///
public void OnShutDown()
{
lock (_sync)
{
EnsureNotDisposed();
if (_state != ReplayRecordingState.Recording)
return;
string replayFileName = GetReplayDefaultName();
string replayFilePath = ResolveReplayPath(replayFileName);
WriteLog("Creating replay file.");
_state = ReplayRecordingState.Finalizing;
try
{
CloseRecordStreamUnsafe();
WriteReplayArchiveUnsafe(replayFilePath, readFromActiveStream: false);
ReplayFileName = replayFileName;
_state = ReplayRecordingState.Stopped;
CleanupWorkingFilesUnsafe();
WriteLog("Replay file created.");
}
catch
{
_state = ReplayRecordingState.Stopped;
throw;
}
}
}
///
/// Create a snapshot replay file while the recording is still running.
///
public void CreateBackupReplay(string replayFileName)
{
lock (_sync)
{
EnsureNotDisposed();
if (_state != ReplayRecordingState.Recording)
return;
WriteDebugLog("Creating backup replay file.");
WriteReplayArchiveUnsafe(ResolveReplayPath(replayFileName), readFromActiveStream: true);
WriteDebugLog("Backup replay file created.");
}
}
///
/// Get a default unique replay file name for the current recording.
///
public string GetReplayDefaultName()
{
string version = ProtocolHandler.ProtocolVersion2MCVer(_protocolVersion).Replace('.', '_');
return $"{DateTime.UtcNow:yyyy_MM_dd_HH_mm_ss_fff}_{version}_{Environment.ProcessId}_{_instanceToken}.mcpr";
}
///
/// Add a packet from network capture.
///
public void AddPacket(int packetID, IEnumerable packetData, bool isLogin, bool isInbound)
{
byte[] packetBytes = packetData as byte[] ?? [.. packetData];
lock (_sync)
{
if (_disposed || _state != ReplayRecordingState.Recording)
return;
try
{
if (!isInbound)
return;
HandleInBoundPacket(packetID, packetBytes, isLogin);
if (PacketShouldSave(packetID, isLogin, isInbound))
AddPacketUnsafe(packetID, packetBytes);
}
catch (Exception e)
{
WriteDebugLog("Exception while adding packet: " + e.Message + "\n" + e.StackTrace);
}
}
}
///
/// Add a player's UUID to the metadata.
///
public void OnPlayerSpawn(Guid uuid)
{
lock (_sync)
{
MetaData.AddPlayerUUID(uuid);
}
}
private void AddPacketUnsafe(int packetID, byte[] packetData)
{
_lastPacketTime = DateTime.UtcNow;
byte[] packetId = [.. DataTypes.GetVarInt(packetID)];
byte[] rawPacket = new byte[packetId.Length + packetData.Length];
packetId.CopyTo(rawPacket, 0);
packetData.CopyTo(rawPacket, packetId.Length);
int elapsedMilliseconds = Math.Max(0, Convert.ToInt32((_lastPacketTime - _recordStartTime).TotalMilliseconds));
Span header = stackalloc byte[8];
BinaryPrimitives.WriteInt32BigEndian(header, elapsedMilliseconds);
BinaryPrimitives.WriteInt32BigEndian(header[4..], rawPacket.Length);
_recordStream.Write(header);
_recordStream.Write(rawPacket);
}
private bool PacketShouldSave(int packetID, bool isLogin, bool isInbound)
{
if (!isInbound)
return false;
if (!isLogin)
return true;
return packetID == 0x02;
}
private void HandleInBoundPacket(int packetID, byte[] packetData, bool isLogin)
{
Queue p = new(packetData);
PacketTypesIn pType = _packetType.GetIncomingTypeById(packetID);
if (isLogin && packetID == 0x02)
{
if (_protocolVersion < Protocol18Handler.MC_1_16_Version)
{
if (Guid.TryParse(_dataTypes.ReadNextString(p), out Guid uuid))
{
SetClientPlayerUUID(uuid);
WriteDebugLog("User UUID: " + uuid);
}
}
else
{
Guid uuid = _dataTypes.ReadNextUUID(p);
SetClientPlayerUUID(uuid);
WriteDebugLog("User UUID: " + uuid);
}
return;
}
if (!isLogin && pType == PacketTypesIn.JoinGame)
{
SetClientEntityID(_dataTypes.ReadNextInt(p));
return;
}
if (!isLogin && pType == PacketTypesIn.SpawnPlayer)
{
_dataTypes.ReadNextVarInt(p);
OnPlayerSpawn(_dataTypes.ReadNextUUID(p));
return;
}
if (pType == PacketTypesIn.PlayerPositionAndLook)
{
double x = _dataTypes.ReadNextDouble(p);
double y = _dataTypes.ReadNextDouble(p);
double z = _dataTypes.ReadNextDouble(p);
float yaw = _dataTypes.ReadNextFloat(p);
float pitch = _dataTypes.ReadNextFloat(p);
byte locMask = _dataTypes.ReadNextByte(p);
_playerLastPitch = pitch;
_playerLastYaw = yaw;
if (_protocolVersion >= Protocol18Handler.MC_1_8_Version)
{
_playerLastPosition.X = (locMask & 1 << 0) != 0 ? _playerLastPosition.X + x : x;
_playerLastPosition.Y = (locMask & 1 << 1) != 0 ? _playerLastPosition.Y + y : y;
_playerLastPosition.Z = (locMask & 1 << 2) != 0 ? _playerLastPosition.Z + z : z;
}
else
{
_playerLastPosition.X = x;
_playerLastPosition.Y = y;
_playerLastPosition.Z = z;
}
}
}
private void WriteReplayArchiveUnsafe(string replayFilePath, bool readFromActiveStream)
{
Directory.CreateDirectory(Path.GetDirectoryName(replayFilePath) ?? ".");
MetaData.duration = GetCurrentDurationMillisecondsUnsafe();
if (_playerEntityId >= 0)
MetaData.selfId = _playerEntityId;
if (_playerUuid != Guid.Empty)
MetaData.AddPlayerUUID(_playerUuid);
MetaData.SaveToFile();
using FileStream replayArchiveFile = new(replayFilePath, FileMode.Create, FileAccess.Write);
using ZipArchive replayArchive = new(replayArchiveFile, ZipArchiveMode.Create);
ZipArchiveEntry recordingEntry = replayArchive.CreateEntry(RecordingEntryName);
using (Stream recordingEntryStream = recordingEntry.Open())
{
if (readFromActiveStream)
CopyActiveRecordingUnsafe(recordingEntryStream);
else
using (FileStream recordingFile = new(_recordingFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
recordingFile.CopyTo(recordingEntryStream);
}
ZipArchiveEntry metadataEntry = replayArchive.CreateEntry(MetaData.MetaDataFileName);
using Stream metadataEntryStream = metadataEntry.Open();
using FileStream metadataFile = new(Path.Combine(_workingDirectory, MetaData.MetaDataFileName), FileMode.Open, FileAccess.Read, FileShare.Read);
metadataFile.CopyTo(metadataEntryStream);
}
private void CopyActiveRecordingUnsafe(Stream destination)
{
_recordStream.Flush();
long position = _recordStream.Position;
try
{
_recordStream.Position = 0;
_recordStream.CopyTo(destination);
}
finally
{
_recordStream.Position = position;
}
}
private int GetCurrentDurationMillisecondsUnsafe() =>
Math.Max(0, Convert.ToInt32((_lastPacketTime - _recordStartTime).TotalMilliseconds));
private bool HasCapturedPacketsUnsafe() => _lastPacketTime > _recordStartTime;
private void CloseRecordStreamUnsafe()
{
if (_recordStreamClosed)
return;
_recordStream.Flush();
_recordStream.Dispose();
_recordStreamClosed = true;
}
private void CleanupWorkingFilesUnsafe()
{
DeleteFileIfExists(_backupReplayPath);
DeleteFileIfExists(_recordingFilePath);
DeleteFileIfExists(Path.Combine(_workingDirectory, MetaData.MetaDataFileName));
if (Directory.Exists(_workingDirectory) && Directory.GetFileSystemEntries(_workingDirectory).Length == 0)
Directory.Delete(_workingDirectory);
}
private void FinalizeOnProcessExit()
{
lock (_sync)
{
if (_disposed || _state != ReplayRecordingState.Recording)
return;
try
{
_state = ReplayRecordingState.Finalizing;
CloseRecordStreamUnsafe();
if (HasCapturedPacketsUnsafe())
{
string replayFileName = GetReplayDefaultName();
WriteDebugLog("Process exit detected, finalizing replay file.");
WriteReplayArchiveUnsafe(ResolveReplayPath(replayFileName), readFromActiveStream: false);
ReplayFileName = replayFileName;
}
_state = ReplayRecordingState.Stopped;
CleanupWorkingFilesUnsafe();
}
catch (Exception e)
{
_state = ReplayRecordingState.Stopped;
WriteDebugLog("Exception while finalizing replay on process exit: " + e.Message + "\n" + e.StackTrace);
}
}
}
private string ResolveReplayPath(string replayFileName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(replayFileName);
if (Path.IsPathRooted(replayFileName) || !string.IsNullOrEmpty(Path.GetDirectoryName(replayFileName)))
return replayFileName;
return Path.Combine(ReplayFileDirectory, replayFileName);
}
private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
private static void DeleteFileIfExists(string path)
{
if (File.Exists(path))
File.Delete(path);
}
private static void WriteLog(string t)
{
if (logOutput)
ConsoleIO.WriteLogLine("[Replay] " + t);
}
private static void WriteDebugLog(string t)
{
if (Settings.Config.Logging.DebugMessages && logOutput)
WriteLog(t);
}
private enum ReplayRecordingState
{
Recording,
Finalizing,
Stopped
}
}
///
/// Metadata used by Replay Mod.
///
public class MetaDataHandler
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HashSet _players = new(StringComparer.OrdinalIgnoreCase);
public string MetaDataFileName { get; } = "metaData.json";
public string temporaryCache { get; }
public bool singlePlayer = false;
public string? serverName;
public string? customServerName;
public int duration;
public long date;
public string mcversion = "0.0";
public string fileFormat = "MCPR";
public int fileFormatVersion = 14;
public int protocol;
public string generator = "MCC";
public int selfId = -1;
public MetaDataHandler(string temporaryCache)
{
this.temporaryCache = temporaryCache;
}
public void AddPlayerUUID(Guid uuid)
{
_players.Add(uuid.ToString());
}
public string ToJson()
{
ReplayMetaDataModel metaData = new()
{
Singleplayer = singlePlayer,
ServerName = serverName,
CustomServerName = customServerName,
Duration = duration,
Date = date,
Mcversion = mcversion,
FileFormat = fileFormat,
FileFormatVersion = fileFormatVersion,
Protocol = protocol,
Generator = generator,
SelfId = selfId,
Players = [.. _players]
};
return JsonSerializer.Serialize(metaData, s_jsonOptions);
}
public void SaveToFile()
{
Directory.CreateDirectory(temporaryCache);
File.WriteAllText(Path.Combine(temporaryCache, MetaDataFileName), ToJson());
}
private sealed class ReplayMetaDataModel
{
public bool Singleplayer { get; init; }
public string? ServerName { get; init; }
public string? CustomServerName { get; init; }
public int Duration { get; init; }
public long Date { get; init; }
[JsonPropertyName("mcversion")]
public string Mcversion { get; init; } = "0.0";
public string FileFormat { get; init; } = "MCPR";
public int FileFormatVersion { get; init; }
public int Protocol { get; init; }
public string Generator { get; init; } = "MCC";
public int SelfId { get; init; } = -1;
public string[] Players { get; init; } = [];
}
}
}