feat(asset-bridge): the cliloc table, decompressed on the shard (Phase 2)
The shard reads its own client's `Cliloc.enu` and serves it over the bridge, so
the operator stops installing UOFiddler, building a converter against its
`Ultima.dll`, and copying a 5 MB file to the web host every time they patch.
`BridgeCliloc.cs` is the one decoder protocol 8 writes rather than calls
(docs/link/v8.md §4, §9): a port of UOFiddler's `MythicDecompress` +
`MoveToFront` — Beerware, so clean to bring into a GPL-3.0-or-later tree —
rewritten against plain arrays, because the upstream is `Span<T>` /
`ArrayPool<T>` / `BinaryPrimitives` code and ServUO targets `net48`.
The algorithm is deliberately unchanged, including the parts that read oddly.
The three-region count/cursor/end table and the symbol-table shifts are
upstream's, because a tidier rewrite of somebody else's format decoder is a
chance to be subtly wrong in a way that produces plausible text. Two bounds
checks were added and they are the only behavioural difference: the upstream
indexes its payload unchecked, which is safe for a file the client wrote and is
not safe for a file this shard was handed.
Measured on a stock client: 4,989,921 bytes read, decompressed and parsed in
**290 ms**, yielding **67,496** non-blank rows in id order. That number is the
acceptance test — it is what UOFiddler's own DLL produced from this same client
through the converter this phase deletes, so an independent implementation
agrees to the row. Zero U+FFFD; the 696 non-ASCII rows carry correct curly
quotes; the longest row is a 12,149-character EULA, which is why the record
length is read unsigned.
Blanks never reach the wire — ~56,000 of the 123,490 entries are empty strings
the client reserves, and the website discards them at import anyway.
Also on this plane:
* `assets.error` gains a `code`. Phase 1 chose between 403 and 400 by looking
for the word "disabled" in an operator-facing sentence, which makes prose
load-bearing; `DISABLED` / `NOT_FOUND` / `UNREADABLE` / `UNAVAILABLE` /
`BAD_REQUEST` say it directly.
* `Accept` and `Fail` are internal rather than private, because the asset
plane's single slot and its refusal frame are shared by every family on it.
The cursor is a cliloc NUMBER, not an offset: the decoded table is cached for
five idle minutes and released after the last page, so it can be dropped and
rebuilt between two pages of one import, and an index would then silently mean
something else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -152,13 +152,13 @@ namespace Server.Custom.Bridge
|
||||
{
|
||||
// Rule 1. Without a correlation id this would land on the event path, be persisted
|
||||
// to the sidecar's store and broadcast to every subscriber. Refuse instead.
|
||||
Fail(null, "assets.sources requires a reqId");
|
||||
Fail(null, "BAD_REQUEST", "assets.sources requires a reqId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BridgeConfig.AssetsEnabled)
|
||||
{
|
||||
Fail(reqId, "asset extraction is disabled on this shard");
|
||||
Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ namespace Server.Custom.Bridge
|
||||
/// Runs on the Core thread and does nothing expensive; <paramref name="job"/> runs on
|
||||
/// the worker and must touch no world state.
|
||||
/// </summary>
|
||||
private static void Accept(string reqId, string kind, Action job)
|
||||
internal static void Accept(string reqId, string kind, Action job)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
@@ -197,7 +197,7 @@ namespace Server.Custom.Bridge
|
||||
_job = null;
|
||||
|
||||
Console.WriteLine("[Bridge] cannot start the asset worker: {0}", e.Message);
|
||||
Fail(reqId, "the shard could not start its asset worker");
|
||||
Fail(reqId, "UNAVAILABLE", "the shard could not start its asset worker");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -222,14 +222,27 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Fail(string reqId, string reason)
|
||||
/// <summary>
|
||||
/// The asset plane's one refusal frame, shared by every family on it.
|
||||
///
|
||||
/// <paramref name="code"/> is what the sidecar maps to a status, and it exists because
|
||||
/// the alternative it replaced — matching on the words in <paramref name="reason"/> —
|
||||
/// makes an operator-facing sentence load-bearing. Rewording "disabled" would silently
|
||||
/// turn a 403 into a 400. The codes are `DISABLED` (the operator switched this plane
|
||||
/// off), `NOT_FOUND` (the shard has no such file), `UNREADABLE` (it has it and cannot
|
||||
/// decode it), `UNAVAILABLE` (the shard cannot do this right now) and `BAD_REQUEST`
|
||||
/// (the default, and the caller's fault).
|
||||
/// </summary>
|
||||
internal static void Fail(string reqId, string code, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("assets.error");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("reason", reason);
|
||||
sb.Str("code", code)
|
||||
.Str("reason", reason);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user