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>
254 lines
9.7 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|