mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: modernize ReplayCapture
This commit is contained in:
commit
03d3d6f950
4 changed files with 392 additions and 336 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; } = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue