From d4bd98d984815a730570c63ebbde4e49196567b8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:39:48 +0200 Subject: [PATCH] FrameRecorder: a stand-in backend that records frames, and a shape diff record listens where the plugin is pointed (/mm ws url) and writes every frame to a JSON-lines file; compare reports, per frame type, whether two recordings have the same fields in the same order with the same JSON types. A live headless run against the local server recorded register, telemetry, vitals, chat, combat_stats, character_stats and the share_* frames, and the unsubscribe at logout. The same tool records the DECAL plugin (point its endpoint here) so the two can be compared. Also ships Newtonsoft.Json beside the plugin (a class library does not copy its package dependencies by default; the plugin failed to load without it). Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 + .../OpenAC.MosswartMassacre.csproj | 5 + tools/FrameRecorder/FrameRecorder.csproj | 6 + tools/FrameRecorder/Program.cs | 172 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 tools/FrameRecorder/FrameRecorder.csproj create mode 100644 tools/FrameRecorder/Program.cs diff --git a/.gitignore b/.gitignore index b7f7048..4a897d1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db .codex/ .agents/ .worktrees/ + +# Frame recordings from live runs +*.jsonl diff --git a/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj index ef71e2a..3f905ce 100644 --- a/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj +++ b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj @@ -1,4 +1,9 @@ + + + true + diff --git a/tools/FrameRecorder/FrameRecorder.csproj b/tools/FrameRecorder/FrameRecorder.csproj new file mode 100644 index 0000000..6fec3c7 --- /dev/null +++ b/tools/FrameRecorder/FrameRecorder.csproj @@ -0,0 +1,6 @@ + + + Exe + false + + diff --git a/tools/FrameRecorder/Program.cs b/tools/FrameRecorder/Program.cs new file mode 100644 index 0000000..7bdaf4a --- /dev/null +++ b/tools/FrameRecorder/Program.cs @@ -0,0 +1,172 @@ +// A stand-in for the Mosswart Overlord backend: accepts the plugin's +// WebSocket, records every frame it sends to a JSON-lines file, and can +// compare two recordings by frame shape. Used to prove the port sends what the +// DECAL plugin sent. +// +// record listen on ws://localhost:/websocket/ +// compare per frame type: field names, order and JSON types +// send (while recording) push one frame to the client +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +return args.Length switch +{ + 3 when args[0] == "record" => await Record(int.Parse(args[1]), args[2]), + 3 when args[0] == "compare" => Compare(args[1], args[2]), + _ => Usage(), +}; + +static int Usage() +{ + Console.Error.WriteLine("usage: record | compare "); + return 2; +} + +static async Task Record(int port, string outPath) +{ + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/websocket/"); + listener.Start(); + Console.WriteLine($"recording on ws://localhost:{port}/websocket/ -> {outPath} (Ctrl+C to stop)"); + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); listener.Stop(); }; + await using var output = new StreamWriter(outPath, append: true, new UTF8Encoding(false)) { AutoFlush = true }; + var writeLock = new SemaphoreSlim(1, 1); + + while (!cts.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync(); + } + catch + { + break; + } + if (!context.Request.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + context.Response.Close(); + continue; + } + string secret = context.Request.Headers["X-Plugin-Secret"] ?? "(none)"; + Console.WriteLine($"client connected; X-Plugin-Secret {(secret == "(none)" ? "missing" : "present")}"); + HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null); + _ = Task.Run(async () => + { + var buffer = new byte[65536]; + using var message = new MemoryStream(); + try + { + while (ws.WebSocket.State == WebSocketState.Open && !cts.IsCancellationRequested) + { + WebSocketReceiveResult result = await ws.WebSocket.ReceiveAsync(buffer, cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + break; + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + continue; + string frame = Encoding.UTF8.GetString(message.ToArray()); + message.SetLength(0); + await writeLock.WaitAsync(); + try + { + await output.WriteLineAsync(frame); + } + finally + { + writeLock.Release(); + } + Console.WriteLine(frame.Length > 160 ? frame[..160] + "..." : frame); + } + } + catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) + { + } + Console.WriteLine("client disconnected"); + }); + } + return 0; +} + +static int Compare(string aPath, string bPath) +{ + Dictionary> a = Shapes(aPath); + Dictionary> b = Shapes(bPath); + int problems = 0; + foreach (string type in a.Keys.Union(b.Keys).OrderBy(t => t, StringComparer.Ordinal)) + { + a.TryGetValue(type, out SortedSet? inA); + b.TryGetValue(type, out SortedSet? inB); + if (inA is null || inB is null) + { + Console.WriteLine($"{type}: only in {(inA is null ? bPath : aPath)}"); + continue; + } + if (inA.SetEquals(inB)) + { + Console.WriteLine($"{type}: same shape ({inA.Count} variant{(inA.Count == 1 ? "" : "s")})"); + continue; + } + problems++; + Console.WriteLine($"{type}: DIFFERENT"); + foreach (string shape in inA.Except(inB)) + Console.WriteLine($" only in A: {shape}"); + foreach (string shape in inB.Except(inA)) + Console.WriteLine($" only in B: {shape}"); + } + return problems == 0 ? 0 : 1; +} + +// A frame's shape: its fields in order with their JSON types, nested objects +// and the first element of arrays included, values left out. Dictionaries +// keyed by data (monster names, property ids, skill names) are summarised by +// the shape of their values. +static Dictionary> Shapes(string path) +{ + var shapes = new Dictionary>(StringComparer.Ordinal); + foreach (string line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + using JsonDocument doc = JsonDocument.Parse(line); + JsonElement root = doc.RootElement; + string type = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("type", out JsonElement t) + ? t.GetString() ?? "(null)" + : "(no type)"; + if (!shapes.TryGetValue(type, out SortedSet? set)) + shapes[type] = set = new SortedSet(StringComparer.Ordinal); + set.Add(Shape(root, depth: 0, keyed: false)); + } + return shapes; +} + +static string Shape(JsonElement element, int depth, bool keyed) +{ + switch (element.ValueKind) + { + case JsonValueKind.Object: + { + var properties = element.EnumerateObject().ToList(); + bool dataKeyed = keyed || properties.Count > 0 && properties.All(p => LooksLikeData(p.Name)); + if (dataKeyed && properties.Count > 0) + return "{*:" + string.Join("|", properties.Select(p => Shape(p.Value, depth + 1, false)).Distinct().OrderBy(s => s, StringComparer.Ordinal)) + "}"; + return "{" + string.Join(",", properties.Select(p => p.Name + ":" + Shape(p.Value, depth + 1, IsDataMap(p.Name)))) + "}"; + } + case JsonValueKind.Array: + return element.GetArrayLength() == 0 ? "[]" : "[" + Shape(element[0], depth + 1, false) + "]"; + case JsonValueKind.Number: + return element.GetRawText().Contains('.') || element.GetRawText().Contains('E') ? "float" : "int"; + default: + return element.ValueKind.ToString().ToLowerInvariant(); + } +} + +// Objects whose keys are data, not field names. +static bool IsDataMap(string name) => name is "monsters" or "properties" or "skills" or "attributes" + or "vitals" or "IntValues" or "DoubleValues" or "BoolValues" or "StringValues" or "offense" or "defense"; + +static bool LooksLikeData(string key) => key.Length > 0 && (char.IsDigit(key[0]) || key.Contains(' '));