openac-mosswartmassacre/tools/FrameRecorder/Program.cs
Erik 71f14473c6 Review fixes: telemetry session id per login; Framework number text
F1: the telemetry session id is new at every login. The original made one per
load of its assembly and its loader reloaded it at every login; the backend
counts new kills per (session id, character), so reusing one id across a
relog under-counted kills until the reset counter passed the old total.

F5/F6: .NET Framework never wrote a negative zero, and its fixed-point
formats (F7, F2, F0) rounded at fifteen significant digits, half away from
zero. Spawn and portal coordinates, kills_per_hour and the !report line now
format that way; the round-trip converter maps -0 to 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:04:28 +02:00

254 lines
9.7 KiB
C#

// 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();
string? name = null;
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);
name = await Relay.Handle(frame, ws.WebSocket, name);
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)
{
}
Relay.Disconnected(name);
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(' '));
// The backend's vital-sharing relay: a client registers under its character
// name; share_subscribe / share_unsubscribe opt it in and out; every other
// share_* frame goes to every other opted-in client, unchanged.
static class Relay
{
private static readonly object Gate = new();
private static readonly Dictionary<string, WebSocket> Clients = new(StringComparer.Ordinal);
private static readonly HashSet<string> Subscribers = new(StringComparer.Ordinal);
public static async Task<string?> Handle(string frame, WebSocket socket, string? name)
{
string? type;
string? character;
try
{
using JsonDocument doc = JsonDocument.Parse(frame);
JsonElement root = doc.RootElement;
type = root.TryGetProperty("type", out JsonElement t) ? t.GetString() : null;
character = root.TryGetProperty("character_name", out JsonElement c) ? c.GetString()
: root.TryGetProperty("player_name", out JsonElement p) ? p.GetString() : null;
}
catch (JsonException)
{
return name;
}
List<WebSocket> targets = [];
lock (Gate)
{
if (type == "register" && character is not null)
{
Clients[character] = socket;
return character;
}
if (type == "share_subscribe" && character is not null)
{
Subscribers.Add(character);
return name;
}
if (type == "share_unsubscribe" && character is not null)
{
Subscribers.Remove(character);
return name;
}
if (type is null || !type.StartsWith("share_", StringComparison.Ordinal))
return name;
foreach ((string client, WebSocket other) in Clients)
{
if (client != character && Subscribers.Contains(client) && other.State == WebSocketState.Open)
targets.Add(other);
}
}
byte[] bytes = Encoding.UTF8.GetBytes(frame);
foreach (WebSocket target in targets)
{
try
{
await target.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (WebSocketException)
{
}
}
return name;
}
public static void Disconnected(string? name)
{
if (name is null)
return;
lock (Gate)
{
Clients.Remove(name);
Subscribers.Remove(name);
}
}
}