diff --git a/src/Network/Analyzer.WinForms/MainForm.cs b/src/Network/Analyzer.WinForms/MainForm.cs
index 22876a317e..f9fc3f9870 100644
--- a/src/Network/Analyzer.WinForms/MainForm.cs
+++ b/src/Network/Analyzer.WinForms/MainForm.cs
@@ -58,7 +58,9 @@ public MainForm()
this.connectedClientsListBox.DisplayMember = nameof(ICapturedConnection.Name);
this.connectedClientsListBox.Update();
- this._analyzer = new PacketAnalyzer();
+ // The analyzer watches the packet definition files, so that changed definitions are
+ // applied without restarting the tool.
+ this._analyzer = new PacketAnalyzer(PacketDefinitionSet.GameServer, watchFiles: true);
this.Disposed += (_, _) => this._analyzer.Dispose();
this.clientVersionComboBox.SelectedIndexChanged += this.OnSelectedClientVersionChanged;
@@ -236,8 +238,9 @@ private void SetPacketDataSource()
private void OnPacketAnalyzingRequested(object? sender, Packet.AnalyzingRequestedEventArgs e)
{
- e.ClientVersion = this._analyzer.ClientVersion;
- (e.Message, e.Definition) = this._analyzer.ExtractShortInformation(e.Packet);
+ var clientVersion = this.SelectedClientVersion;
+ e.ClientVersion = clientVersion;
+ (e.Message, e.Definition) = this._analyzer.ExtractShortInformation(e.Packet, clientVersion);
}
private void OnUnfilteredListChanged(object? sender, ListChangedEventArgs e)
@@ -267,7 +270,6 @@ private void OnSelectedClientVersionChanged(object? o, EventArgs eventArgs)
listener.ClientVersion = this.SelectedClientVersion;
}
- this._analyzer.ClientVersion = this.SelectedClientVersion;
if (this._unfilteredList is { } unfilteredList)
{
foreach (var packet in unfilteredList)
@@ -315,7 +317,6 @@ private void StartProxy(object sender, System.EventArgs e)
ClientVersion = this.SelectedClientVersion,
};
- this._analyzer.ClientVersion = this.SelectedClientVersion;
this._clientListener.ClientConnected += this.ClientListenerOnClientConnected;
this._clientListener.Start();
this.btnStartProxy.Text = "Stop Proxy";
@@ -364,7 +365,7 @@ private void OnPacketSelected(object sender, EventArgs e)
if (rows.Count > 0 && this.packetGridView.SelectedRows[0].DataBoundItem is Packet packet)
{
this.rawDataTextBox.Text = packet.PacketData;
- this.extractedInfoTextBox.Text = this._analyzer.ExtractInformation(packet);
+ this.extractedInfoTextBox.Text = this._analyzer.ExtractInformation(packet, this.SelectedClientVersion);
this.packetInfoGroup.Enabled = true;
}
else
diff --git a/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj b/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
index 0213d7ad1f..e2ad75c00d 100644
--- a/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
+++ b/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
@@ -28,6 +28,12 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/src/Network/Analyzer/PacketAnalyzer.cs b/src/Network/Analyzer/PacketAnalyzer.cs
index 4007a471e4..c1fed9b3cf 100644
--- a/src/Network/Analyzer/PacketAnalyzer.cs
+++ b/src/Network/Analyzer/PacketAnalyzer.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -16,82 +16,91 @@ namespace MUnique.OpenMU.Network.Analyzer;
///
public sealed class PacketAnalyzer : IDisposable
{
- private const string ClientToServerPacketsFile = "ClientToServerPackets.xml";
- private const string ServerToClientPacketsFile = "ServerToClientPackets.xml";
private const string CommonFile = "CommonEnums.xml";
private const int DefaultVersionValue = 100;
private const int ExtendedVersionValue = (106 * 100) + 3;
private readonly IList _watchers = new List();
- private PacketDefinitions? _clientPacketDefinitions;
- private PacketDefinitions? _serverPacketDefinitions;
- private PacketDefinitions? _commonDefinitions;
- private ClientVersion _clientVersion;
- private int _clientVersionValue = DefaultVersionValue;
///
- /// Initializes a new instance of the class.
- /// The configuration is automatically loaded from the configuration files.
+ /// The loaded packet definitions of the . The array has one
+ /// slot per file, so that a reloaded file can simply replace its previous content.
///
- public PacketAnalyzer()
- {
- this.LoadAndWatchConfiguration(def => this._serverPacketDefinitions = def, ServerToClientPacketsFile);
- this.LoadAndWatchConfiguration(def => this._clientPacketDefinitions = def, ClientToServerPacketsFile);
- this.LoadAndWatchConfiguration(def => this._commonDefinitions = def, CommonFile);
- }
+ private readonly PacketDefinitions?[] _packetDefinitions;
+
+ private PacketDefinitions? _commonDefinitions;
///
- /// Gets or sets the client version.
+ /// Initializes a new instance of the class.
+ /// The definitions are automatically loaded from the configuration files.
///
- public ClientVersion ClientVersion
+ /// The set of packet definitions which should be used.
+ /// If set to true, the definition files are watched and
+ /// automatically reloaded when they change. That's useful when new packet definitions are
+ /// developed, but usually not required when just analyzing the traffic.
+ public PacketAnalyzer(PacketDefinitionSet definitionSet = PacketDefinitionSet.GameServer, bool watchFiles = false)
{
- get => this._clientVersion;
- set
+ this.DefinitionSet = definitionSet;
+
+ var files = GetDefinitionFiles(definitionSet);
+ this._packetDefinitions = new PacketDefinitions?[files.Length];
+ for (int i = 0; i < files.Length; i++)
{
- this._clientVersion = value;
- this._clientVersionValue = (value.Season * 100) + value.Episode;
+ var index = i;
+ this.LoadConfiguration(def => this._packetDefinitions[index] = def, files[index], watchFiles);
}
+
+ this.LoadConfiguration(def => this._commonDefinitions = def, CommonFile, watchFiles);
}
+ ///
+ /// Gets the set of packet definitions which is used by this instance.
+ ///
+ public PacketDefinitionSet DefinitionSet { get; }
+
///
/// Extracts the information of the packet and returns it as a formatted string.
///
/// The packet.
+ /// The client version of the connection, which decides which
+ /// packet definition applies when more than one matches.
/// The formatted string with the extracted information.
- public string ExtractInformation(Packet packet)
+ public string ExtractInformation(Packet packet, ClientVersion clientVersion)
{
- var definitions = packet.ToServer ? this._clientPacketDefinitions : this._serverPacketDefinitions;
- var definition = this.DeterminePacketDefinition(packet);
- if (definition != null)
+ if (this.DeterminePacketDefinition(packet, clientVersion) is not { } match)
{
- var stringBuilder = new StringBuilder()
- .Append(definition.Caption ?? definition.Name);
- foreach (var field in definition.Fields ?? Enumerable.Empty())
- {
- stringBuilder.Append(Environment.NewLine)
- .Append(field.Name).Append(": ").Append(this.ExtractFieldValueOrGetError(packet.Data.AsSpan(), field, definition, definitions!));
- }
+ return string.Empty;
+ }
- return stringBuilder.ToString();
+ var (definition, definitions) = match;
+ var clientVersionValue = GetVersionValue(clientVersion);
+ var stringBuilder = new StringBuilder()
+ .Append(definition.Caption ?? definition.Name);
+ foreach (var field in definition.Fields ?? Enumerable.Empty())
+ {
+ stringBuilder.Append(Environment.NewLine)
+ .Append(field.Name).Append(": ").Append(this.ExtractFieldValueOrGetError(packet.Data.AsSpan(), field, definition, definitions, clientVersionValue));
}
- return string.Empty;
+ return stringBuilder.ToString();
}
///
/// Extracts the information of the packet and returns it as a short, formatted string.
///
/// The packet.
+ /// The client version of the connection, which decides which
+ /// packet definition applies when more than one matches.
/// The formatted string with the extracted information.
- public (string Data, PacketDefinition? Definition) ExtractShortInformation(Packet packet)
+ public (string Data, PacketDefinition? Definition) ExtractShortInformation(Packet packet, ClientVersion clientVersion)
{
- var definitions = packet.ToServer ? this._clientPacketDefinitions : this._serverPacketDefinitions;
- var definition = this.DeterminePacketDefinition(packet);
- if (definition is null)
+ if (this.DeterminePacketDefinition(packet, clientVersion) is not { } match)
{
return (packet.PacketData, null);
}
+ var (definition, definitions) = match;
+ var clientVersionValue = GetVersionValue(clientVersion);
var stringBuilder = new StringBuilder(definition.Caption ?? definition.Name ?? string.Empty);
var relevantFields = definition.Fields?
.Where(f => f.Type != FieldType.Binary && f.Type != FieldType.StructureArray)
@@ -112,7 +121,7 @@ public string ExtractInformation(Packet packet)
stringBuilder.Append(field.Name)
.Append(": ")
- .Append(this.ExtractFieldValueOrGetError(packet.Data.AsSpan(), field, definition, definitions!));
+ .Append(this.ExtractFieldValueOrGetError(packet.Data.AsSpan(), field, definition, definitions, clientVersionValue));
}
stringBuilder.Append(")");
@@ -134,13 +143,26 @@ public void Dispose()
this._watchers.Clear();
}
- private PacketDefinition? DeterminePacketDefinition(Packet packet)
+ private static int GetVersionValue(ClientVersion clientVersion)
+ {
+ return (clientVersion.Season * 100) + clientVersion.Episode;
+ }
+
+ private static string[] GetDefinitionFiles(PacketDefinitionSet definitionSet)
{
- var allDefinitions = packet.ToServer ? this._clientPacketDefinitions : this._serverPacketDefinitions;
- if (allDefinitions is null)
+ return definitionSet switch
{
- return null;
- }
+ PacketDefinitionSet.GameServer => ["ClientToServerPackets.xml", "ServerToClientPackets.xml"],
+ PacketDefinitionSet.ConnectServer => ["ConnectServerPackets.xml"],
+ PacketDefinitionSet.ChatServer => ["ChatServerPackets.xml"],
+ _ => throw new ArgumentOutOfRangeException(nameof(definitionSet), definitionSet, "Unknown packet definition set."),
+ };
+ }
+
+ private (PacketDefinition Definition, PacketDefinitions Owner)? DeterminePacketDefinition(Packet packet, ClientVersion clientVersion)
+ {
+ var direction = packet.ToServer ? Direction.ClientToServer : Direction.ServerToClient;
+ var clientVersionValue = GetVersionValue(clientVersion);
int GetVersion(string name)
{
@@ -158,51 +180,55 @@ int GetVersion(string name)
return DefaultVersionValue;
}
- var filteredDefinitions = allDefinitions.Packets?
- .Where(p => (byte)p.Type == packet.Type && p.Code == packet.Code && (!p.SubCodeSpecified || p.SubCode == packet.SubCode))
- .Select(p => (Version: GetVersion(p.Name ?? string.Empty), Definition: p))
+ var filteredDefinitions = this._packetDefinitions
+ .Where(definitions => definitions is not null)
+ .SelectMany(definitions => (definitions!.Packets ?? Enumerable.Empty())
+ .Select(p => (Definition: p, Owner: definitions)))
+ .Where(pair => pair.Definition.Direction == direction || pair.Definition.Direction == Direction.Bidirectional)
+ .Where(pair => (byte)pair.Definition.Type == packet.Type && pair.Definition.Code == packet.Code && (!pair.Definition.SubCodeSpecified || pair.Definition.SubCode == packet.SubCode))
+ .Select(pair => (Version: GetVersion(pair.Definition.Name ?? string.Empty), pair.Definition, pair.Owner))
.OrderBy(pair => pair.Version)
.ToList();
- if (filteredDefinitions is null || !filteredDefinitions.Any())
+ if (filteredDefinitions.Count == 0)
{
return null;
}
if (filteredDefinitions.Count == 1)
{
- return filteredDefinitions[0].Definition;
+ return (filteredDefinitions[0].Definition, filteredDefinitions[0].Owner);
}
- if (filteredDefinitions.FirstOrDefault(d => d.Version == this._clientVersionValue) is { Definition: { Name: { } } } exactMatch)
+ if (filteredDefinitions.FirstOrDefault(d => d.Version == clientVersionValue) is { Definition: { Name: { } } } exactMatch)
{
- return exactMatch.Definition;
+ return (exactMatch.Definition, exactMatch.Owner);
}
- var sameLengthPackets = filteredDefinitions.Where(d => d.Definition.Length == packet.Size).Select(d => d.Definition).ToList();
+ var sameLengthPackets = filteredDefinitions.Where(d => d.Definition.Length == packet.Size).ToList();
if (sameLengthPackets.Count > 0)
{
- if (sameLengthPackets.Count == 1 && sameLengthPackets.First() is { Name: { } } sameLengthMatch)
+ if (sameLengthPackets.Count == 1 && sameLengthPackets[0] is { Definition.Name: { } } sameLengthMatch)
{
- return sameLengthMatch;
+ return (sameLengthMatch.Definition, sameLengthMatch.Owner);
}
- var filteredByDefaults = this.GetPacketDefinitionsFilteredByDefaultValues(packet, sameLengthPackets, allDefinitions).ToList();
+ var filteredByDefaults = this.GetPacketDefinitionsFilteredByDefaultValues(packet, sameLengthPackets, clientVersionValue).ToList();
if (filteredByDefaults.Count == 1)
{
- return filteredByDefaults.First();
+ return (filteredByDefaults[0].Definition, filteredByDefaults[0].Owner);
}
if (filteredByDefaults.Count > 0)
{
- filteredDefinitions.RemoveAll(def => !filteredByDefaults.Contains(def.Definition));
+ filteredDefinitions.RemoveAll(def => !filteredByDefaults.Any(f => ReferenceEquals(f.Definition, def.Definition)));
}
}
- var current = filteredDefinitions.First();
+ var current = filteredDefinitions[0];
foreach (var def in filteredDefinitions.Skip(1))
{
- if (def.Version > this._clientVersionValue)
+ if (def.Version > clientVersionValue)
{
break;
}
@@ -210,31 +236,37 @@ int GetVersion(string name)
current = def;
}
- return current.Definition;
+ return (current.Definition, current.Owner);
}
- private IEnumerable GetPacketDefinitionsFilteredByDefaultValues(Packet packet, IEnumerable definitions, PacketDefinitions allDefinitions)
+ private IEnumerable<(int Version, PacketDefinition Definition, PacketDefinitions Owner)> GetPacketDefinitionsFilteredByDefaultValues(Packet packet, IEnumerable<(int Version, PacketDefinition Definition, PacketDefinitions Owner)> definitions, int clientVersionValue)
{
- foreach (var def in definitions)
+ foreach (var candidate in definitions)
{
+ var def = candidate.Definition;
var defaultFields = def.Fields?.TakeWhile(f => !string.IsNullOrWhiteSpace(f.DefaultValue)).ToList();
if (defaultFields is null or { Count: 0 })
{
break;
}
- if (defaultFields.TrueForAll(field => int.TryParse(this.ExtractFieldValueOrGetError(packet.Data, field, def, allDefinitions), out var actual)
+ if (defaultFields.TrueForAll(field => int.TryParse(this.ExtractFieldValueOrGetError(packet.Data, field, def, candidate.Owner, clientVersionValue), out var actual)
&& (int.TryParse(field.DefaultValue, out var target) || int.TryParse(field.DefaultValue!.Replace("0x", string.Empty), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out target))
&& actual == target))
{
- yield return def;
+ yield return candidate;
}
}
}
- private void LoadAndWatchConfiguration(Action assignAction, string fileName)
+ private void LoadConfiguration(Action assignAction, string fileName, bool watchFile)
{
assignAction(PacketDefinitions.Load(fileName));
+ if (!watchFile)
+ {
+ return;
+ }
+
var watcher = new FileSystemWatcher(Environment.CurrentDirectory, fileName);
watcher.Changed += (_, _) =>
@@ -268,14 +300,15 @@ private void LoadAndWatchConfiguration(Action assignAction,
/// The field definition.
/// The packet.
/// The definitions.
+ /// The numeric client version of the connection.
///
/// The value of the field or the error message.
///
- private string ExtractFieldValueOrGetError(Span data, Field field, PacketDefinition packet, PacketDefinitions definitions)
+ private string ExtractFieldValueOrGetError(Span data, Field field, PacketDefinition packet, PacketDefinitions definitions, int clientVersionValue)
{
try
{
- return this.ExtractFieldValue(data, field, packet, definitions);
+ return this.ExtractFieldValue(data, field, packet, definitions, clientVersionValue);
}
catch (Exception e)
{
@@ -290,10 +323,11 @@ private string ExtractFieldValueOrGetError(Span data, Field field, PacketD
/// The field definition.
/// The packet.
/// The definitions.
+ /// The numeric client version of the connection.
///
/// The value of the field.
///
- private string ExtractFieldValue(Span data, Field field, PacketDefinition packet, PacketDefinitions definitions)
+ private string ExtractFieldValue(Span data, Field field, PacketDefinition packet, PacketDefinitions definitions, int clientVersionValue)
{
var fieldSize = field.GetFieldSizeInBytes();
if (field.Type == FieldType.String && field.Index < data.Length)
@@ -326,14 +360,14 @@ private string ExtractFieldValue(Span data, Field field, PacketDefinition
FieldType.LongLittleEndian => ReadUInt64LittleEndian(data[field.Index..]).ToString(CultureInfo.InvariantCulture),
FieldType.LongBigEndian => ReadUInt64BigEndian(data[field.Index..]).ToString(CultureInfo.InvariantCulture),
FieldType.Enum => this.ExtractEnumValue(data, field, packet, definitions),
- FieldType.StructureArray => this.ExtractStructureArrayValues(data, field, packet, definitions),
+ FieldType.StructureArray => this.ExtractStructureArrayValues(data, field, packet, definitions, clientVersionValue),
FieldType.Float => ReadSingleLittleEndian(data[field.Index..]).ToString(CultureInfo.InvariantCulture),
FieldType.Double => ReadDoubleBigEndian(data[field.Index..]).ToString(CultureInfo.InvariantCulture),
_ => string.Empty,
};
}
- private string ExtractStructureArrayValues(Span data, Field arrayField, PacketDefinition packet, PacketDefinitions definitions)
+ private string ExtractStructureArrayValues(Span data, Field arrayField, PacketDefinition packet, PacketDefinitions definitions, int clientVersionValue)
{
var elementType = packet.Structures?.FirstOrDefault(s => s.Name == arrayField.TypeName)
?? definitions.Structures?.FirstOrDefault(s => s.Name == arrayField.TypeName)
@@ -345,7 +379,7 @@ private string ExtractStructureArrayValues(Span data, Field arrayField, Pa
var countField = packet.Fields?.FirstOrDefault(f => f.Name == arrayField.ItemCountField)
?? packet.Structures?.SelectMany(s => s.Fields ?? Enumerable.Empty()).FirstOrDefault(f => f.Name == arrayField.ItemCountField);
- int count = countField is null ? 0 : int.Parse(this.ExtractFieldValue(data, countField, packet, definitions), CultureInfo.InvariantCulture);
+ int count = countField is null ? 0 : int.Parse(this.ExtractFieldValue(data, countField, packet, definitions, clientVersionValue), CultureInfo.InvariantCulture);
if (count == 0)
{
return string.Empty;
@@ -358,7 +392,7 @@ private string ExtractStructureArrayValues(Span data, Field arrayField, Pa
for (int i = 0; i < count; i++)
{
- var currentLength = typeLength ?? this.DetermineDynamicStructLength(restData, elementType, packet) ?? fixedLengthByCount;
+ var currentLength = typeLength ?? this.DetermineDynamicStructLength(restData, elementType, packet, clientVersionValue) ?? fixedLengthByCount;
if (currentLength is null)
{
break;
@@ -374,7 +408,7 @@ private string ExtractStructureArrayValues(Span data, Field arrayField, Pa
foreach (var structField in elementType.Fields ?? Enumerable.Empty())
{
stringBuilder.Append(Environment.NewLine)
- .Append(" ").Append(structField.Name).Append(": ").Append(this.ExtractFieldValue(elementData, structField, packet, definitions));
+ .Append(" ").Append(structField.Name).Append(": ").Append(this.ExtractFieldValue(elementData, structField, packet, definitions, clientVersionValue));
}
}
@@ -418,8 +452,9 @@ private string ExtractEnumValue(Span data, Field field, PacketDefinition p
/// The rest data.
/// The type.
/// Type of the packet.
+ /// The numeric client version of the connection.
/// The dynamic length of a struct with a nested structure array.
- private int? DetermineDynamicStructLength(Span restData, Structure type, PacketDefinition packetType)
+ private int? DetermineDynamicStructLength(Span restData, Structure type, PacketDefinition packetType, int clientVersionValue)
{
if (type.Fields is null)
{
@@ -435,7 +470,7 @@ private string ExtractEnumValue(Span data, Field field, PacketDefinition p
return nestedStructField.Index + (count * nestedStructType.Length);
}
- if (this._clientVersionValue == ExtendedVersionValue
+ if (clientVersionValue == ExtendedVersionValue
&& type.Fields.FirstOrDefault(f => f.Type == FieldType.Binary) is { } binaryField
&& binaryField.Name?.EndsWith("ItemData") is true)
{
diff --git a/src/Network/Analyzer/PacketDefinitionSet.cs b/src/Network/Analyzer/PacketDefinitionSet.cs
new file mode 100644
index 0000000000..0979eb8be1
--- /dev/null
+++ b/src/Network/Analyzer/PacketDefinitionSet.cs
@@ -0,0 +1,27 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+///
+/// The set of packet definitions which is used to analyze the traffic of a connection.
+/// Which one applies depends on the server the client is connected to.
+///
+public enum PacketDefinitionSet
+{
+ ///
+ /// The packets which are exchanged between game client and game server.
+ ///
+ GameServer,
+
+ ///
+ /// The packets which are exchanged between game client and connect server.
+ ///
+ ConnectServer,
+
+ ///
+ /// The packets which are exchanged between game client and chat server.
+ ///
+ ChatServer,
+}
diff --git a/src/Network/CapturedPacketReader.cs b/src/Network/CapturedPacketReader.cs
new file mode 100644
index 0000000000..10490dc01f
--- /dev/null
+++ b/src/Network/CapturedPacketReader.cs
@@ -0,0 +1,131 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network;
+
+using System.Buffers;
+using System.IO.Pipelines;
+using Microsoft.Extensions.Logging;
+
+///
+/// Handler for a captured data packet.
+///
+/// The complete data packet.
+internal delegate void PacketCapturedHandler(ReadOnlySpan packet);
+
+///
+/// Splits the captured outgoing data of a connection into data packets again.
+///
+///
+/// One write to a is not necessarily one data packet: a bigger message
+/// may be written in several chunks, and more than one packet may be written before the writer
+/// gets flushed. The written data is therefore copied into an own pipe, which is read by this
+/// class like any other packet source of the network layer.
+///
+internal sealed class CapturedPacketReader : PacketPipeReaderBase
+{
+ private readonly PacketCapturedHandler _packetCaptured;
+
+ private readonly Action _completed;
+
+ private readonly ILogger _logger;
+
+ private readonly Pipe _pipe;
+
+ private bool _isCompleted;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The handler which is called for each complete data packet.
+ /// Is called when this reader stopped reading, e.g. because the
+ /// captured data was malformed. The caller should then stop writing into the .
+ /// The logger.
+ public CapturedPacketReader(PacketCapturedHandler packetCaptured, Action completed, ILogger logger)
+ {
+ this._packetCaptured = packetCaptured;
+ this._completed = completed;
+ this._logger = logger;
+
+ // The capturing must never slow down or block the connection itself, so the writer is
+ // never paused. The reader just forwards the packets to the sinks, so it keeps up.
+ this._pipe = new Pipe(new PipeOptions(useSynchronizationContext: false, pauseWriterThreshold: 0, resumeWriterThreshold: 0));
+ this.Source = this._pipe.Reader;
+ }
+
+ ///
+ /// Gets the writer, into which the captured data is written.
+ ///
+ public PipeWriter Writer => this._pipe.Writer;
+
+ ///
+ /// Starts reading the captured data.
+ ///
+ public void Start()
+ {
+ _ = this.ReadCapturedDataAsync();
+ }
+
+ ///
+ /// Stops the reader by completing the .
+ ///
+ public void Stop()
+ {
+ try
+ {
+ this._pipe.Writer.Complete();
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogDebug(ex, "Error when completing the packet capture.");
+ }
+ }
+
+ ///
+ protected override ValueTask ReadPacketAsync(ReadOnlySequence packet)
+ {
+ if (packet.IsSingleSegment)
+ {
+ this._packetCaptured(packet.FirstSpan);
+ }
+ else
+ {
+ this._packetCaptured(packet.ToArray());
+ }
+
+ return ValueTask.FromResult(true);
+ }
+
+ ///
+ protected override async ValueTask OnCompleteAsync(Exception? exception)
+ {
+ if (this._isCompleted)
+ {
+ return;
+ }
+
+ this._isCompleted = true;
+ if (exception is not null)
+ {
+ this._logger.LogWarning(exception, "Error while reading the captured data packets. The capturing is stopped.");
+ }
+
+ await this._pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
+ this._completed();
+ }
+
+ private async Task ReadCapturedDataAsync()
+ {
+ try
+ {
+ await this.ReadSourceAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // ReadSourceAsync already reported it to OnCompleteAsync; the capturing of this
+ // connection is over, but the connection itself is not affected.
+ this._logger.LogDebug(ex, "The packet capture reader stopped.");
+ }
+ }
+}
diff --git a/src/Network/Connection.cs b/src/Network/Connection.cs
index abc5dd65c9..917a16ec6b 100644
--- a/src/Network/Connection.cs
+++ b/src/Network/Connection.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.Network;
using System.Buffers;
+using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.IO.Pipelines;
@@ -43,9 +44,20 @@ public sealed class Connection : PacketPipeReaderBase, IConnection
private readonly ILogger _logger;
private readonly EndPoint _remoteEndPoint;
+ ///
+ /// Lock for the lifecycle of the . The sinks themselves are
+ /// lock-free; this one is just held when a capture is started or stopped, which is a rare
+ /// operation and never happens on the path of a data packet.
+ ///
+ private readonly object _captureLifecycleLock = new();
+
private IDuplexPipe? _duplexPipe;
private bool _disconnected;
- private PipeWriter? _outputWriter;
+ private ExtendedPipeWriter? _outputWriter;
+
+ private ImmutableArray _captureSinks = ImmutableArray.Empty;
+
+ private CapturedPacketReader? _outgoingCapture;
///
/// Initializes a new instance of the class.
@@ -71,6 +83,9 @@ public Connection(IDuplexPipe duplexPipe, IPipelinedDecryptor? decryptionPipe, I
///
public event AsyncEventHandler? Disconnected;
+ ///
+ public Guid Id { get; } = Guid.NewGuid();
+
///
public bool Connected => this.SocketConnection != null ? this.SocketConnection.ShutdownKind == PipeShutdownKind.None && !this._disconnected : !this._disconnected;
@@ -81,7 +96,7 @@ public Connection(IDuplexPipe duplexPipe, IPipelinedDecryptor? decryptionPipe, I
public EndPoint? LocalEndPoint { get; }
///
- public PipeWriter Output => this._outputWriter ??= new ExtendedPipeWriter(this._encryptionPipe?.Writer ?? this._duplexPipe!.Output, OutgoingBytesCounter);
+ public PipeWriter Output => this.OutputWriter;
///
public AsyncLock OutputLock { get; }
@@ -96,6 +111,12 @@ public Connection(IDuplexPipe duplexPipe, IPipelinedDecryptor? decryptionPipe, I
///
private SocketConnection? SocketConnection => this._duplexPipe as SocketConnection;
+ ///
+ /// Gets the of the , which is also
+ /// the place where the outgoing data packets are captured.
+ ///
+ private ExtendedPipeWriter OutputWriter => this.GetOrCreateOutputWriter(this._duplexPipe!);
+
///
public override string ToString() => this._remoteEndPoint?.ToString() ?? $"{base.ToString()} {this.GetHashCode()}";
@@ -146,12 +167,30 @@ public async ValueTask DisconnectAsync()
await this.Disconnected.SafeInvokeAsync().ConfigureAwait(false);
}
+ ///
+ public void AddCaptureSink(IPacketCaptureSink sink)
+ {
+ ImmutableInterlocked.Update(ref this._captureSinks, static (sinks, added) => sinks.Contains(added) ? sinks : sinks.Add(added), sink);
+ this.StartCapturingOutgoingData();
+ }
+
+ ///
+ public void RemoveCaptureSink(IPacketCaptureSink sink)
+ {
+ ImmutableInterlocked.Update(ref this._captureSinks, static (sinks, removed) => sinks.Remove(removed), sink);
+ if (this._captureSinks.IsEmpty)
+ {
+ this.StopCapturing();
+ }
+ }
+
///
public void Dispose()
{
this.DisconnectAsync().AsTask().WaitAndUnwrapException();
this.PacketReceived = null;
this.Disconnected = null;
+ this.StopCapturing();
}
///
@@ -207,6 +246,18 @@ protected override async ValueTask ReadPacketAsync(ReadOnlySequence
.Start();
try
{
+ if (!this._captureSinks.IsEmpty)
+ {
+ if (packet.IsSingleSegment)
+ {
+ this.RaisePacketCaptured(packet.FirstSpan, false);
+ }
+ else
+ {
+ this.RaisePacketCaptured(packet.ToArray(), false);
+ }
+ }
+
await this.PacketReceived.SafeInvokeAsync(packet).ConfigureAwait(false);
return true;
}
@@ -215,4 +266,90 @@ protected override async ValueTask ReadPacketAsync(ReadOnlySequence
activity?.Stop();
}
}
+
+ ///
+ /// Gets the , or creates it, if it doesn't exist yet.
+ /// It's created atomically, so that the capturing is attached to the same instance which
+ /// is used by the .
+ ///
+ /// The duplex pipe of the connection.
+ /// The of this connection.
+ private ExtendedPipeWriter GetOrCreateOutputWriter(IDuplexPipe duplexPipe)
+ {
+ if (this._outputWriter is { } existingWriter)
+ {
+ return existingWriter;
+ }
+
+ var createdWriter = new ExtendedPipeWriter(this._encryptionPipe?.Writer ?? duplexPipe.Output, OutgoingBytesCounter);
+ return Interlocked.CompareExchange(ref this._outputWriter, createdWriter, null) ?? createdWriter;
+ }
+
+ ///
+ /// Starts to capture the outgoing data packets, if it's not running yet. The incoming
+ /// packets don't need that, because they arrive as complete packets anyway.
+ ///
+ private void StartCapturingOutgoingData()
+ {
+ lock (this._captureLifecycleLock)
+ {
+ if (this._outgoingCapture is not null || this._captureSinks.IsEmpty || this._disconnected
+ || this._duplexPipe is not { } duplexPipe)
+ {
+ return;
+ }
+
+ var capture = new CapturedPacketReader(this.OnPacketSent, this.StopCapturing, this._logger);
+ this._outgoingCapture = capture;
+
+ // We don't use the OutputWriter property here, because the connection may already
+ // be disconnected - in that case there is no outgoing traffic to capture anymore.
+ this.GetOrCreateOutputWriter(duplexPipe).PendingCaptureWriter = capture.Writer;
+ capture.Start();
+ }
+ }
+
+ ///
+ /// Stops the capturing of the outgoing data packets, so that nothing is copied anymore
+ /// when nobody is watching. The applies this at the next
+ /// packet boundary, so a capture never starts or ends in the middle of a data packet.
+ ///
+ private void StopCapturing()
+ {
+ lock (this._captureLifecycleLock)
+ {
+ if (this._outgoingCapture is not { } capture)
+ {
+ return;
+ }
+
+ this._outgoingCapture = null;
+ if (this._outputWriter is { } outputWriter)
+ {
+ outputWriter.PendingCaptureWriter = null;
+ }
+
+ capture.Stop();
+ }
+ }
+
+ private void OnPacketSent(ReadOnlySpan packet)
+ {
+ this.RaisePacketCaptured(packet, true);
+ }
+
+ private void RaisePacketCaptured(ReadOnlySpan packet, bool sent)
+ {
+ foreach (var sink in this._captureSinks)
+ {
+ try
+ {
+ sink.PacketCaptured(packet, sent);
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogWarning(ex, "Error in a packet capture sink of connection {connectionId}.", this.Id);
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/src/Network/ExtendedPipeWriter.cs b/src/Network/ExtendedPipeWriter.cs
index 850daa6ae3..30c14506f5 100644
--- a/src/Network/ExtendedPipeWriter.cs
+++ b/src/Network/ExtendedPipeWriter.cs
@@ -16,6 +16,10 @@ public class ExtendedPipeWriter : PipeWriter
private readonly PipeWriter _target;
private readonly Counter _writeCounter;
+ private Memory _lastBuffer;
+
+ private PipeWriter? _activeCaptureWriter;
+
///
/// Initializes a new instance of the class.
///
@@ -27,6 +31,17 @@ public ExtendedPipeWriter(PipeWriter target, Counter writeCounter)
this._writeCounter = writeCounter;
}
+ ///
+ /// Gets or sets the writer of the packet capture, into which the written data should be
+ /// copied. If it's , nothing should be captured.
+ ///
+ ///
+ /// It's only applied at a packet boundary, because the capture would start or end in the
+ /// middle of a data packet otherwise. It can be set by any thread; the switch itself
+ /// happens on the thread which writes to this instance.
+ ///
+ internal PipeWriter? PendingCaptureWriter { get; set; }
+
///
public override void Complete(Exception? exception = null)
{
@@ -42,12 +57,40 @@ public override void CancelPendingFlush()
///
public override ValueTask FlushAsync(CancellationToken cancellationToken = default)
{
+ if (this._activeCaptureWriter is { } captureWriter)
+ {
+ return this.FlushWithCaptureAsync(captureWriter, cancellationToken);
+ }
+
+ // After a flush, the next written data starts a new packet, so a requested capture
+ // can start here.
+ this._activeCaptureWriter = this.PendingCaptureWriter;
return this._target.FlushAsync(cancellationToken);
}
///
public override void Advance(int bytes)
{
+ if (this._activeCaptureWriter is { } captureWriter && bytes > 0 && this._lastBuffer.Length >= bytes)
+ {
+ // The data has to be copied before it's advanced, because the target may recycle
+ // the buffer afterwards. The capturing must never break the connection, so a
+ // failing capture is silently ignored here.
+ try
+ {
+ this._lastBuffer.Span[..bytes].CopyTo(captureWriter.GetSpan(bytes));
+ captureWriter.Advance(bytes);
+ }
+ catch (InvalidOperationException)
+ {
+ // The capture has been completed in the meantime.
+ }
+ }
+
+ // A new buffer has to be requested before writing again, so we forget this one.
+ // That way, the same data can't be reported twice.
+ this._lastBuffer = default;
+
this._target.Advance(bytes);
this._writeCounter.Add(bytes);
}
@@ -55,14 +98,62 @@ public override void Advance(int bytes)
///
public override Memory GetMemory(int sizeHint = 0)
{
- return this._target.GetMemory(sizeHint);
+ this.ApplyPendingCaptureWriterAtPacketBoundary();
+ var memory = this._target.GetMemory(sizeHint);
+ this._lastBuffer = memory;
+ return memory;
}
///
public override Span GetSpan(int sizeHint = 0)
{
- var span = this._target.GetSpan(sizeHint);
- span.Clear();
- return span;
+ this.ApplyPendingCaptureWriterAtPacketBoundary();
+ if (this._activeCaptureWriter is null)
+ {
+ // We don't remember the buffer in this case. That way, a capture which gets
+ // attached between this call and the next Advance doesn't report stale data.
+ this._lastBuffer = default;
+ var span = this._target.GetSpan(sizeHint);
+ span.Clear();
+ return span;
+ }
+
+ var memory = this._target.GetMemory(sizeHint);
+ this._lastBuffer = memory;
+ var memorySpan = memory.Span;
+ memorySpan.Clear();
+ return memorySpan;
+ }
+
+ ///
+ /// Applies a requested change of the capture, when nothing is written to the target yet.
+ /// In that case we're at a packet boundary, so a starting capture doesn't begin in the
+ /// middle of a data packet.
+ ///
+ private void ApplyPendingCaptureWriterAtPacketBoundary()
+ {
+ if (!ReferenceEquals(this._activeCaptureWriter, this.PendingCaptureWriter)
+ && this._target is { CanGetUnflushedBytes: true, UnflushedBytes: 0 })
+ {
+ this._activeCaptureWriter = this.PendingCaptureWriter;
+ }
+ }
+
+ private async ValueTask FlushWithCaptureAsync(PipeWriter captureWriter, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await captureWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (InvalidOperationException)
+ {
+ // The capture has been completed in the meantime.
+ }
+
+ // After a flush, the next written data starts a new packet, so a requested change of
+ // the capture can be applied here.
+ this._activeCaptureWriter = this.PendingCaptureWriter;
+
+ return await this._target.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
\ No newline at end of file
diff --git a/src/Network/IConnection.cs b/src/Network/IConnection.cs
index 7ff3b24a36..2bb6f8e54c 100644
--- a/src/Network/IConnection.cs
+++ b/src/Network/IConnection.cs
@@ -29,6 +29,11 @@ public interface IConnection : IDisposable
///
event AsyncEventHandler? Disconnected;
+ ///
+ /// Gets the identifier of this connection, which is unique for the lifetime of the process.
+ ///
+ Guid Id { get; }
+
///
/// Gets a value indicating whether this is connected.
///
@@ -70,4 +75,22 @@ public interface IConnection : IDisposable
/// Disconnects this instance.
///
ValueTask DisconnectAsync();
+
+ ///
+ /// Adds a sink which gets all decrypted data packets of this connection, until it's
+ /// removed again by .
+ ///
+ /// The sink which should get the data packets.
+ ///
+ /// Adding the same sink more than once has no effect. As long as no sink is added, the
+ /// connection doesn't capture anything.
+ ///
+ void AddCaptureSink(IPacketCaptureSink sink);
+
+ ///
+ /// Removes a previously added capture sink. When the last sink is removed, the connection
+ /// stops capturing.
+ ///
+ /// The sink which should not get the data packets anymore.
+ void RemoveCaptureSink(IPacketCaptureSink sink);
}
\ No newline at end of file
diff --git a/src/Network/IPacketCaptureSink.cs b/src/Network/IPacketCaptureSink.cs
new file mode 100644
index 0000000000..3004d93d95
--- /dev/null
+++ b/src/Network/IPacketCaptureSink.cs
@@ -0,0 +1,31 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network;
+
+///
+/// A sink which gets the decrypted data packets of a connection, as long as it's registered
+/// at .
+///
+///
+/// This allows to watch the traffic of a connection without an external proxy, e.g. to show
+/// it in the admin panel. As long as no sink is registered at a connection, it doesn't
+/// capture anything.
+///
+public interface IPacketCaptureSink
+{
+ ///
+ /// Is called when a complete, decrypted data packet was received from or sent to the
+ /// remote endpoint of the connection.
+ ///
+ /// The complete, decrypted data packet.
+ /// , if the packet was sent to the remote endpoint;
+ /// , if it was received from it.
+ ///
+ /// This is called on the network thread of the connection, so an implementation should
+ /// return as fast as possible. The is only valid during the call,
+ /// so it must be copied if it's required afterwards.
+ ///
+ void PacketCaptured(ReadOnlySpan packet, bool sent);
+}
diff --git a/tests/MUnique.OpenMU.Network.Tests/MUnique.OpenMU.Network.Tests.csproj b/tests/MUnique.OpenMU.Network.Tests/MUnique.OpenMU.Network.Tests.csproj
index 6937f452d5..a8c38dfc19 100644
--- a/tests/MUnique.OpenMU.Network.Tests/MUnique.OpenMU.Network.Tests.csproj
+++ b/tests/MUnique.OpenMU.Network.Tests/MUnique.OpenMU.Network.Tests.csproj
@@ -38,5 +38,6 @@
+
diff --git a/tests/MUnique.OpenMU.Network.Tests/PacketAnalyzerTest.cs b/tests/MUnique.OpenMU.Network.Tests/PacketAnalyzerTest.cs
new file mode 100644
index 0000000000..0b2a6e3d70
--- /dev/null
+++ b/tests/MUnique.OpenMU.Network.Tests/PacketAnalyzerTest.cs
@@ -0,0 +1,107 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Tests;
+
+using MUnique.OpenMU.Network.Analyzer;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// Tests for the , especially for the selection of the packet
+/// definition by direction, definition set and client version.
+///
+[TestFixture]
+public class PacketAnalyzerTest
+{
+ private static readonly ClientVersion Season6 = new(6, 3, ClientLanguage.English);
+
+ private static readonly ClientVersion Version075 = new(0, 75, ClientLanguage.Invariant);
+
+ ///
+ /// Tests if the same packet code is resolved to a different definition, depending on the
+ /// direction of the packet.
+ ///
+ [Test]
+ public void DefinitionIsSelectedByDirection()
+ {
+ using var analyzer = new PacketAnalyzer();
+
+ var toServer = new Packet(TimeSpan.Zero, [0xC1, 0x05, 0x15, 0x01, 0x02], true);
+ var toClient = new Packet(TimeSpan.Zero, [0xC1, 0x08, 0x15, 0x00, 0x01, 0x02, 0x03, 0x04], false);
+
+ Assert.That(analyzer.ExtractShortInformation(toServer, Season6).Definition?.Name, Is.EqualTo("InstantMoveRequest"));
+ Assert.That(analyzer.ExtractShortInformation(toClient, Season6).Definition?.Name, Is.EqualTo("ObjectMoved"));
+ }
+
+ ///
+ /// Tests if the client version decides which of the definitions of a packet code applies.
+ /// The version is passed per call, so one instance can serve connections of different
+ /// client versions.
+ ///
+ [Test]
+ public void DefinitionIsSelectedByClientVersion()
+ {
+ using var analyzer = new PacketAnalyzer();
+ var packet = new Packet(TimeSpan.Zero, [0xC2, 0x00, 0x06, 0x13, 0x01, 0x00], false);
+
+ Assert.That(analyzer.ExtractShortInformation(packet, Version075).Definition?.Name, Is.EqualTo("AddNpcsToScope075"));
+ Assert.That(analyzer.ExtractShortInformation(packet, Season6).Definition?.Name, Is.EqualTo("AddNpcsToScope"));
+ }
+
+ ///
+ /// Tests if the definitions of the connect server are used when the corresponding
+ /// definition set is selected.
+ ///
+ [Test]
+ public void ConnectServerDefinitionsAreSelectable()
+ {
+ using var analyzer = new PacketAnalyzer(PacketDefinitionSet.ConnectServer);
+ var packet = new Packet(TimeSpan.Zero, [0xC1, 0x04, 0xF4, 0x06], true);
+
+ Assert.That(analyzer.ExtractShortInformation(packet, Season6).Definition?.Name, Is.EqualTo("ServerListRequest"));
+ }
+
+ ///
+ /// Tests if a packet of the game server definitions is not found in the connect server
+ /// definition set.
+ ///
+ [Test]
+ public void DefinitionsOfOtherSetsAreNotUsed()
+ {
+ using var analyzer = new PacketAnalyzer(PacketDefinitionSet.ConnectServer);
+ var packet = new Packet(TimeSpan.Zero, [0xC1, 0x05, 0x15, 0x01, 0x02], true);
+
+ Assert.That(analyzer.ExtractShortInformation(packet, Season6).Definition, Is.Null);
+ }
+
+ ///
+ /// Tests if a bidirectional packet definition is found for both directions.
+ ///
+ [Test]
+ public void BidirectionalDefinitionIsFoundInBothDirections()
+ {
+ using var analyzer = new PacketAnalyzer(PacketDefinitionSet.ChatServer);
+ var toServer = new Packet(TimeSpan.Zero, [0xC1, 0x05, 0x04, 0x00, 0x01], true);
+ var toClient = new Packet(TimeSpan.Zero, [0xC1, 0x05, 0x04, 0x00, 0x01], false);
+
+ Assert.That(analyzer.ExtractShortInformation(toServer, Season6).Definition?.Name, Is.EqualTo("ChatMessage"));
+ Assert.That(analyzer.ExtractShortInformation(toClient, Season6).Definition?.Name, Is.EqualTo("ChatMessage"));
+ }
+
+ ///
+ /// Tests if the extracted information contains the field values of the packet.
+ ///
+ [Test]
+ public void InformationContainsFieldValues()
+ {
+ using var analyzer = new PacketAnalyzer();
+ var packet = new Packet(TimeSpan.Zero, [0xC1, 0x05, 0x15, 0x0A, 0x14], true);
+
+ var information = analyzer.ExtractInformation(packet, Season6);
+
+ Assert.That(information, Does.Contain("InstantMoveRequest"));
+ Assert.That(information, Does.Contain("10"));
+ Assert.That(information, Does.Contain("20"));
+ }
+}
diff --git a/tests/MUnique.OpenMU.Network.Tests/PacketCaptureTest.cs b/tests/MUnique.OpenMU.Network.Tests/PacketCaptureTest.cs
new file mode 100644
index 0000000000..17bafa4af4
--- /dev/null
+++ b/tests/MUnique.OpenMU.Network.Tests/PacketCaptureTest.cs
@@ -0,0 +1,372 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Tests;
+
+using System.Buffers;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.Logging.Abstractions;
+
+///
+/// Tests for the packet capturing of a .
+///
+[TestFixture]
+public class PacketCaptureTest
+{
+ ///
+ /// Tests if a received data packet is reported to a registered sink.
+ ///
+ /// The async task.
+ [Test]
+ public async Task ReceivedPacketIsCapturedAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ var duplexPipe = new DuplexPipe();
+ using var connection = new Connection(duplexPipe, null, null, new NullLogger());
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+ _ = connection.BeginReceiveAsync();
+
+ await duplexPipe.ReceivePipe.Writer.WriteAsync(packet).ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(1));
+ Assert.That(captured[0].Packet, Is.EqualTo(packet));
+ Assert.That(captured[0].Sent, Is.False);
+ }
+
+ ///
+ /// Tests if a sent data packet is reported to a registered sink.
+ ///
+ /// The async task.
+ [Test]
+ public async Task SentPacketIsCapturedAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(1));
+ Assert.That(captured[0].Packet, Is.EqualTo(packet));
+ Assert.That(captured[0].Sent, Is.True);
+ }
+
+ ///
+ /// Tests if a data packet which is written in more than one chunk is reported as one packet.
+ ///
+ /// The async task.
+ [Test]
+ public async Task FragmentedPacketIsCapturedAsOnePacketAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x06, 0xF1, 0x00, 0x11, 0x22 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ Write(connection, packet.AsSpan(0, 3));
+ await connection.Output.FlushAsync().ConfigureAwait(false);
+ Assert.That(sink.Snapshot(), Is.Empty, "The packet is not complete yet.");
+
+ Write(connection, packet.AsSpan(3));
+ await connection.Output.FlushAsync().ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(1));
+ Assert.That(captured[0].Packet, Is.EqualTo(packet));
+ }
+
+ ///
+ /// Tests if more than one data packet which is written at once is reported as separate packets.
+ ///
+ /// The async task.
+ [Test]
+ public async Task MultiplePacketsInOneWriteAreCapturedSeparatelyAsync()
+ {
+ var first = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ var second = new byte[] { 0xC1, 0x03, 0xF3 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ await connection.Output.WriteAsync(first.Concat(second).ToArray()).ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(2).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(2));
+ Assert.That(captured[0].Packet, Is.EqualTo(first));
+ Assert.That(captured[1].Packet, Is.EqualTo(second));
+ }
+
+ ///
+ /// Tests if a packet with a two byte length header (C2) is captured with its correct length.
+ ///
+ /// The async task.
+ [Test]
+ public async Task BigPacketIsCapturedAsync()
+ {
+ var packet = new byte[300];
+ packet[0] = 0xC2;
+ packet[1] = 0x01;
+ packet[2] = 0x2C;
+ packet[3] = 0xF1;
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(1));
+ Assert.That(captured[0].Packet, Has.Length.EqualTo(300));
+ Assert.That(captured[0].Packet, Is.EqualTo(packet));
+ }
+
+ ///
+ /// Tests if an incomplete data packet is only reported as soon as the rest is written,
+ /// even when a complete packet was written before it.
+ ///
+ /// The async task.
+ [Test]
+ public async Task IncompleteTrailingPacketIsCapturedWhenCompletedAsync()
+ {
+ var complete = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ var trailing = new byte[] { 0xC1, 0x04, 0xF3, 0x01 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ await connection.Output.WriteAsync(complete.Concat(trailing.Take(2)).ToArray()).ConfigureAwait(false);
+ var captured = await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+ Assert.That(captured, Has.Count.EqualTo(1), "Only the complete packet should be reported.");
+
+ await connection.Output.WriteAsync(trailing.AsMemory(2)).ConfigureAwait(false);
+ captured = await sink.WaitForPacketsAsync(2).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(2));
+ Assert.That(captured[1].Packet, Is.EqualTo(trailing));
+ }
+
+ ///
+ /// Tests if all registered sinks get the data packets, and that a removed sink doesn't
+ /// get them anymore.
+ ///
+ /// The async task.
+ [Test]
+ public async Task AllRegisteredSinksAreNotifiedAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ using var connection = CreateConnection();
+ var first = new CapturingSink();
+ var second = new CapturingSink();
+ connection.AddCaptureSink(first);
+ connection.AddCaptureSink(second);
+ connection.AddCaptureSink(first); // registering twice should have no effect
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ await first.WaitForPacketsAsync(1).ConfigureAwait(false);
+ await second.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(first.Snapshot(), Has.Count.EqualTo(1));
+ Assert.That(second.Snapshot(), Has.Count.EqualTo(1));
+
+ connection.RemoveCaptureSink(first);
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ await second.WaitForPacketsAsync(2).ConfigureAwait(false);
+
+ Assert.That(first.Snapshot(), Has.Count.EqualTo(1), "The removed sink should not get further packets.");
+ Assert.That(second.Snapshot(), Has.Count.EqualTo(2));
+ }
+
+ ///
+ /// Tests if nothing is captured anymore after the last sink has been removed.
+ ///
+ /// The async task.
+ [Test]
+ public async Task NothingIsCapturedAfterLastSinkWasRemovedAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+ connection.RemoveCaptureSink(sink);
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ await Task.Delay(50).ConfigureAwait(false);
+
+ Assert.That(sink.Snapshot(), Is.Empty);
+ }
+
+ ///
+ /// Tests if the capturing can be started again after it has been stopped, because the
+ /// last sink was removed.
+ ///
+ /// The async task.
+ [Test]
+ public async Task CapturingCanBeRestartedAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+
+ connection.AddCaptureSink(sink);
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ await sink.WaitForPacketsAsync(1).ConfigureAwait(false);
+ Assert.That(sink.Snapshot(), Has.Count.EqualTo(1));
+
+ connection.RemoveCaptureSink(sink);
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ await Task.Delay(50).ConfigureAwait(false);
+ Assert.That(sink.Snapshot(), Has.Count.EqualTo(1), "Nothing should be captured without a sink.");
+
+ var secondSink = new CapturingSink();
+ connection.AddCaptureSink(secondSink);
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ var captured = await secondSink.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(captured, Has.Count.EqualTo(1));
+ Assert.That(captured[0].Packet, Is.EqualTo(packet));
+ }
+
+ ///
+ /// Tests if the written data still arrives at the target pipe unchanged, with and without
+ /// a registered capture sink.
+ ///
+ /// If set to true, a capture sink is registered.
+ /// The async task.
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task WrittenDataIsForwardedUnchangedAsync(bool withSink)
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ var duplexPipe = new DuplexPipe();
+ using var connection = new Connection(duplexPipe, null, null, new NullLogger());
+ if (withSink)
+ {
+ connection.AddCaptureSink(new CapturingSink());
+ }
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+
+ var result = await duplexPipe.SendPipe.Reader.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Buffer.ToArray(), Is.EqualTo(packet));
+ }
+
+ ///
+ /// Tests if an exception of a sink doesn't bubble up to the connection.
+ ///
+ /// The async task.
+ [Test]
+ public async Task ExceptionInSinkIsCaughtAsync()
+ {
+ var packet = new byte[] { 0xC1, 0x04, 0xF1, 0x00 };
+ using var connection = CreateConnection();
+ var working = new CapturingSink();
+ connection.AddCaptureSink(new ThrowingSink());
+ connection.AddCaptureSink(working);
+
+ await connection.Output.WriteAsync(packet).ConfigureAwait(false);
+ var captured = await working.WaitForPacketsAsync(1).ConfigureAwait(false);
+
+ Assert.That(connection.Connected, Is.True);
+ Assert.That(captured, Has.Count.EqualTo(1));
+ }
+
+ ///
+ /// Tests if malformed captured data stops the capturing without affecting the connection.
+ ///
+ /// The async task.
+ [Test]
+ public async Task MalformedDataStopsCapturingWithoutBreakingTheConnectionAsync()
+ {
+ var malformed = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
+ using var connection = CreateConnection();
+ var sink = new CapturingSink();
+ connection.AddCaptureSink(sink);
+
+ await connection.Output.WriteAsync(malformed).ConfigureAwait(false);
+ await Task.Delay(50).ConfigureAwait(false);
+
+ Assert.That(sink.Snapshot(), Is.Empty);
+ Assert.That(connection.Connected, Is.True);
+
+ // The connection still works, it's just not captured anymore.
+ await connection.Output.WriteAsync(new byte[] { 0xC1, 0x04, 0xF1, 0x00 }).ConfigureAwait(false);
+ Assert.That(connection.Connected, Is.True);
+ }
+
+ ///
+ /// Tests if each connection has its own identifier.
+ ///
+ [Test]
+ public void EachConnectionHasItsOwnId()
+ {
+ using var first = CreateConnection();
+ using var second = CreateConnection();
+
+ Assert.That(first.Id, Is.Not.EqualTo(Guid.Empty));
+ Assert.That(first.Id, Is.Not.EqualTo(second.Id));
+ }
+
+ private static Connection CreateConnection()
+ {
+ return new Connection(new DuplexPipe(), null, null, new NullLogger());
+ }
+
+ private static void Write(Connection connection, ReadOnlySpan data)
+ {
+ var span = connection.Output.GetSpan(data.Length);
+ data.CopyTo(span);
+ connection.Output.Advance(data.Length);
+ }
+
+ private sealed class CapturingSink : IPacketCaptureSink
+ {
+ private readonly List<(byte[] Packet, bool Sent)> _captured = new();
+
+ public void PacketCaptured(ReadOnlySpan packet, bool sent)
+ {
+ var entry = (packet.ToArray(), sent);
+ lock (this._captured)
+ {
+ this._captured.Add(entry);
+ }
+ }
+
+ public IList<(byte[] Packet, bool Sent)> Snapshot()
+ {
+ lock (this._captured)
+ {
+ return this._captured.ToList();
+ }
+ }
+
+ public async Task> WaitForPacketsAsync(int count)
+ {
+ for (int i = 0; i < 100; i++)
+ {
+ var snapshot = this.Snapshot();
+ if (snapshot.Count >= count)
+ {
+ return snapshot;
+ }
+
+ await Task.Delay(10).ConfigureAwait(false);
+ }
+
+ return this.Snapshot();
+ }
+ }
+
+ private sealed class ThrowingSink : IPacketCaptureSink
+ {
+ public void PacketCaptured(ReadOnlySpan packet, bool sent)
+ {
+ throw new InvalidOperationException("This sink is broken.");
+ }
+ }
+}