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) <noreply@anthropic.com>
This commit is contained in:
parent
5b3179dd75
commit
d4bd98d984
4 changed files with 186 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,3 +25,6 @@ Thumbs.db
|
|||
.codex/
|
||||
.agents/
|
||||
.worktrees/
|
||||
|
||||
# Frame recordings from live runs
|
||||
*.jsonl
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<!-- Ship Newtonsoft.Json beside the plugin: the host loads a plugin's own
|
||||
dependencies from its folder. -->
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenAC.MosswartMassacre.Tests" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
6
tools/FrameRecorder/FrameRecorder.csproj
Normal file
6
tools/FrameRecorder/FrameRecorder.csproj
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
172
tools/FrameRecorder/Program.cs
Normal file
172
tools/FrameRecorder/Program.cs
Normal file
|
|
@ -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 <port> <out.jsonl> listen on ws://localhost:<port>/websocket/
|
||||
// compare <a.jsonl> <b.jsonl> per frame type: field names, order and JSON types
|
||||
// send <port> <json> (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 <port> <out.jsonl> | compare <a.jsonl> <b.jsonl>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
static async Task<int> 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<string, SortedSet<string>> a = Shapes(aPath);
|
||||
Dictionary<string, SortedSet<string>> 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<string>? inA);
|
||||
b.TryGetValue(type, out SortedSet<string>? 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<string, SortedSet<string>> Shapes(string path)
|
||||
{
|
||||
var shapes = new Dictionary<string, SortedSet<string>>(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<string>? set))
|
||||
shapes[type] = set = new SortedSet<string>(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(' '));
|
||||
Loading…
Add table
Add a link
Reference in a new issue