feat: modernize ReplayCapture

This commit is contained in:
Anon 2026-05-04 13:04:15 +02:00 committed by GitHub
commit 03d3d6f950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 392 additions and 336 deletions

View file

@ -43,8 +43,7 @@ namespace MinecraftClient.ChatBots
public override void Initialize() public override void Initialize()
{ {
SetNetworkPacketEventEnabled(true); SetNetworkPacketEventEnabled(true);
replay = new ReplayHandler(GetProtocolVersion()); replay = new ReplayHandler(GetProtocolVersion(), $"{GetServerHost()}:{GetServerPort()}");
replay.MetaData.serverName = GetServerHost() + GetServerPort();
backupCounter = Settings.DoubleToTick(Config.Backup_Interval); backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
McClient.dispatcher.Register(l => l.Literal("help") McClient.dispatcher.Register(l => l.Literal("help")
@ -68,6 +67,8 @@ namespace MinecraftClient.ChatBots
{ {
McClient.dispatcher.Unregister(CommandName); McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName); McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
replay?.Dispose();
replay = null;
} }
private int OnCommandHelp(CmdResult r, string? cmd) private int OnCommandHelp(CmdResult r, string? cmd)
@ -85,9 +86,9 @@ namespace MinecraftClient.ChatBots
{ {
try try
{ {
if (replay!.RecordRunning) if (replay is { RecordRunning: true })
{ {
replay.CreateBackupReplay(Path.Combine("replay_recordings", replay.GetReplayDefaultName())); replay.CreateBackupReplay(Path.Combine(replay.ReplayFileDirectory, replay.GetReplayDefaultName()));
return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_created); return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_created);
} }
else else
@ -103,7 +104,7 @@ namespace MinecraftClient.ChatBots
{ {
try try
{ {
if (replay!.RecordRunning) if (replay is { RecordRunning: true })
{ {
replay.OnShutDown(); replay.OnShutDown();
return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_stopped); return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_stopped);
@ -119,16 +120,16 @@ namespace MinecraftClient.ChatBots
public override void OnNetworkPacket(int packetID, List<byte> packetData, bool isLogin, bool isInbound) public override void OnNetworkPacket(int packetID, List<byte> packetData, bool isLogin, bool isInbound)
{ {
replay!.AddPacket(packetID, packetData, isLogin, isInbound); replay?.AddPacket(packetID, packetData, isLogin, isInbound);
} }
public override void Update() public override void Update()
{ {
if (Config.Backup_Interval > 0 && replay!.RecordRunning) if (Config.Backup_Interval > 0 && replay is { RecordRunning: true })
{ {
if (backupCounter <= 0) if (backupCounter <= 0)
{ {
replay.CreateBackupReplay(Path.Combine("recording_cache", "REPLAY_BACKUP.mcpr")); replay.CreateBackupReplay(replay.GetBackupReplayPath());
backupCounter = Settings.DoubleToTick(Config.Backup_Interval); backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
} }
else backupCounter--; else backupCounter--;
@ -137,7 +138,7 @@ namespace MinecraftClient.ChatBots
public override bool OnDisconnect(DisconnectReason reason, string message) public override bool OnDisconnect(DisconnectReason reason, string message)
{ {
replay!.OnShutDown(); replay?.OnShutDown();
return base.OnDisconnect(reason, message); return base.OnDisconnect(reason, message);
} }
} }

View file

@ -1,8 +1,11 @@
using System; using System;
using System.Buffers.Binary;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using MinecraftClient.Mapping; using MinecraftClient.Mapping;
using MinecraftClient.Protocol.Handlers; using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.PacketPalettes; using MinecraftClient.Protocol.Handlers.PacketPalettes;
@ -10,405 +13,445 @@ using MinecraftClient.Protocol.Handlers.PacketPalettes;
namespace MinecraftClient.Protocol namespace MinecraftClient.Protocol
{ {
/// <summary> /// <summary>
/// Record and save replay file that can be used by Replay mod /// Record and save replay files that can be used by Replay Mod.
/// </summary> /// </summary>
public class ReplayHandler public class ReplayHandler : IDisposable
{ {
public string ReplayFileName = @"whhhh.mcpr"; private const string DefaultReplayDirectory = "replay_recordings";
public string ReplayFileDirectory = @"replay_recordings"; private const string WorkingRootDirectory = "recording_cache";
public MetaDataHandler MetaData; private const string RecordingEntryName = "recording.tmcpr";
public bool RecordRunning { get { return !cleanedUp; } } private const string BackupFileName = "REPLAY_BACKUP.mcpr";
private readonly string recordingTmpFileName = @"recording.tmcpr";
private readonly string temporaryCache = @"recording_cache";
private readonly DataTypes dataTypes;
private readonly PacketTypePalette packetType;
private readonly int protocolVersion;
private readonly BinaryWriter? recordStream;
private readonly DateTime recordStartTime;
private DateTime lastPacketTime;
private bool prepareCleanUp = false;
private bool cleanedUp = false;
private static readonly bool logOutput = true; private static readonly bool logOutput = true;
private int playerEntityID; private readonly Lock _sync = new();
private Guid playerUUID; private readonly DataTypes _dataTypes;
private Location playerLastPosition; private readonly PacketTypePalette _packetType;
private float playerLastYaw; private readonly int _protocolVersion;
private float playerLastPitch; 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) public ReplayHandler(int protocolVersion)
: this(protocolVersion, null, DefaultReplayDirectory)
{ {
dataTypes = new DataTypes(protocolVersion); }
packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion);
this.protocolVersion = protocolVersion;
if (!Directory.Exists(ReplayFileDirectory)) public ReplayHandler(int protocolVersion, string? serverName, string recordingDirectory = DefaultReplayDirectory)
Directory.CreateDirectory(ReplayFileDirectory); {
if (!Directory.Exists(temporaryCache)) ArgumentException.ThrowIfNullOrWhiteSpace(recordingDirectory);
Directory.CreateDirectory(temporaryCache);
recordStream = new BinaryWriter(new FileStream(Path.Combine(temporaryCache, recordingTmpFileName), FileMode.Create, FileAccess.ReadWrite)); _dataTypes = new DataTypes(protocolVersion);
recordStartTime = DateTime.Now; _packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion);
_protocolVersion = protocolVersion;
ReplayFileDirectory = recordingDirectory;
Directory.CreateDirectory(ReplayFileDirectory);
MetaData = new MetaDataHandler _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)
{ {
date = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalMilliseconds, serverName = serverName,
date = new DateTimeOffset(_recordStartTime).ToUnixTimeMilliseconds(),
protocol = protocolVersion, protocol = protocolVersion,
mcversion = ProtocolHandler.ProtocolVersion2MCVer(protocolVersion) mcversion = ProtocolHandler.ProtocolVersion2MCVer(protocolVersion)
}; };
MetaData.SaveToFile(); MetaData.SaveToFile();
playerLastPosition = new Location(0, 0, 0); _playerLastPosition = new Location(0, 0, 0);
AppDomain.CurrentDomain.ProcessExit += _processExitHandler;
WriteLog("Start recording."); WriteLog("Start recording.");
} }
public ReplayHandler(int protocolVersion, string serverName, string recordingDirectory = @"replay_recordings") public void Dispose()
: this(protocolVersion)
{ {
dataTypes = new DataTypes(protocolVersion); if (_disposed)
packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion); return;
MetaData.serverName = serverName; try
ReplayFileDirectory = recordingDirectory; {
} OnShutDown();
}
~ReplayHandler() finally
{ {
OnShutDown(); AppDomain.CurrentDomain.ProcessExit -= _processExitHandler;
_disposed = true;
GC.SuppressFinalize(this);
}
} }
public void SetClientEntityID(int entityID) public void SetClientEntityID(int entityID)
{ {
playerEntityID = entityID; lock (_sync)
{
_playerEntityId = entityID;
if (entityID >= 0)
MetaData.selfId = entityID;
}
} }
public void SetClientPlayerUUID(Guid uuid) public void SetClientPlayerUUID(Guid uuid)
{ {
playerUUID = uuid; lock (_sync)
}
#region File and stream handling
public void CloseRecordStream()
{
try
{ {
recordStream!.Flush(); _playerUuid = uuid;
recordStream.Close(); MetaData.AddPlayerUUID(uuid);
} }
catch { }
} }
public string GetBackupReplayPath() => _backupReplayPath;
/// <summary> /// <summary>
/// Stop recording and save replay file. Should called once before program exit /// Stop recording and save the replay file.
/// </summary> /// </summary>
public void OnShutDown() public void OnShutDown()
{ {
if (!cleanedUp) lock (_sync)
{ {
prepareCleanUp = true; EnsureNotDisposed();
CloseRecordStream();
CreateReplayFile();
cleanedUp = true;
}
}
/// <summary> if (_state != ReplayRecordingState.Recording)
/// Create the replay file for Replay mod to read return;
/// </summary>
public void CreateReplayFile()
{
string replayFileName = GetReplayDefaultName();
CreateReplayFile(replayFileName);
}
/// <summary> string replayFileName = GetReplayDefaultName();
/// Create the replay file for Replay mod to read string replayFilePath = ResolveReplayPath(replayFileName);
/// </summary>
/// <param name="replayFileName">Replay file name</param>
public void CreateReplayFile(string replayFileName)
{
WriteLog("Creating replay file.");
MetaData.duration = Convert.ToInt32((lastPacketTime - recordStartTime).TotalMilliseconds); WriteLog("Creating replay file.");
MetaData.SaveToFile(); _state = ReplayRecordingState.Finalizing;
try
using (Stream recordingFile = new FileStream(Path.Combine(temporaryCache, recordingTmpFileName), FileMode.Open))
{
using Stream metaDataFile = new FileStream(Path.Combine(temporaryCache, MetaData.MetaDataFileName), FileMode.Open);
using FileStream replayArchiveFile = new(Path.Combine(ReplayFileDirectory, replayFileName), FileMode.Create, FileAccess.Write);
using ZipArchive replayArchive = new(replayArchiveFile, ZipArchiveMode.Create);
ZipArchiveEntry recordingEntry = replayArchive.CreateEntry(recordingTmpFileName);
using (Stream recordingEntryStream = recordingEntry.Open())
{ {
recordingFile.CopyTo(recordingEntryStream); CloseRecordStreamUnsafe();
WriteReplayArchiveUnsafe(replayFilePath, readFromActiveStream: false);
ReplayFileName = replayFileName;
_state = ReplayRecordingState.Stopped;
CleanupWorkingFilesUnsafe();
WriteLog("Replay file created.");
}
catch
{
_state = ReplayRecordingState.Stopped;
throw;
} }
ZipArchiveEntry metadataEntry = replayArchive.CreateEntry(MetaData.MetaDataFileName);
using Stream metadataEntryStream = metadataEntry.Open();
metaDataFile.CopyTo(metadataEntryStream);
} }
File.Delete(Path.Combine(temporaryCache, recordingTmpFileName));
File.Delete(Path.Combine(temporaryCache, MetaData.MetaDataFileName));
WriteLog("Replay file created.");
} }
/// <summary> /// <summary>
/// Create a backup replay file while recording /// Create a snapshot replay file while the recording is still running.
/// </summary> /// </summary>
/// <param name="replayFileName"></param>
public void CreateBackupReplay(string replayFileName) public void CreateBackupReplay(string replayFileName)
{ {
if (cleanedUp || prepareCleanUp) lock (_sync)
return;
WriteDebugLog("Creating backup replay file.");
MetaData.duration = Convert.ToInt32((lastPacketTime - recordStartTime).TotalMilliseconds);
MetaData.SaveToFile();
using (Stream metaDataFile = new FileStream(Path.Combine(temporaryCache, MetaData.MetaDataFileName), FileMode.Open))
{ {
using FileStream replayArchiveFile = new(replayFileName, FileMode.Create, FileAccess.Write); EnsureNotDisposed();
using ZipArchive replayArchive = new(replayArchiveFile, ZipArchiveMode.Create);
ZipArchiveEntry recordingEntry = replayArchive.CreateEntry(recordingTmpFileName); if (_state != ReplayRecordingState.Recording)
using (Stream recordingEntryStream = recordingEntry.Open()) return;
{
// .CopyTo() method start from stream current position
// We need to reset position in order to get full content
long lastPosition = recordStream!.BaseStream.Position;
try
{
recordStream.BaseStream.Position = 0;
recordStream.BaseStream.CopyTo(recordingEntryStream);
}
finally
{
recordStream.BaseStream.Position = lastPosition;
}
}
ZipArchiveEntry metadataEntry = replayArchive.CreateEntry(MetaData.MetaDataFileName); WriteDebugLog("Creating backup replay file.");
using Stream metadataEntryStream = metadataEntry.Open(); WriteReplayArchiveUnsafe(ResolveReplayPath(replayFileName), readFromActiveStream: true);
metaDataFile.CopyTo(metadataEntryStream); WriteDebugLog("Backup replay file created.");
} }
WriteDebugLog("Backup replay file created.");
} }
/// <summary> /// <summary>
/// Get the default mcpr file name by current time /// Get a default unique replay file name for the current recording.
/// </summary> /// </summary>
/// <returns></returns>
public string GetReplayDefaultName() public string GetReplayDefaultName()
{ {
var now = DateTime.Now; string version = ProtocolHandler.ProtocolVersion2MCVer(_protocolVersion).Replace('.', '_');
return string.Format("{0}_{1}_{2}_{3}_{4}_{5}.mcpr", now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second); // yyyy_mm_dd_hh_mm_ss return $"{DateTime.UtcNow:yyyy_MM_dd_HH_mm_ss_fff}_{version}_{Environment.ProcessId}_{_instanceToken}.mcpr";
} }
#endregion
#region Packet related method
/// <summary> /// <summary>
/// Add a packet from network /// Add a packet from network capture.
/// </summary> /// </summary>
/// <param name="packetID"></param>
/// <param name="packetData"></param>
/// <param name="isLogin"></param>
/// <param name="isInbound"></param>
public void AddPacket(int packetID, IEnumerable<byte> packetData, bool isLogin, bool isInbound) public void AddPacket(int packetID, IEnumerable<byte> packetData, bool isLogin, bool isInbound)
{ {
if (cleanedUp || prepareCleanUp) byte[] packetBytes = packetData as byte[] ?? [.. packetData];
return;
try lock (_sync)
{ {
if (isInbound) if (_disposed || _state != ReplayRecordingState.Recording)
HandleInBoundPacket(packetID, packetData, isLogin); return;
else return;
if (PacketShouldSave(packetID, isLogin, isInbound)) try
AddPacket(packetID, packetData); {
} if (!isInbound)
catch (Exception e) return;
{
WriteDebugLog("Exception while adding packet: " + e.Message + "\n" + e.StackTrace); 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);
}
} }
} }
/// <summary> /// <summary>
/// Add packet directly without checking (internal use only) /// Add a player's UUID to the metadata.
/// </summary> /// </summary>
/// <param name="packetID"></param>
/// <param name="packetData"></param>
private void AddPacket(int packetID, IEnumerable<byte> packetData)
{
lastPacketTime = DateTime.Now;
// build raw packet
// format: packetID + packetData
List<byte> rawPacket = new();
rawPacket.AddRange(DataTypes.GetVarInt(packetID).ToArray());
rawPacket.AddRange(packetData.ToArray());
// build format
// format: timestamp + packetLength + RawPacket
List<byte> line = new();
int nowTime = Convert.ToInt32((lastPacketTime - recordStartTime).TotalMilliseconds);
line.AddRange(BitConverter.GetBytes((Int32)nowTime).AsEnumerable().Reverse().ToArray());
line.AddRange(BitConverter.GetBytes((Int32)rawPacket.Count).AsEnumerable().Reverse().ToArray());
line.AddRange(rawPacket.ToArray());
// Write out to the file
recordStream!.Write(line.ToArray());
}
/// <summary>
/// Add a player's UUID to the MetaData
/// </summary>
/// <param name="uuid"></param>
/// <param name="name"></param>
public void OnPlayerSpawn(Guid uuid) public void OnPlayerSpawn(Guid uuid)
{ {
// Metadata has a field for storing uuid for all players entered client render range lock (_sync)
MetaData.AddPlayerUUID(uuid); {
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<byte> header = stackalloc byte[8];
BinaryPrimitives.WriteInt32BigEndian(header, elapsedMilliseconds);
BinaryPrimitives.WriteInt32BigEndian(header[4..], rawPacket.Length);
_recordStream.Write(header);
_recordStream.Write(rawPacket);
} }
/// <summary>
/// Determine a packet should be saved
/// </summary>
/// <param name="packetID"></param>
/// <param name="isLogin"></param>
/// <param name="isInbound"></param>
/// <returns></returns>
private bool PacketShouldSave(int packetID, bool isLogin, bool isInbound) private bool PacketShouldSave(int packetID, bool isLogin, bool isInbound)
{ {
if (!isInbound) // save inbound only if (!isInbound)
return false; return false;
if (!isLogin) // save all play state packet
{ if (!isLogin)
return true; return true;
}
else return packetID == 0x02;
{ // is login
if (packetID == 0x02) // login success
{
return true;
}
else return false;
}
} }
/// <summary> private void HandleInBoundPacket(int packetID, byte[] packetData, bool isLogin)
/// Used to gather information needed
/// </summary>
/// <remarks>
/// Also for converting client side packet to server side packet
/// </remarks>
/// <param name="packetID"></param>
/// <param name="packetData"></param>
/// <param name="isLogin"></param>
private void HandleInBoundPacket(int packetID, IEnumerable<byte> packetData, bool isLogin)
{ {
Queue<byte> p = new(packetData); Queue<byte> p = new(packetData);
PacketTypesIn pType = packetType.GetIncomingTypeById(packetID); PacketTypesIn pType = _packetType.GetIncomingTypeById(packetID);
// Login success. Get player UUID
if (isLogin && packetID == 0x02) if (isLogin && packetID == 0x02)
{ {
if (protocolVersion < Protocol18Handler.MC_1_16_Version) if (_protocolVersion < Protocol18Handler.MC_1_16_Version)
{ {
if (Guid.TryParse(dataTypes.ReadNextString(p), out Guid uuid)) if (Guid.TryParse(_dataTypes.ReadNextString(p), out Guid uuid))
{ {
SetClientPlayerUUID(uuid); SetClientPlayerUUID(uuid);
WriteDebugLog("User UUID: " + uuid.ToString()); WriteDebugLog("User UUID: " + uuid);
} }
} }
else else
{ {
var uuid2 = dataTypes.ReadNextUUID(p); Guid uuid = _dataTypes.ReadNextUUID(p);
SetClientPlayerUUID(uuid2); SetClientPlayerUUID(uuid);
WriteDebugLog("User UUID: " + uuid2.ToString()); WriteDebugLog("User UUID: " + uuid);
} }
return; return;
} }
if (!isLogin && pType == PacketTypesIn.JoinGame) if (!isLogin && pType == PacketTypesIn.JoinGame)
{ {
// Get client player entity ID SetClientEntityID(_dataTypes.ReadNextInt(p));
SetClientEntityID(dataTypes.ReadNextInt(p));
return; return;
} }
if (!isLogin && pType == PacketTypesIn.SpawnPlayer) if (!isLogin && pType == PacketTypesIn.SpawnPlayer)
{ {
dataTypes.ReadNextVarInt(p); _dataTypes.ReadNextVarInt(p);
OnPlayerSpawn(dataTypes.ReadNextUUID(p)); OnPlayerSpawn(_dataTypes.ReadNextUUID(p));
return; return;
} }
// Get client player location for calculating movement delta later
if (pType == PacketTypesIn.PlayerPositionAndLook) if (pType == PacketTypesIn.PlayerPositionAndLook)
{ {
double x = dataTypes.ReadNextDouble(p); double x = _dataTypes.ReadNextDouble(p);
double y = dataTypes.ReadNextDouble(p); double y = _dataTypes.ReadNextDouble(p);
double z = dataTypes.ReadNextDouble(p); double z = _dataTypes.ReadNextDouble(p);
float yaw = dataTypes.ReadNextFloat(p); float yaw = _dataTypes.ReadNextFloat(p);
float pitch = dataTypes.ReadNextFloat(p); float pitch = _dataTypes.ReadNextFloat(p);
byte locMask = dataTypes.ReadNextByte(p); byte locMask = _dataTypes.ReadNextByte(p);
playerLastPitch = pitch; _playerLastPitch = pitch;
playerLastYaw = yaw; _playerLastYaw = yaw;
if (protocolVersion >= Protocol18Handler.MC_1_8_Version) if (_protocolVersion >= Protocol18Handler.MC_1_8_Version)
{ {
playerLastPosition.X = (locMask & 1 << 0) != 0 ? playerLastPosition.X + x : x; _playerLastPosition.X = (locMask & 1 << 0) != 0 ? _playerLastPosition.X + x : x;
playerLastPosition.Y = (locMask & 1 << 1) != 0 ? playerLastPosition.Y + y : y; _playerLastPosition.Y = (locMask & 1 << 1) != 0 ? _playerLastPosition.Y + y : y;
playerLastPosition.Z = (locMask & 1 << 2) != 0 ? playerLastPosition.Z + z : z; _playerLastPosition.Z = (locMask & 1 << 2) != 0 ? _playerLastPosition.Z + z : z;
} }
else else
{ {
playerLastPosition.X = x; _playerLastPosition.X = x;
playerLastPosition.Y = y; _playerLastPosition.Y = y;
playerLastPosition.Z = z; _playerLastPosition.Z = z;
} }
return;
} }
} }
/// <summary> private void WriteReplayArchiveUnsafe(string replayFilePath, bool readFromActiveStream)
/// Handle outbound packet (i.e. client player movement)
/// </summary>
/// <param name="packetID"></param>
/// <param name="packetData"></param>
/// <param name="isLogin"></param>
private void HandleOutBoundPacket(int packetID, IEnumerable<byte> packetData, bool isLogin)
{ {
var packetType = this.packetType.GetOutgoingTypeById(packetID); Directory.CreateDirectory(Path.GetDirectoryName(replayFilePath) ?? ".");
if (packetType == PacketTypesOut.PlayerPosition
|| packetType == PacketTypesOut.PlayerPositionAndRotation) 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())
{ {
// translate them to incoming entitymovement packet then save them 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 byte[] GetSpawnPlayerPacket(int entityID, Guid playerUUID, Location location, double pitch, double yaw) private int GetCurrentDurationMillisecondsUnsafe() =>
Math.Max(0, Convert.ToInt32((_lastPacketTime - _recordStartTime).TotalMilliseconds));
private bool HasCapturedPacketsUnsafe() => _lastPacketTime > _recordStartTime;
private void CloseRecordStreamUnsafe()
{ {
List<byte> packet = new(); if (_recordStreamClosed)
packet.AddRange(DataTypes.GetVarInt(entityID)); return;
packet.AddRange(playerUUID.ToBigEndianBytes());
packet.AddRange(dataTypes.GetDouble(location.X)); _recordStream.Flush();
packet.AddRange(dataTypes.GetDouble(location.Y)); _recordStream.Dispose();
packet.AddRange(dataTypes.GetDouble(location.Z)); _recordStreamClosed = true;
packet.Add((byte)0);
packet.Add((byte)0);
return packet.ToArray();
} }
#endregion private void CleanupWorkingFilesUnsafe()
{
DeleteFileIfExists(_backupReplayPath);
DeleteFileIfExists(_recordingFilePath);
DeleteFileIfExists(Path.Combine(_workingDirectory, MetaData.MetaDataFileName));
#region Helper method 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) private static void WriteLog(string t)
{ {
@ -422,88 +465,96 @@ namespace MinecraftClient.Protocol
WriteLog(t); WriteLog(t);
} }
#endregion private enum ReplayRecordingState
{
Recording,
Finalizing,
Stopped
}
} }
/// <summary> /// <summary>
/// Handle MetaData used by Replay mod /// Metadata used by Replay Mod.
/// </summary> /// </summary>
public class MetaDataHandler public class MetaDataHandler
{ {
public readonly string MetaDataFileName = @"metaData.json"; private static readonly JsonSerializerOptions s_jsonOptions = new()
public readonly string temporaryCache = @"recording_cache"; {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HashSet<string> _players = new(StringComparer.OrdinalIgnoreCase);
public string MetaDataFileName { get; } = "metaData.json";
public string temporaryCache { get; }
public bool singlePlayer = false; public bool singlePlayer = false;
public string? serverName; public string? serverName;
public int duration = 0; // duration of the whole replay public string? customServerName;
public long date; // start time of the recording in unix timestamp milliseconds public int duration;
public string mcversion = "0.0"; // e.g. 1.15.2 public long date;
public string mcversion = "0.0";
public string fileFormat = "MCPR"; public string fileFormat = "MCPR";
public int fileFormatVersion = 14; // 14 is what I found in metadata generated in 1.15.2 replay mod public int fileFormatVersion = 14;
public int protocol; public int protocol;
public string generator = "MCC"; // The program which generated the file (MCC have more popularity now :P) public string generator = "MCC";
public int selfId = -1; // I saw -1 in medaData file generated by Replay mod. Not sure what is this for public int selfId = -1;
public List<string> players; // Array of UUIDs of all players which can be seen in the replay
public MetaDataHandler() public MetaDataHandler(string temporaryCache)
{ {
players = new List<string>(); this.temporaryCache = temporaryCache;
} }
/// <summary>
/// Add a player's UUID who appeared in the replay
/// </summary>
/// <param name="uuid"></param>
public void AddPlayerUUID(Guid uuid) public void AddPlayerUUID(Guid uuid)
{ {
players.Add(uuid.ToString()); _players.Add(uuid.ToString());
} }
/// <summary>
/// Export metadata to JSON string
/// </summary>
/// <returns>JSON string</returns>
public string ToJson() public string ToJson()
{ {
return String.Concat(new[] { "{" ReplayMetaDataModel metaData = new()
, "\"singleplayer\":" , singlePlayer.ToString().ToLower() , "," {
, "\"serverName\":\"" , serverName , "\"," Singleplayer = singlePlayer,
, "\"duration\":" , duration.ToString() , "," ServerName = serverName,
, "\"date\":" , date.ToString() , "," CustomServerName = customServerName,
, "\"mcversion\":\"" , mcversion , "\"," Duration = duration,
, "\"fileFormat\":\"" , fileFormat , "\"," Date = date,
, "\"fileFormatVersion\":" , fileFormatVersion.ToString() , "," Mcversion = mcversion,
, "\"protocol\":" , protocol.ToString() , "," FileFormat = fileFormat,
, "\"generator\":\"" , generator , "\"," FileFormatVersion = fileFormatVersion,
, "\"selfId\":" , selfId.ToString() + "," Protocol = protocol,
, "\"player\":" , GetPlayersJsonArray() Generator = generator,
, "}" SelfId = selfId,
}); Players = [.. _players]
};
return JsonSerializer.Serialize(metaData, s_jsonOptions);
} }
/// <summary>
/// Save metadata to disk file
/// </summary>
public void SaveToFile() public void SaveToFile()
{ {
Directory.CreateDirectory(temporaryCache);
File.WriteAllText(Path.Combine(temporaryCache, MetaDataFileName), ToJson()); File.WriteAllText(Path.Combine(temporaryCache, MetaDataFileName), ToJson());
} }
/// <summary> private sealed class ReplayMetaDataModel
/// Get players UUID JSON array string
/// </summary>
/// <returns></returns>
private string GetPlayersJsonArray()
{ {
if (players.Count == 0) public bool Singleplayer { get; init; }
return "[]"; public string? ServerName { get; init; }
public string? CustomServerName { get; init; }
public int Duration { get; init; }
public long Date { get; init; }
// Place between brackets the comma-separated list of player names placed between quotes [JsonPropertyName("mcversion")]
return String.Format("[{0}]", public string Mcversion { get; init; } = "0.0";
String.Join(",",
players.Select(player => String.Format("\"{0}\"", player)) 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; } = [];
} }
} }
} }

View file

@ -498,8 +498,10 @@ You need to have ChatFormat working correctly and add yourself in botowners to u
/!\ Server admins can spoof PMs (/tellraw, /nick) so enable RemoteControl only if you trust server admins</value> /!\ Server admins can spoof PMs (/tellraw, /nick) so enable RemoteControl only if you trust server admins</value>
</data> </data>
<data name="ChatBot.ReplayCapture" xml:space="preserve"> <data name="ChatBot.ReplayCapture" xml:space="preserve">
<value>Enable recording of the game (/replay start) and replay it later using the Replay Mod (https://www.replaymod.com/) <value>Enable automatic recording of the game and replay it later using the Replay Mod (https://www.replaymod.com/)
Use /replay save to create a snapshot replay file while still recording.
Please note that due to technical limitations, the client player (you) will not be shown in the replay file Please note that due to technical limitations, the client player (you) will not be shown in the replay file
Each MCC instance uses its own temporary replay cache so multiple clients can record from the same folder without colliding.
/!\ You SHOULD use /replay stop or exit the program gracefully with /quit OR THE REPLAY FILE MAY GET CORRUPT!</value> /!\ You SHOULD use /replay stop or exit the program gracefully with /quit OR THE REPLAY FILE MAY GET CORRUPT!</value>
</data> </data>
<data name="ChatBot.ReplayCapture.Backup_Interval" xml:space="preserve"> <data name="ChatBot.ReplayCapture.Backup_Interval" xml:space="preserve">

View file

@ -3064,11 +3064,13 @@ redirectFrom:
- **Description:** - **Description:**
Enable recording of the game (`/replay start`) and replay it later using the Replay Mod (https://www.replaymod.com/). Enable automatic recording of the game and replay it later using the Replay Mod (https://www.replaymod.com/).
Use `/replay save` to create a snapshot replay file while recording, and `/replay stop` to finalize the active recording.
<div class="custom-container warning"><p class="custom-container-title">Warning</p> <div class="custom-container warning"><p class="custom-container-title">Warning</p>
**This bot does not work for 1.19, we need maintainers for it.** **Use `/replay stop` or exit MCC gracefully with `/quit` so the replay file can be finalized cleanly.**
</div> </div>
@ -3078,9 +3080,9 @@ redirectFrom:
</div> </div>
<div class="custom-container warning"><p class="custom-container-title">Warning</p> <div class="custom-container note"><p class="custom-container-title">Note</p>
**You SHOULD use `/replay stop` or exit the program gracefully with `/quit` OR THE REPLAY FILE MAY GET CORRUPT!** **Each MCC instance uses its own temporary replay cache, so multiple MCC clients can record from the same folder without overwriting each other.**
</div> </div>