From e6cf10ddd0a1d109ae668c3f0c49526fa6083ec4 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 24 Apr 2026 15:18:42 +1200 Subject: [PATCH 1/2] perf: batch packet log UI updates; rename Send/Recv tags to Client/Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drain captured packets on a 50 ms UI-thread Timer into a single BeginUpdate/AddRange/EndUpdate block instead of BeginInvoke-ing the UI thread once per packet. At high packet rates this was saturating the message loop; the batched path keeps the ListBox responsive. Cache the formatted display string on LoggedPacket so repaints don't re-format per item. Select All now runs inside BeginUpdate/EndUpdate so 5000-item selection no longer fires a paint per SetSelected call. Rename the direction tag from [Send]/[Recv] to [Client]/[Server] — it was ambiguous whether "Send" meant sent by client or sent by server. [Client] now unambiguously marks packets that originated on the game client (client→server) and [Server] marks packets that originated on the server (server→client). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/NosCore.DeveloperTools/Forms/MainForm.cs | 60 ++++++++++++++++--- .../Models/LoggedPacket.cs | 14 +++-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/NosCore.DeveloperTools/Forms/MainForm.cs b/src/NosCore.DeveloperTools/Forms/MainForm.cs index 53e5139..2c33711 100644 --- a/src/NosCore.DeveloperTools/Forms/MainForm.cs +++ b/src/NosCore.DeveloperTools/Forms/MainForm.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using NosCore.DeveloperTools.Models; using NosCore.DeveloperTools.Remote; @@ -16,6 +17,11 @@ public sealed class MainForm : Form private readonly ToolStripStatusLabel _statusLabel = new() { Text = "No process attached." }; private readonly ListBox _logListBox = new(); + // High packet rates mean per-packet BeginInvoke + ListBox.Add would saturate the UI thread. + // Capture threads enqueue here; a UI-thread timer drains the queue in one BeginUpdate/EndUpdate batch. + private readonly ConcurrentQueue _pendingPackets = new(); + private readonly System.Windows.Forms.Timer _flushTimer = new() { Interval = 50 }; + private const int LogCap = 5000; private readonly CheckBox _captureSendBox = new() { Text = "Capture Send", AutoSize = true }; private readonly CheckBox _captureRecvBox = new() { Text = "Capture Recv", AutoSize = true }; private readonly Button _clearButton = new() { Text = "Clear", AutoSize = true }; @@ -813,8 +819,12 @@ private TabPage BuildAboutTab() private void WireEvents() { - _log.PacketLogged += (_, packet) => BeginInvoke(() => AppendLog(packet)); - _log.Cleared += (_, _) => BeginInvoke(() => _logListBox.Items.Clear()); + _log.PacketLogged += (_, packet) => _pendingPackets.Enqueue(packet); + _log.Cleared += (_, _) => BeginInvoke(() => + { + while (_pendingPackets.TryDequeue(out _)) { } + _logListBox.Items.Clear(); + }); _injection.PacketCaptured += (_, args) => { @@ -824,8 +834,12 @@ private void WireEvents() }; _injection.StatusChanged += (_, msg) => BeginInvoke(() => _statusLabel.Text = msg); + _flushTimer.Tick += (_, _) => FlushPendingPackets(); + _flushTimer.Start(); + FormClosing += (_, _) => { + _flushTimer.Stop(); _settings.MainWindow.Width = Size.Width; _settings.MainWindow.Height = Size.Height; _settings.MainWindow.X = Location.X; @@ -834,14 +848,34 @@ private void WireEvents() }; } - private void AppendLog(LoggedPacket packet) + private void FlushPendingPackets() { - _logListBox.Items.Add(packet); - if (_logListBox.Items.Count > 5000) + if (_pendingPackets.IsEmpty) return; + + // Snapshot the queue to a local array so the batch add runs in a single + // BeginUpdate/EndUpdate block even if more packets arrive during the drain. + var buffer = new List(); + while (_pendingPackets.TryDequeue(out var p)) { - _logListBox.Items.RemoveAt(0); + buffer.Add(p); + } + if (buffer.Count == 0) return; + + _logListBox.BeginUpdate(); + try + { + _logListBox.Items.AddRange(buffer.ToArray()); + var excess = _logListBox.Items.Count - LogCap; + for (var i = 0; i < excess; i++) + { + _logListBox.Items.RemoveAt(0); + } + _logListBox.TopIndex = Math.Max(0, _logListBox.Items.Count - 1); + } + finally + { + _logListBox.EndUpdate(); } - _logListBox.TopIndex = Math.Max(0, _logListBox.Items.Count - 1); } private ContextMenuStrip BuildLogContextMenu() @@ -883,9 +917,17 @@ private void OnLogKeyDown(object? sender, KeyEventArgs e) private void SelectAllLog() { - for (var i = 0; i < _logListBox.Items.Count; i++) + _logListBox.BeginUpdate(); + try + { + for (var i = 0; i < _logListBox.Items.Count; i++) + { + _logListBox.SetSelected(i, true); + } + } + finally { - _logListBox.SetSelected(i, true); + _logListBox.EndUpdate(); } } diff --git a/src/NosCore.DeveloperTools/Models/LoggedPacket.cs b/src/NosCore.DeveloperTools/Models/LoggedPacket.cs index 8853c8a..8f4ab62 100644 --- a/src/NosCore.DeveloperTools/Models/LoggedPacket.cs +++ b/src/NosCore.DeveloperTools/Models/LoggedPacket.cs @@ -19,11 +19,15 @@ public sealed record LoggedPacket( string Header, string Raw) { - /// Full line with timestamp + tags. Used as the ListBox DisplayString. - public override string ToString() + private readonly string _display = FormatDisplay(Timestamp, Connection, Direction, Raw); + + public override string ToString() => _display; + + private static string FormatDisplay(DateTime ts, PacketConnection conn, PacketDirection dir, string raw) { - var conn = Connection == PacketConnection.Login ? "Login" : "World"; - var dir = Direction == PacketDirection.Send ? "Send" : "Recv"; - return $"[{Timestamp:HH:mm:ss.fff}] [{conn}] [{dir}] {Raw}"; + var c = conn == PacketConnection.Login ? "Login" : "World"; + // [Client] = originated on the game client (client→server); [Server] = originated on the server (server→client). + var source = dir == PacketDirection.Send ? "Client" : "Server"; + return $"[{ts:HH:mm:ss.fff}] [{c}] [{source}] {raw}"; } } From f38e058f4b3366654ec8beda1474bf3185df6d23 Mon Sep 17 00:00:00 2001 From: Erwan JOLY <35202750+erwan-joly@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:12:54 +1200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Issues=20sub-tab=20=E2=80=94=20flag?= =?UTF-8?q?s=20missing/wrong-structure/wrong-tag=20packets=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Issues sub-tab detecting Missing / WrongStructure / WrongTag packets Every captured packet is run through NosCore.Packets's deserializer against its expected direction, and anomalies are logged in a new Issues sub-tab under the Packets tab: - Missing — header not defined anywhere in NosCore.Packets. - WrongStructure — header defined for this direction but the deserializer rejects the wire format. - WrongTag — header is defined only in the opposite-direction namespace (client header captured as [Server], or vice versa). Uses two separate Deserializer instances so the "server loses to client on duplicate header" dedup inside Deserializer.Initialize doesn't hide wrong-tag cases. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: filter packet types to PacketBase-derived only NosCore.Packets has classes marked with [PacketHeader] that don't inherit from PacketBase (e.g. ServerPackets.Event.EventPacket). Passing those to Deserializer crashed startup: ArgumentException: GenericArguments[0], 'EventPacket', on 'Void Initialize[T]()' violates the constraint of type 'T'. Add an IsAssignableFrom(PacketBase) filter before constructing the deserializers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: Copy / Copy with tags / Select all on the Issues listbox Same context menu + Ctrl+C / Ctrl+A handling the Log listbox has, now on Issues too. Parameterised so the listbox is passed in rather than hard-coded to _logListBox. Copy picks up Raw text for both item types (LoggedPacket.Raw and PacketValidationIssue.Packet.Raw); Copy with tags uses the full ToString() line — including the [Missing] / [Wrong structure] / [Wrong tag] category and the deserializer detail. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: colored stripe on Log rows that have a validation issue Each LoggedPacket carries a nullable ValidationCategory set when the validator flags it. The Log listbox is owner-drawn and paints a 4px left stripe per row: gold for Missing, indian-red for WrongStructure, dark-orange for WrongTag. Selection highlighting still works — the stripe sits over the normal row background. Lets you see problematic packets at a glance while reading the Log, without having to switch to the Issues tab for every packet. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: failed-headers summary on Issues; drop plain Copy on Issues menu - Issues sub-tab gets a read-only TextBox docked at the top listing every packet header that failed validation (unique, sorted). Cleared with the log. - Context menu on Issues drops the raw "Copy" option — only "Copy with tags" makes sense there since the tags carry the category and deserializer detail. Ctrl+C on the Issues listbox now copies with tags; Log listbox behavior is unchanged. - Clear handling consolidated into the PacketLogService.Cleared event so both the Clear button and any future direct clear call empty the log, issues queue, issues listbox, and failed-headers set. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: register sub-packet types so 'key not present in dictionary' goes away Deserializer.DeserializeValue looks up sub-packet property types by typeof(T).Name in _packetDeserializerDictionary. Sub-packets don't carry [PacketHeader] — my original type filter excluded them, so every parent packet with a sub-packet field failed with e.g. The given key 'InCharacterSubPacket' was not present in the dictionary. Register all PacketBase-derived types without [PacketHeader] on both client and server deserializer instances (they're direction-neutral). Keep the _clientHeaders / _serverHeaders sets populated from header- bearing types only so the direction-match check still works. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: skip the three pre-auth login-handshake lines in validator Every world-server connection opens with three bare lines from the client before the encrypted packet stream: — single numeric token GF — username / platform / region thisisgfmode — GF-mode marker None of them use the header protocol so NosCore.Packets legitimately doesn't define them. Flagging them as Missing was pure noise on the Issues tab. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- src/NosCore.DeveloperTools/Forms/MainForm.cs | 220 +++++++++++++++--- .../Models/LoggedPacket.cs | 3 + .../Models/PacketValidationIssue.cs | 33 +++ src/NosCore.DeveloperTools/Program.cs | 3 +- .../Services/PacketValidationService.cs | 140 +++++++++++ 5 files changed, 362 insertions(+), 37 deletions(-) create mode 100644 src/NosCore.DeveloperTools/Models/PacketValidationIssue.cs create mode 100644 src/NosCore.DeveloperTools/Services/PacketValidationService.cs diff --git a/src/NosCore.DeveloperTools/Forms/MainForm.cs b/src/NosCore.DeveloperTools/Forms/MainForm.cs index 2c33711..820bbb6 100644 --- a/src/NosCore.DeveloperTools/Forms/MainForm.cs +++ b/src/NosCore.DeveloperTools/Forms/MainForm.cs @@ -13,15 +13,27 @@ public sealed class MainForm : Form private readonly ProcessService _processService; private readonly IInjectionService _injection; private readonly PacketLogService _log; + private readonly PacketValidationService _validation; private AppSettings _settings; private readonly ToolStripStatusLabel _statusLabel = new() { Text = "No process attached." }; private readonly ListBox _logListBox = new(); + private readonly ListBox _issuesListBox = new(); + private readonly TextBox _failedHeadersBox = new() + { + Dock = DockStyle.Top, + ReadOnly = true, + Multiline = false, + PlaceholderText = "Packet headers that have failed validation (cleared with the log).", + }; + private readonly SortedSet _failedHeaders = new(StringComparer.Ordinal); // High packet rates mean per-packet BeginInvoke + ListBox.Add would saturate the UI thread. // Capture threads enqueue here; a UI-thread timer drains the queue in one BeginUpdate/EndUpdate batch. private readonly ConcurrentQueue _pendingPackets = new(); + private readonly ConcurrentQueue _pendingIssues = new(); private readonly System.Windows.Forms.Timer _flushTimer = new() { Interval = 50 }; private const int LogCap = 5000; + private const int IssuesCap = 5000; private readonly CheckBox _captureSendBox = new() { Text = "Capture Send", AutoSize = true }; private readonly CheckBox _captureRecvBox = new() { Text = "Capture Recv", AutoSize = true }; private readonly Button _clearButton = new() { Text = "Clear", AutoSize = true }; @@ -34,12 +46,14 @@ public MainForm( SettingsService settingsService, ProcessService processService, IInjectionService injection, - PacketLogService log) + PacketLogService log, + PacketValidationService validation) { _settingsService = settingsService; _processService = processService; _injection = injection; _log = log; + _validation = validation; _settings = _settingsService.Load(); Text = "NosCore.DeveloperTools"; @@ -134,8 +148,23 @@ private TabPage BuildPacketsTab() _logListBox.Font = new Font(FontFamily.GenericMonospace, 9F); _logListBox.HorizontalScrollbar = true; _logListBox.SelectionMode = SelectionMode.MultiExtended; - _logListBox.KeyDown += OnLogKeyDown; - _logListBox.ContextMenuStrip = BuildLogContextMenu(); + _logListBox.DrawMode = DrawMode.OwnerDrawFixed; + _logListBox.ItemHeight = _logListBox.Font.Height + 2; + // OwnerDraw disables ListBox's auto-measuring of item width, so pick a large + // static extent covering anything we realistically capture (raw packets rarely + // exceed ~500 chars × ~7 px/char). + _logListBox.HorizontalExtent = 4000; + _logListBox.DrawItem += LogListBox_DrawItem; + _logListBox.KeyDown += OnListKeyDown; + _logListBox.ContextMenuStrip = BuildListContextMenu(_logListBox); + + _issuesListBox.Dock = DockStyle.Fill; + _issuesListBox.IntegralHeight = false; + _issuesListBox.Font = new Font(FontFamily.GenericMonospace, 9F); + _issuesListBox.HorizontalScrollbar = true; + _issuesListBox.SelectionMode = SelectionMode.MultiExtended; + _issuesListBox.KeyDown += OnListKeyDown; + _issuesListBox.ContextMenuStrip = BuildListContextMenu(_issuesListBox, includeRawCopy: false); var toolbar = new FlowLayoutPanel { @@ -168,7 +197,17 @@ private TabPage BuildPacketsTab() var injectBar = BuildInjectBar(); - page.Controls.Add(_logListBox); + var subTabs = new TabControl { Dock = DockStyle.Fill }; + var logPage = new TabPage("Log"); + logPage.Controls.Add(_logListBox); + var issuesPage = new TabPage("Issues"); + // Fill must be added before the docked Top control so it occupies the remaining space. + issuesPage.Controls.Add(_issuesListBox); + issuesPage.Controls.Add(_failedHeadersBox); + subTabs.TabPages.Add(logPage); + subTabs.TabPages.Add(issuesPage); + + page.Controls.Add(subTabs); page.Controls.Add(injectBar); page.Controls.Add(toolbar); return page; @@ -823,13 +862,24 @@ private void WireEvents() _log.Cleared += (_, _) => BeginInvoke(() => { while (_pendingPackets.TryDequeue(out _)) { } + while (_pendingIssues.TryDequeue(out _)) { } _logListBox.Items.Clear(); + _issuesListBox.Items.Clear(); + _failedHeaders.Clear(); + _failedHeadersBox.Text = string.Empty; }); _injection.PacketCaptured += (_, args) => { // Drop filtered packets at intake so they never reach the log at all. if (!ShouldCapture(args.Packet)) return; + // Stamp Issue before enqueueing so the Log flush sees the flag on first draw. + var issue = _validation.Validate(args.Packet); + if (issue is not null) + { + args.Packet.Issue = issue.Category; + _pendingIssues.Enqueue(issue); + } _log.Add(args.Packet); }; _injection.StatusChanged += (_, msg) => BeginInvoke(() => _statusLabel.Text = msg); @@ -850,93 +900,191 @@ private void WireEvents() private void FlushPendingPackets() { - if (_pendingPackets.IsEmpty) return; + FlushQueueInto(_pendingPackets, _logListBox, LogCap); + FlushIssues(); + } + + private void FlushIssues() + { + if (_pendingIssues.IsEmpty) return; + + var buffer = new List(); + while (_pendingIssues.TryDequeue(out var issue)) + { + buffer.Add(issue); + } + if (buffer.Count == 0) return; + + var headersDirty = false; + foreach (var issue in buffer) + { + var h = issue.Packet.Header; + if (!string.IsNullOrEmpty(h) && _failedHeaders.Add(h)) + { + headersDirty = true; + } + } + + _issuesListBox.BeginUpdate(); + try + { + _issuesListBox.Items.AddRange(buffer.Cast().ToArray()); + var excess = _issuesListBox.Items.Count - IssuesCap; + for (var i = 0; i < excess; i++) + { + _issuesListBox.Items.RemoveAt(0); + } + _issuesListBox.TopIndex = Math.Max(0, _issuesListBox.Items.Count - 1); + } + finally + { + _issuesListBox.EndUpdate(); + } + + if (headersDirty) + { + _failedHeadersBox.Text = string.Join(", ", _failedHeaders); + } + } + + private static void FlushQueueInto(ConcurrentQueue source, ListBox target, int cap) + { + if (source.IsEmpty) return; // Snapshot the queue to a local array so the batch add runs in a single - // BeginUpdate/EndUpdate block even if more packets arrive during the drain. - var buffer = new List(); - while (_pendingPackets.TryDequeue(out var p)) + // BeginUpdate/EndUpdate block even if more items arrive during the drain. + var buffer = new List(); + while (source.TryDequeue(out var item)) { - buffer.Add(p); + buffer.Add(item!); } if (buffer.Count == 0) return; - _logListBox.BeginUpdate(); + target.BeginUpdate(); try { - _logListBox.Items.AddRange(buffer.ToArray()); - var excess = _logListBox.Items.Count - LogCap; + target.Items.AddRange(buffer.Cast().ToArray()); + var excess = target.Items.Count - cap; for (var i = 0; i < excess; i++) { - _logListBox.Items.RemoveAt(0); + target.Items.RemoveAt(0); } - _logListBox.TopIndex = Math.Max(0, _logListBox.Items.Count - 1); + target.TopIndex = Math.Max(0, target.Items.Count - 1); } finally { - _logListBox.EndUpdate(); + target.EndUpdate(); } } - private ContextMenuStrip BuildLogContextMenu() + private ContextMenuStrip BuildListContextMenu(ListBox list, bool includeRawCopy = true) { var menu = new ContextMenuStrip(); - var copy = new ToolStripMenuItem("Copy") { ShortcutKeyDisplayString = "Ctrl+C" }; - copy.Click += (_, _) => CopySelected(withTags: false); - menu.Items.Add(copy); + if (includeRawCopy) + { + var copy = new ToolStripMenuItem("Copy") { ShortcutKeyDisplayString = "Ctrl+C" }; + copy.Click += (_, _) => CopySelected(list, withTags: false); + menu.Items.Add(copy); - var copyTags = new ToolStripMenuItem("Copy with tags"); - copyTags.Click += (_, _) => CopySelected(withTags: true); - menu.Items.Add(copyTags); + var copyTags = new ToolStripMenuItem("Copy with tags"); + copyTags.Click += (_, _) => CopySelected(list, withTags: true); + menu.Items.Add(copyTags); + } + else + { + // For the Issues listbox, raw-only copy is useless — the tags carry the category + detail. + var copyTags = new ToolStripMenuItem("Copy") { ShortcutKeyDisplayString = "Ctrl+C" }; + copyTags.Click += (_, _) => CopySelected(list, withTags: true); + menu.Items.Add(copyTags); + } menu.Items.Add(new ToolStripSeparator()); var selectAll = new ToolStripMenuItem("Select all") { ShortcutKeyDisplayString = "Ctrl+A" }; - selectAll.Click += (_, _) => SelectAllLog(); + selectAll.Click += (_, _) => SelectAll(list); menu.Items.Add(selectAll); return menu; } - private void OnLogKeyDown(object? sender, KeyEventArgs e) + private void OnListKeyDown(object? sender, KeyEventArgs e) { + if (sender is not ListBox list) return; if (e.Control && e.KeyCode == Keys.A) { - SelectAllLog(); + SelectAll(list); e.SuppressKeyPress = true; e.Handled = true; } else if (e.Control && e.KeyCode == Keys.C) { - CopySelected(withTags: false); + // Issues listbox: always copy with tags (the category + detail is the whole point). + var withTags = list == _issuesListBox; + CopySelected(list, withTags); e.SuppressKeyPress = true; e.Handled = true; } } - private void SelectAllLog() + private void LogListBox_DrawItem(object? sender, DrawItemEventArgs e) { - _logListBox.BeginUpdate(); + if (e.Index < 0 || e.Index >= _logListBox.Items.Count) return; + e.DrawBackground(); + + const int stripeWidth = 4; + var packet = _logListBox.Items[e.Index] as LoggedPacket; + if (packet?.Issue is { } category) + { + var color = category switch + { + ValidationCategory.Missing => Color.Gold, + ValidationCategory.WrongStructure => Color.IndianRed, + ValidationCategory.WrongTag => Color.DarkOrange, + _ => Color.Transparent, + }; + using var brush = new SolidBrush(color); + e.Graphics.FillRectangle(brush, e.Bounds.Left, e.Bounds.Top, stripeWidth, e.Bounds.Height); + } + + var fg = (e.State & DrawItemState.Selected) != 0 ? SystemColors.HighlightText : SystemColors.WindowText; + var textBounds = new Rectangle( + e.Bounds.Left + stripeWidth + 2, e.Bounds.Top, + e.Bounds.Width - stripeWidth - 2, e.Bounds.Height); + TextRenderer.DrawText( + e.Graphics, + packet?.ToString() ?? _logListBox.Items[e.Index]?.ToString() ?? string.Empty, + e.Font ?? _logListBox.Font, + textBounds, fg, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); + e.DrawFocusRectangle(); + } + + private static void SelectAll(ListBox list) + { + list.BeginUpdate(); try { - for (var i = 0; i < _logListBox.Items.Count; i++) + for (var i = 0; i < list.Items.Count; i++) { - _logListBox.SetSelected(i, true); + list.SetSelected(i, true); } } finally { - _logListBox.EndUpdate(); + list.EndUpdate(); } } - private void CopySelected(bool withTags) + private static void CopySelected(ListBox list, bool withTags) { - if (_logListBox.SelectedItems.Count == 0) return; - var lines = _logListBox.SelectedItems - .Cast() - .Select(p => withTags ? p.ToString() : p.Raw); + if (list.SelectedItems.Count == 0) return; + var lines = list.SelectedItems.Cast().Select(item => item switch + { + LoggedPacket p => withTags ? p.ToString() : p.Raw, + PacketValidationIssue i => withTags ? i.ToString() : i.Packet.Raw, + _ => item?.ToString() ?? string.Empty, + }); var text = string.Join(Environment.NewLine, lines); if (!string.IsNullOrEmpty(text)) { diff --git a/src/NosCore.DeveloperTools/Models/LoggedPacket.cs b/src/NosCore.DeveloperTools/Models/LoggedPacket.cs index 8f4ab62..f2b6870 100644 --- a/src/NosCore.DeveloperTools/Models/LoggedPacket.cs +++ b/src/NosCore.DeveloperTools/Models/LoggedPacket.cs @@ -21,6 +21,9 @@ public sealed record LoggedPacket( { private readonly string _display = FormatDisplay(Timestamp, Connection, Direction, Raw); + /// Set by PacketValidationService when validation flags this packet. Drives the Log tab's issue indicator. + public ValidationCategory? Issue { get; set; } + public override string ToString() => _display; private static string FormatDisplay(DateTime ts, PacketConnection conn, PacketDirection dir, string raw) diff --git a/src/NosCore.DeveloperTools/Models/PacketValidationIssue.cs b/src/NosCore.DeveloperTools/Models/PacketValidationIssue.cs new file mode 100644 index 0000000..b1ed2e2 --- /dev/null +++ b/src/NosCore.DeveloperTools/Models/PacketValidationIssue.cs @@ -0,0 +1,33 @@ +namespace NosCore.DeveloperTools.Models; + +public enum ValidationCategory +{ + Missing, + WrongStructure, + WrongTag, +} + +public sealed record PacketValidationIssue( + DateTime Timestamp, + ValidationCategory Category, + LoggedPacket Packet, + string Detail) +{ + private readonly string _display = Format(Timestamp, Category, Packet, Detail); + + public override string ToString() => _display; + + private static string Format(DateTime ts, ValidationCategory cat, LoggedPacket p, string detail) + { + var tag = cat switch + { + ValidationCategory.Missing => "[Missing]", + ValidationCategory.WrongStructure => "[Wrong structure]", + ValidationCategory.WrongTag => "[Wrong tag]", + _ => $"[{cat}]", + }; + var conn = p.Connection == PacketConnection.Login ? "Login" : "World"; + var src = p.Direction == PacketDirection.Send ? "Client" : "Server"; + return $"[{ts:HH:mm:ss.fff}] {tag} [{conn}] [{src}] {p.Raw} — {detail}"; + } +} diff --git a/src/NosCore.DeveloperTools/Program.cs b/src/NosCore.DeveloperTools/Program.cs index 650e2fd..f6fad70 100644 --- a/src/NosCore.DeveloperTools/Program.cs +++ b/src/NosCore.DeveloperTools/Program.cs @@ -29,7 +29,8 @@ private static void Main() var processService = new ProcessService(); using var injection = new RemoteAttachmentService(); var log = new PacketLogService(); - using var mainForm = new MainForm(settingsService, processService, injection, log); + var validation = new PacketValidationService(); + using var mainForm = new MainForm(settingsService, processService, injection, log, validation); DiagnosticLog.Info("MainForm constructed, Application.Run()"); Application.Run(mainForm); DiagnosticLog.Info("Application.Run() returned normally"); diff --git a/src/NosCore.DeveloperTools/Services/PacketValidationService.cs b/src/NosCore.DeveloperTools/Services/PacketValidationService.cs new file mode 100644 index 0000000..803edbf --- /dev/null +++ b/src/NosCore.DeveloperTools/Services/PacketValidationService.cs @@ -0,0 +1,140 @@ +using System.Reflection; +using NosCore.DeveloperTools.Models; +using NosCore.Packets; +using NosCore.Packets.Attributes; + +namespace NosCore.DeveloperTools.Services; + +/// +/// Runs every captured packet through NosCore.Packets's deserializer +/// against the direction it was tagged with, and reports anomalies: +/// +/// Missing — header isn't defined anywhere in NosCore.Packets. +/// WrongStructure — header is defined for this direction but the wire format doesn't match the schema (deserializer throws). +/// WrongTag — header is defined but only in the opposite-direction namespace (a client header appeared tagged as [Server], or vice versa). +/// +/// Two separate instances are built so the +/// existing "server loses to client on duplicate header" logic inside +/// doesn't mask wrong-tag cases. +/// +public sealed class PacketValidationService +{ + private readonly Deserializer _clientDeserializer; + private readonly Deserializer _serverDeserializer; + private readonly HashSet _clientHeaders; + private readonly HashSet _serverHeaders; + + public PacketValidationService() + { + var allPacketBase = typeof(PacketBase).Assembly + .GetTypes() + .Where(t => t is { IsAbstract: false, IsClass: true }) + .Where(t => typeof(PacketBase).IsAssignableFrom(t)) + .ToList(); + + // Header-bearing types are the top-level packets, split by direction. + var clientHeadered = allPacketBase + .Where(t => t.Namespace?.Contains("ClientPackets") == true) + .Where(t => t.GetCustomAttribute() != null) + .ToList(); + var serverHeadered = allPacketBase + .Where(t => t.Namespace?.Contains("ServerPackets") == true) + .Where(t => t.GetCustomAttribute() != null) + .ToList(); + + // Sub-packet types have no [PacketHeader] — the Deserializer keys them by + // typeof(T).Name. They're not direction-specific, so feed them to both + // deserializers; otherwise DeserializeValue crashes on 'The given key + // was not present in the dictionary.' + var subpackets = allPacketBase + .Where(t => t.GetCustomAttribute() == null) + .ToList(); + + _clientDeserializer = new Deserializer(clientHeadered.Concat(subpackets)); + _serverDeserializer = new Deserializer(serverHeadered.Concat(subpackets)); + + // Only headered types drive the direction-match check; sub-packets never + // appear as top-level headers on the wire. + _clientHeaders = new HashSet(clientHeadered.SelectMany(HeadersOf), StringComparer.Ordinal); + _serverHeaders = new HashSet(serverHeadered.SelectMany(HeadersOf), StringComparer.Ordinal); + } + + public PacketValidationIssue? Validate(LoggedPacket packet) + { + // Skip the pre-auth handshake lines the NosTale client sends before the + // encrypted packet stream begins — they don't use the normal header + // protocol and are correctly absent from NosCore.Packets. + if (IsLoginHandshake(packet)) return null; + + var header = ExtractHeader(packet.Raw); + if (string.IsNullOrEmpty(header)) return null; + + var fromClient = packet.Direction == PacketDirection.Send; + var expected = fromClient ? _clientHeaders : _serverHeaders; + var opposite = fromClient ? _serverHeaders : _clientHeaders; + var deserializer = fromClient ? _clientDeserializer : _serverDeserializer; + + if (expected.Contains(header)) + { + try + { + _ = deserializer.Deserialize(packet.Raw); + return null; + } + catch (Exception ex) + { + var detail = ex.InnerException?.Message ?? ex.Message; + return new PacketValidationIssue(DateTime.Now, ValidationCategory.WrongStructure, packet, detail); + } + } + + if (opposite.Contains(header)) + { + var expectedTag = fromClient ? "[Client]" : "[Server]"; + var actualSide = fromClient ? "server" : "client"; + return new PacketValidationIssue(DateTime.Now, ValidationCategory.WrongTag, packet, + $"Header '{header}' is a known {actualSide} packet but was captured as {expectedTag}."); + } + + return new PacketValidationIssue(DateTime.Now, ValidationCategory.Missing, packet, + $"Header '{header}' is not defined in NosCore.Packets."); + } + + private static IEnumerable HeadersOf(Type t) + { + var primary = t.GetCustomAttribute()?.Identification; + if (primary != null) yield return primary; + foreach (var alias in t.GetCustomAttributes()) + { + yield return alias.Identification; + } + } + + // The client sends three bare handshake lines at the start of every + // connection before the encrypted packet stream kicks in: + // "" — single numeric token + // " GF " — username / platform / region + // "thisisgfmode" — GF-mode marker + // None of them are header-protocol packets; flagging them as Missing is noise. + private static bool IsLoginHandshake(LoggedPacket packet) + { + if (packet.Direction != PacketDirection.Send) return false; + var tokens = packet.Raw.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return false; + if (tokens.Length == 1 && ulong.TryParse(tokens[0], out _)) return true; + if (tokens.Length == 1 && tokens[0] == "thisisgfmode") return true; + if (tokens.Length >= 2 && tokens[1] == "GF") return true; + return false; + } + + // Mirror of Deserializer's own header-extraction logic: the leading token + // is the keepalive id when it parses as a ushort (client→server packets), + // otherwise it's the header directly (server→client packets). + private static string ExtractHeader(string raw) + { + var tokens = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return string.Empty; + if (ushort.TryParse(tokens[0], out _) && tokens.Length >= 2) return tokens[1]; + return tokens[0]; + } +}