Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
wtclaude 64c0ec00b1 feat(asset-bridge): the body catalogue and slug to body id (Phase 3)
Two request families, and they run on opposite threads on purpose.

`assets.bodies` (BridgeBodies) answers the question only code inside ServUO
can: the atlas knows a creature by the class name in Spawns/*.xml, the client
knows it by a body id, and nothing in the tree declares the mapping. Construct
the type, read Body.BodyID, Delete(). That is world mutation, so it answers on
the CORE thread and is the one family here that does not take the asset
worker's slot -- and the batch is capped at 100 names, REFUSED rather than
truncated, because a truncated answer is indistinguishable from a complete one
from the website's side.

`assets.manifest` / `assets.fetch` (BridgeCatalog) are the catalogue, on the
worker. The manifest carries { key, sha256, bytes, width, height } and no
pixels, so an Update fetches only what moved; the fetch carries base64 PNG.
The scan keeps the bytes it hashed rather than decoding all 787 sprites twice.

Three things worth stating about the shapes:

- It pages on the WALL CLOCK as well as on bytes. The rows are ~90 bytes and
  the whole catalogue is one page by the byte budget, but building it means
  decoding hundreds of sprites against a 10 s reply timeout.
- `catalog` is derived from the client files (sizes, mtimes, both direction
  settings, EXTRACTOR_VERSION), not minted per build -- the cache is released
  when idle, and a fresh id per build would force a restart mid-import although
  nothing about the client moved.
- ARGB1555 is expanded to 32bpp here rather than handed to GDI+, because what
  it does with a one-bit alpha channel varies by platform and a black rectangle
  behind every sprite would pass any test that only checked the bytes decoded.

Nothing trusts the library's success. Every body goes through CheckEntry and
AnimationRecordSane before it is decoded, which is what keeps the 357 bodies
whose index entry reads `length 0` -- and which the decoder hands back the
PREVIOUS creature's bitmap for -- out of the catalogue.

Walked on a live shard: 787 rows in one 734 ms page; bodies 320, 607, 666 all
absent rather than wrong; 783 at direction 1 and 4 at direction 0; all 455 stock
creature classes resolved at ~190 ms per 100 with zero mobiles leaked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:40:12 -05:00

270 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using Server.Commands;
namespace Server.Custom.Bridge
{
/// <summary>
/// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
///
/// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
///
/// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
/// ServerStarted, once the world is actually there to describe.
///
/// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
/// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
/// </summary>
public static class BridgeBoot
{
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
/// <summary>
/// Identifies this run of the shard. It is stable across sidecar reconnects and changes
/// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
/// cached state) from "the shard restarted" (discard it).
/// </summary>
private static string _bootId;
public static void Configure()
{
BridgeConfig.Configure();
}
public static void Initialize()
{
if (!BridgeConfig.Enabled)
{
Console.WriteLine("[Bridge] disabled by config");
return;
}
CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
RegisterHandler("ping", OnPing);
BridgeLink.InboundLine += OnInboundLine;
BridgeLink.Connected_Core += EmitHello;
EventSink.ServerStarted += OnServerStarted;
EventSink.Shutdown += OnShutdown;
EventSink.Crashed += OnCrashed;
Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
}
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
{
_handlers[kind] = handler;
}
private static void OnServerStarted()
{
_bootId = Guid.NewGuid().ToString("N");
BridgeLink.Start();
}
/// <summary>
/// Core thread, once per connection. The sidecar restarts independently of the shard,
/// so this is sent on every connect rather than once at boot — otherwise a sidecar that
/// came up second would never learn which shard it is talking to.
/// </summary>
private static void EmitHello()
{
BridgeLink.Emit(BridgeJson.Begin("server.hello")
.Str("shard", Server.Misc.ServerList.ServerName)
.Str("bootId", _bootId)
.Num("connects", BridgeLink.Connects)
.Num("items", World.Items.Count)
.Num("mobiles", World.Mobiles.Count)
.Num("accounts", Accounting.Accounts.Count)
.End());
}
private static void OnShutdown(ShutdownEventArgs e)
{
BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
// Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
// the goodbye. Best effort: the sidecar must not depend on receiving it.
BridgeLink.Stop();
}
private static void OnCrashed(CrashedEventArgs e)
{
try
{
BridgeLink.Emit(BridgeJson.Begin("server.crashed")
.Str("error", e.Exception == null ? null : e.Exception.Message)
.End());
BridgeLink.Stop();
}
catch
{
// The process is already going down. Never make a crash worse.
}
}
/// <summary>Core thread, one call per inbound line.</summary>
private static void OnInboundLine(string line)
{
var obj = BridgeJson.Parse(line);
if (obj == null)
{
Console.WriteLine("[Bridge] malformed inbound line, ignoring");
return;
}
var kind = BridgeJson.GetString(obj, "kind");
if (kind == null)
return;
Action<Dictionary<string, object>> handler;
if (!_handlers.TryGetValue(kind, out handler))
{
Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
return;
}
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
// most once: a repeat is answered with the original reply rather than re-run. The gate
// is here rather than in each handler so it covers every inbound kind — including the
// ones a later protocol adds, which is the half that is easy to forget. A command with
// no key behaves exactly as it did before, which is what keeps the admin screens (which
// send none) unchanged.
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
if (idempotencyKey == null)
{
handler(obj);
return;
}
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
return; // already answered: a replay of the original reply, or bridge.busy
string error = null;
try
{
handler(obj);
}
catch (Exception ex)
{
// Swallowed deliberately, and only on the keyed path: the key must be closed out
// with a definite answer (see BridgeIdempotency's header) rather than left in
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
// way they always have.
error = ex.Message;
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
}
finally
{
BridgeIdempotency.Finish(idempotencyKey, error);
}
}
private static void OnPing(Dictionary<string, object> o)
{
var sb = BridgeJson.Begin("pong");
var id = BridgeJson.GetString(o, "id");
if (id != null)
sb.Str("id", id);
BridgeLink.Emit(sb.End());
}
[Usage("bridge [status | reload | ping | sweepnow]")]
[Description("Inspects and controls the sidecar link.")]
private static void Bridge_OnCommand(CommandEventArgs e)
{
var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
switch (arg)
{
case "reload":
BridgeConfig.Load();
BridgeSweeps.Rearm();
BridgePages.Rearm();
BridgeChamps.Rearm();
BridgeSocial.Rearm();
BridgeGovernance.Rearm();
BridgePresence.Rearm();
BridgeHousing.Rearm();
BridgePoints.Rearm();
BridgeMarket.Rearm();
BridgeParticipation.Rearm();
BridgeLeases.Rearm();
BridgeWorld.Rearm();
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
// .cfg wants the change on the site now, not after a shard restart.
BridgeRuleset.Emit();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; ruleset re-emitted; endpoint changes take effect on reconnect.");
break;
case "ping":
BridgeLink.Emit(BridgeJson.Begin("ping").End());
e.Mobile.SendMessage("Bridge: ping queued.");
break;
case "sweepnow":
BridgeSweeps.SweepOnce();
BridgeChamps.SweepOnce();
BridgeSocial.SweepOnce();
BridgeGovernance.SweepOnce();
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
BridgePoints.SweepOnce();
BridgeMarket.SweepOnce();
BridgeParticipation.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
break;
default:
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage(
"Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
break;
}
}
}
}