mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: modernize ReplayCapture
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
acabe95801
commit
98a1551f6a
4 changed files with 392 additions and 336 deletions
|
|
@ -43,8 +43,7 @@ namespace MinecraftClient.ChatBots
|
|||
public override void Initialize()
|
||||
{
|
||||
SetNetworkPacketEventEnabled(true);
|
||||
replay = new ReplayHandler(GetProtocolVersion());
|
||||
replay.MetaData.serverName = GetServerHost() + GetServerPort();
|
||||
replay = new ReplayHandler(GetProtocolVersion(), $"{GetServerHost()}:{GetServerPort()}");
|
||||
backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
|
||||
|
||||
McClient.dispatcher.Register(l => l.Literal("help")
|
||||
|
|
@ -68,6 +67,8 @@ namespace MinecraftClient.ChatBots
|
|||
{
|
||||
McClient.dispatcher.Unregister(CommandName);
|
||||
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
|
||||
replay?.Dispose();
|
||||
replay = null;
|
||||
}
|
||||
|
||||
private int OnCommandHelp(CmdResult r, string? cmd)
|
||||
|
|
@ -85,9 +86,9 @@ namespace MinecraftClient.ChatBots
|
|||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
|
|
@ -103,7 +104,7 @@ namespace MinecraftClient.ChatBots
|
|||
{
|
||||
try
|
||||
{
|
||||
if (replay!.RecordRunning)
|
||||
if (replay is { RecordRunning: true })
|
||||
{
|
||||
replay.OnShutDown();
|
||||
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)
|
||||
{
|
||||
replay!.AddPacket(packetID, packetData, isLogin, isInbound);
|
||||
replay?.AddPacket(packetID, packetData, isLogin, isInbound);
|
||||
}
|
||||
|
||||
public override void Update()
|
||||
{
|
||||
if (Config.Backup_Interval > 0 && replay!.RecordRunning)
|
||||
if (Config.Backup_Interval > 0 && replay is { RecordRunning: true })
|
||||
{
|
||||
if (backupCounter <= 0)
|
||||
{
|
||||
replay.CreateBackupReplay(Path.Combine("recording_cache", "REPLAY_BACKUP.mcpr"));
|
||||
replay.CreateBackupReplay(replay.GetBackupReplayPath());
|
||||
backupCounter = Settings.DoubleToTick(Config.Backup_Interval);
|
||||
}
|
||||
else backupCounter--;
|
||||
|
|
@ -137,7 +138,7 @@ namespace MinecraftClient.ChatBots
|
|||
|
||||
public override bool OnDisconnect(DisconnectReason reason, string message)
|
||||
{
|
||||
replay!.OnShutDown();
|
||||
replay?.OnShutDown();
|
||||
return base.OnDisconnect(reason, message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Protocol.Handlers;
|
||||
using MinecraftClient.Protocol.Handlers.PacketPalettes;
|
||||
|
|
@ -10,405 +13,445 @@ using MinecraftClient.Protocol.Handlers.PacketPalettes;
|
|||
namespace MinecraftClient.Protocol
|
||||
{
|
||||
/// <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>
|
||||
public class ReplayHandler
|
||||
public class ReplayHandler : IDisposable
|
||||
{
|
||||
public string ReplayFileName = @"whhhh.mcpr";
|
||||
public string ReplayFileDirectory = @"replay_recordings";
|
||||
public MetaDataHandler MetaData;
|
||||
public bool RecordRunning { get { return !cleanedUp; } }
|
||||
|
||||
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 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 int playerEntityID;
|
||||
private Guid playerUUID;
|
||||
private Location playerLastPosition;
|
||||
private float playerLastYaw;
|
||||
private float playerLastPitch;
|
||||
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)
|
||||
{
|
||||
dataTypes = new DataTypes(protocolVersion);
|
||||
packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion);
|
||||
this.protocolVersion = protocolVersion;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(ReplayFileDirectory))
|
||||
Directory.CreateDirectory(ReplayFileDirectory);
|
||||
if (!Directory.Exists(temporaryCache))
|
||||
Directory.CreateDirectory(temporaryCache);
|
||||
public ReplayHandler(int protocolVersion, string? serverName, string recordingDirectory = DefaultReplayDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(recordingDirectory);
|
||||
|
||||
recordStream = new BinaryWriter(new FileStream(Path.Combine(temporaryCache, recordingTmpFileName), FileMode.Create, FileAccess.ReadWrite));
|
||||
recordStartTime = DateTime.Now;
|
||||
_dataTypes = new DataTypes(protocolVersion);
|
||||
_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,
|
||||
mcversion = ProtocolHandler.ProtocolVersion2MCVer(protocolVersion)
|
||||
};
|
||||
MetaData.SaveToFile();
|
||||
|
||||
playerLastPosition = new Location(0, 0, 0);
|
||||
_playerLastPosition = new Location(0, 0, 0);
|
||||
AppDomain.CurrentDomain.ProcessExit += _processExitHandler;
|
||||
|
||||
WriteLog("Start recording.");
|
||||
}
|
||||
|
||||
public ReplayHandler(int protocolVersion, string serverName, string recordingDirectory = @"replay_recordings")
|
||||
: this(protocolVersion)
|
||||
public void Dispose()
|
||||
{
|
||||
dataTypes = new DataTypes(protocolVersion);
|
||||
packetType = new PacketTypeHandler().GetTypeHandler(protocolVersion);
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
MetaData.serverName = serverName;
|
||||
ReplayFileDirectory = recordingDirectory;
|
||||
}
|
||||
|
||||
~ReplayHandler()
|
||||
{
|
||||
OnShutDown();
|
||||
try
|
||||
{
|
||||
OnShutDown();
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit -= _processExitHandler;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetClientEntityID(int entityID)
|
||||
{
|
||||
playerEntityID = entityID;
|
||||
lock (_sync)
|
||||
{
|
||||
_playerEntityId = entityID;
|
||||
if (entityID >= 0)
|
||||
MetaData.selfId = entityID;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetClientPlayerUUID(Guid uuid)
|
||||
{
|
||||
playerUUID = uuid;
|
||||
}
|
||||
|
||||
#region File and stream handling
|
||||
|
||||
public void CloseRecordStream()
|
||||
{
|
||||
try
|
||||
lock (_sync)
|
||||
{
|
||||
recordStream!.Flush();
|
||||
recordStream.Close();
|
||||
_playerUuid = uuid;
|
||||
MetaData.AddPlayerUUID(uuid);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public string GetBackupReplayPath() => _backupReplayPath;
|
||||
|
||||
/// <summary>
|
||||
/// Stop recording and save replay file. Should called once before program exit
|
||||
/// Stop recording and save the replay file.
|
||||
/// </summary>
|
||||
public void OnShutDown()
|
||||
{
|
||||
if (!cleanedUp)
|
||||
lock (_sync)
|
||||
{
|
||||
prepareCleanUp = true;
|
||||
CloseRecordStream();
|
||||
CreateReplayFile();
|
||||
cleanedUp = true;
|
||||
}
|
||||
}
|
||||
EnsureNotDisposed();
|
||||
|
||||
/// <summary>
|
||||
/// Create the replay file for Replay mod to read
|
||||
/// </summary>
|
||||
public void CreateReplayFile()
|
||||
{
|
||||
string replayFileName = GetReplayDefaultName();
|
||||
CreateReplayFile(replayFileName);
|
||||
}
|
||||
if (_state != ReplayRecordingState.Recording)
|
||||
return;
|
||||
|
||||
/// <summary>
|
||||
/// Create the replay file for Replay mod to read
|
||||
/// </summary>
|
||||
/// <param name="replayFileName">Replay file name</param>
|
||||
public void CreateReplayFile(string replayFileName)
|
||||
{
|
||||
WriteLog("Creating replay file.");
|
||||
string replayFileName = GetReplayDefaultName();
|
||||
string replayFilePath = ResolveReplayPath(replayFileName);
|
||||
|
||||
MetaData.duration = Convert.ToInt32((lastPacketTime - recordStartTime).TotalMilliseconds);
|
||||
MetaData.SaveToFile();
|
||||
|
||||
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())
|
||||
WriteLog("Creating replay file.");
|
||||
_state = ReplayRecordingState.Finalizing;
|
||||
try
|
||||
{
|
||||
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>
|
||||
/// Create a backup replay file while recording
|
||||
/// Create a snapshot replay file while the recording is still running.
|
||||
/// </summary>
|
||||
/// <param name="replayFileName"></param>
|
||||
public void CreateBackupReplay(string replayFileName)
|
||||
{
|
||||
if (cleanedUp || prepareCleanUp)
|
||||
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))
|
||||
lock (_sync)
|
||||
{
|
||||
using FileStream replayArchiveFile = new(replayFileName, FileMode.Create, FileAccess.Write);
|
||||
using ZipArchive replayArchive = new(replayArchiveFile, ZipArchiveMode.Create);
|
||||
EnsureNotDisposed();
|
||||
|
||||
ZipArchiveEntry recordingEntry = replayArchive.CreateEntry(recordingTmpFileName);
|
||||
using (Stream recordingEntryStream = recordingEntry.Open())
|
||||
{
|
||||
// .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;
|
||||
}
|
||||
}
|
||||
if (_state != ReplayRecordingState.Recording)
|
||||
return;
|
||||
|
||||
ZipArchiveEntry metadataEntry = replayArchive.CreateEntry(MetaData.MetaDataFileName);
|
||||
using Stream metadataEntryStream = metadataEntry.Open();
|
||||
metaDataFile.CopyTo(metadataEntryStream);
|
||||
WriteDebugLog("Creating backup replay file.");
|
||||
WriteReplayArchiveUnsafe(ResolveReplayPath(replayFileName), readFromActiveStream: true);
|
||||
WriteDebugLog("Backup replay file created.");
|
||||
}
|
||||
|
||||
WriteDebugLog("Backup replay file created.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the default mcpr file name by current time
|
||||
/// Get a default unique replay file name for the current recording.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public string GetReplayDefaultName()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
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
|
||||
string version = ProtocolHandler.ProtocolVersion2MCVer(_protocolVersion).Replace('.', '_');
|
||||
return $"{DateTime.UtcNow:yyyy_MM_dd_HH_mm_ss_fff}_{version}_{Environment.ProcessId}_{_instanceToken}.mcpr";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Packet related method
|
||||
|
||||
/// <summary>
|
||||
/// Add a packet from network
|
||||
/// Add a packet from network capture.
|
||||
/// </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)
|
||||
{
|
||||
if (cleanedUp || prepareCleanUp)
|
||||
return;
|
||||
byte[] packetBytes = packetData as byte[] ?? [.. packetData];
|
||||
|
||||
try
|
||||
lock (_sync)
|
||||
{
|
||||
if (isInbound)
|
||||
HandleInBoundPacket(packetID, packetData, isLogin);
|
||||
else return;
|
||||
if (_disposed || _state != ReplayRecordingState.Recording)
|
||||
return;
|
||||
|
||||
if (PacketShouldSave(packetID, isLogin, isInbound))
|
||||
AddPacket(packetID, packetData);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
WriteDebugLog("Exception while adding packet: " + e.Message + "\n" + e.StackTrace);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add packet directly without checking (internal use only)
|
||||
/// Add a player's UUID to the metadata.
|
||||
/// </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)
|
||||
{
|
||||
// Metadata has a field for storing uuid for all players entered client render range
|
||||
MetaData.AddPlayerUUID(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<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)
|
||||
{
|
||||
if (!isInbound) // save inbound only
|
||||
if (!isInbound)
|
||||
return false;
|
||||
if (!isLogin) // save all play state packet
|
||||
{
|
||||
|
||||
if (!isLogin)
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{ // is login
|
||||
if (packetID == 0x02) // login success
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else return false;
|
||||
}
|
||||
|
||||
return packetID == 0x02;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
private void HandleInBoundPacket(int packetID, byte[] packetData, bool isLogin)
|
||||
{
|
||||
Queue<byte> p = new(packetData);
|
||||
PacketTypesIn pType = packetType.GetIncomingTypeById(packetID);
|
||||
// Login success. Get player UUID
|
||||
PacketTypesIn pType = _packetType.GetIncomingTypeById(packetID);
|
||||
|
||||
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);
|
||||
WriteDebugLog("User UUID: " + uuid.ToString());
|
||||
WriteDebugLog("User UUID: " + uuid);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var uuid2 = dataTypes.ReadNextUUID(p);
|
||||
SetClientPlayerUUID(uuid2);
|
||||
WriteDebugLog("User UUID: " + uuid2.ToString());
|
||||
Guid uuid = _dataTypes.ReadNextUUID(p);
|
||||
SetClientPlayerUUID(uuid);
|
||||
WriteDebugLog("User UUID: " + uuid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLogin && pType == PacketTypesIn.JoinGame)
|
||||
{
|
||||
// Get client player entity ID
|
||||
SetClientEntityID(dataTypes.ReadNextInt(p));
|
||||
SetClientEntityID(_dataTypes.ReadNextInt(p));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLogin && pType == PacketTypesIn.SpawnPlayer)
|
||||
{
|
||||
dataTypes.ReadNextVarInt(p);
|
||||
OnPlayerSpawn(dataTypes.ReadNextUUID(p));
|
||||
_dataTypes.ReadNextVarInt(p);
|
||||
OnPlayerSpawn(_dataTypes.ReadNextUUID(p));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get client player location for calculating movement delta later
|
||||
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);
|
||||
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)
|
||||
_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;
|
||||
_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;
|
||||
_playerLastPosition.X = x;
|
||||
_playerLastPosition.Y = y;
|
||||
_playerLastPosition.Z = z;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
private void WriteReplayArchiveUnsafe(string replayFilePath, bool readFromActiveStream)
|
||||
{
|
||||
var packetType = this.packetType.GetOutgoingTypeById(packetID);
|
||||
if (packetType == PacketTypesOut.PlayerPosition
|
||||
|| packetType == PacketTypesOut.PlayerPositionAndRotation)
|
||||
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())
|
||||
{
|
||||
// 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();
|
||||
packet.AddRange(DataTypes.GetVarInt(entityID));
|
||||
packet.AddRange(playerUUID.ToBigEndianBytes());
|
||||
packet.AddRange(dataTypes.GetDouble(location.X));
|
||||
packet.AddRange(dataTypes.GetDouble(location.Y));
|
||||
packet.AddRange(dataTypes.GetDouble(location.Z));
|
||||
packet.Add((byte)0);
|
||||
packet.Add((byte)0);
|
||||
return packet.ToArray();
|
||||
if (_recordStreamClosed)
|
||||
return;
|
||||
|
||||
_recordStream.Flush();
|
||||
_recordStream.Dispose();
|
||||
_recordStreamClosed = true;
|
||||
}
|
||||
|
||||
#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)
|
||||
{
|
||||
|
|
@ -422,88 +465,96 @@ namespace MinecraftClient.Protocol
|
|||
WriteLog(t);
|
||||
}
|
||||
|
||||
#endregion
|
||||
private enum ReplayRecordingState
|
||||
{
|
||||
Recording,
|
||||
Finalizing,
|
||||
Stopped
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle MetaData used by Replay mod
|
||||
/// Metadata used by Replay Mod.
|
||||
/// </summary>
|
||||
public class MetaDataHandler
|
||||
{
|
||||
public readonly string MetaDataFileName = @"metaData.json";
|
||||
public readonly string temporaryCache = @"recording_cache";
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
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 string? serverName;
|
||||
public int duration = 0; // duration of the whole replay
|
||||
public long date; // start time of the recording in unix timestamp milliseconds
|
||||
public string mcversion = "0.0"; // e.g. 1.15.2
|
||||
public string? customServerName;
|
||||
public int duration;
|
||||
public long date;
|
||||
public string mcversion = "0.0";
|
||||
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 string generator = "MCC"; // The program which generated the file (MCC have more popularity now :P)
|
||||
public int selfId = -1; // I saw -1 in medaData file generated by Replay mod. Not sure what is this for
|
||||
public List<string> players; // Array of UUIDs of all players which can be seen in the replay
|
||||
public string generator = "MCC";
|
||||
public int selfId = -1;
|
||||
|
||||
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)
|
||||
{
|
||||
players.Add(uuid.ToString());
|
||||
_players.Add(uuid.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export metadata to JSON string
|
||||
/// </summary>
|
||||
/// <returns>JSON string</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
return String.Concat(new[] { "{"
|
||||
, "\"singleplayer\":" , singlePlayer.ToString().ToLower() , ","
|
||||
, "\"serverName\":\"" , serverName , "\","
|
||||
, "\"duration\":" , duration.ToString() , ","
|
||||
, "\"date\":" , date.ToString() , ","
|
||||
, "\"mcversion\":\"" , mcversion , "\","
|
||||
, "\"fileFormat\":\"" , fileFormat , "\","
|
||||
, "\"fileFormatVersion\":" , fileFormatVersion.ToString() , ","
|
||||
, "\"protocol\":" , protocol.ToString() , ","
|
||||
, "\"generator\":\"" , generator , "\","
|
||||
, "\"selfId\":" , selfId.ToString() + ","
|
||||
, "\"player\":" , GetPlayersJsonArray()
|
||||
, "}"
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save metadata to disk file
|
||||
/// </summary>
|
||||
public void SaveToFile()
|
||||
{
|
||||
Directory.CreateDirectory(temporaryCache);
|
||||
File.WriteAllText(Path.Combine(temporaryCache, MetaDataFileName), ToJson());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get players UUID JSON array string
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private string GetPlayersJsonArray()
|
||||
private sealed class ReplayMetaDataModel
|
||||
{
|
||||
if (players.Count == 0)
|
||||
return "[]";
|
||||
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; }
|
||||
|
||||
// Place between brackets the comma-separated list of player names placed between quotes
|
||||
return String.Format("[{0}]",
|
||||
String.Join(",",
|
||||
players.Select(player => String.Format("\"{0}\"", player))
|
||||
)
|
||||
);
|
||||
[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; } = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</data>
|
||||
<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
|
||||
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>
|
||||
</data>
|
||||
<data name="ChatBot.ReplayCapture.Backup_Interval" xml:space="preserve">
|
||||
|
|
|
|||
|
|
@ -3064,11 +3064,13 @@ redirectFrom:
|
|||
|
||||
- **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>
|
||||
|
||||
**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>
|
||||
|
||||
|
|
@ -3078,9 +3080,9 @@ redirectFrom:
|
|||
|
||||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue