feat(asset-bridge): the 73 bodies action 0 could not see, and the ceiling that makes looking safe (Phase 6)

The catalogue asked every body for action 0 and reported the rest absent. 73 of
this client's bodies have no art there and real art deeper — body 820's first
drawn action is 23, and it is a horse — so they rendered as text on the bestiary.
The catalogue now falls back to the first action that has art, and the key names
that action (`body/820/a23`). 1,022 -> 1,095 rows.

Walking the action axis is the one thing that can walk off the end of a body's
slots, and the slots after a body's band are the NEXT BODY'S. Measured here: one
action past the band, 643 of 795 legacy bodies return a fully validated picture
and 452 of those are byte-identical to body+1's action 0 (body 1 action 22 is an
ettin; body 3's is an imp, both confirmed by rendering them). Phase 0's validator
cannot catch that — the record is real — so the ceiling refuses the ADDRESS, in
ResolveAnimation where every caller already goes.

The ceiling is the index banding, never `Animations.GetAnimLength`: for a body
reaching file type 5 as id 34 that function answers 22 while the arithmetic gives
13, and the difference is nine actions of another creature's art.

A fetch serves only the key the catalogue chose for that body. `body/820/a0` and
`body/400/a2` come back `unsupported` with the chosen action alongside, never by
decoding what was asked for.

`EXTRACTOR_VERSION` 2 -> 3 (unchanged input, a different answer). Protocol stays
8 — `action` on a manifest/fetch row is additive.

Deep frame keys and the bulk-fill switch that §16 planned for this phase were
NOT built: the site displays still pictures, and a complete one-direction
animation set measures 174,453 frames / 281.5 MB against no consumer (docs
§11.2, org lead 2026-09-11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-14 01:09:38 -05:00
parent b68aac41c6
commit a9bd18e48e
3 changed files with 282 additions and 45 deletions

View File

@@ -14,11 +14,20 @@ namespace Server.Custom.Bridge
///
/// One thumbnail per creature body: the working set that makes a bestiary, a marketplace
/// listing and a character sheet render. Everything deeper — every action, every frame —
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
/// is the set that is worth importing before anything asks for it, because on this
/// machine's client it is **1,022 sprites at about a kilobyte each** — 787 out of the
/// legacy `anim*.mul` files and, since phase 4, 235 more out of `AnimationFrame*.uop`,
/// which ServUO's vendored decoder never opens (§4.3, §4.9).
/// is the same addressing scheme at a deeper key and is **not served** (§11.2, phase 6:
/// the site shows still pictures, so frames wait for a consumer that wants them). This is
/// the set that is worth importing before anything asks for it, because on this machine's
/// client it is **1,096 sprites at about a kilobyte each** — 787 out of the legacy
/// `anim*.mul` files, 235 more out of `AnimationFrame*.uop` since phase 4 (§4.3, §4.9),
/// and 74 more since phase 6, which have no art at action 0 and real art deeper.
///
/// ── **One picture per body, at the first action that has one** ──
///
/// A key carries the action it came from — `body/820/a23` for a horse whose action 0 is
/// empty — so the catalogue is still exactly one row per body, and the row says which
/// picture it is. What it never does is decode an action the walk did not choose: a fetch
/// for `body/820/a0` is `unsupported`, not a second attempt, because the slots past a
/// body's band belong to the next body and every check passes on them (§4.10).
///
/// Two request kinds, which are §6's two stages for assets rather than for sources:
///
@@ -89,8 +98,22 @@ namespace Server.Custom.Bridge
/// <summary>Bodies are addressable to 2047; the sweep behind §4.8 covered exactly this.</summary>
private const int MaxBody = 2047;
/// <summary>The catalogue is first frames only. Deep keys are phase 6.</summary>
private const int CatalogAction = 0;
/// <summary>
/// The action a thumbnail comes from when the body has one, which is nearly always.
/// Everything deeper than a first frame is deferred — see §11.2.
/// </summary>
private const int PreferredAction = 0;
/// <summary>
/// How far the fallback looks for a body with no art at <see cref="PreferredAction"/>.
///
/// 35 because that is the largest band any file type gives a body, so an action beyond
/// it is not something the client's own layout can name. The legacy arm is bounded
/// tighter still and per body, by <see cref="BridgeAssetValidator.ActionCount"/> —
/// this is only the scan's outer stop, and it is the UOP arm's real one, where an
/// action is a named entry rather than an offset.
/// </summary>
private const int MaxAction = 35;
public static void Initialize()
{
@@ -110,6 +133,14 @@ namespace Server.Custom.Bridge
{
public string Key;
public int Body;
/// <summary>
/// Which action this body's thumbnail came from — <see cref="PreferredAction"/>
/// for all but 74 bodies on this client, and on the wire because the key names it
/// (§5, §11.2). A consumer that assumes `a0` would build a dead URL for a horse.
/// </summary>
public int Action;
public int Direction;
public int FileType;
public string Sha256;
@@ -130,8 +161,15 @@ namespace Server.Custom.Bridge
private sealed class Catalog
{
public string Id;
public readonly Dictionary<string, Sprite> ByKey =
new Dictionary<string, Sprite>(StringComparer.Ordinal);
/// <summary>
/// Keyed by **body**, not by asset key, since phase 6: a body's key now carries
/// the action its picture came from, so the key cannot be spelled until the body
/// has been resolved. A fetch arrives holding a key and has to reach the same
/// sprite, which it does by parsing the body out of it and comparing.
/// </summary>
public readonly Dictionary<int, Sprite> ByBody = new Dictionary<int, Sprite>();
public readonly List<Sprite> Order = new List<Sprite>();
/// <summary>The next body the scan has yet to look at.</summary>
@@ -264,6 +302,7 @@ namespace Server.Custom.Bridge
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append('}');
@@ -413,9 +452,9 @@ namespace Server.Custom.Bridge
/// </summary>
private static string Render(Catalog catalog, Readers readers, string key)
{
int body;
int body, action;
if (!TryParseKey(key, out body))
if (!TryParseKey(key, out body, out action))
{
var bad = new StringBuilder(96);
bad.Append("{\"key\":");
@@ -437,12 +476,26 @@ namespace Server.Custom.Bridge
return item.ToString();
}
if (sprite.Action != action)
{
// The body has a picture, but not at the action this key names. Two ways to get
// here and both are the caller's: an old manifest that catalogued this body at
// `a0` before the client was patched, or a key someone built by assuming the
// action. Neither is served — decoding the asked-for action instead would be
// §4.10's wrong picture, arrived at politely.
item.Append(",\"status\":\"unsupported\"");
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append('}');
return item.ToString();
}
item.Append(",\"status\":\"ok\"");
item.Append(",\"sha256\":\"").Append(sprite.Sha256).Append('"');
item.Append(",\"bytes\":").Append(sprite.Png.Length.ToString(CultureInfo.InvariantCulture));
item.Append(",\"width\":").Append(sprite.Width.ToString(CultureInfo.InvariantCulture));
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"action\":").Append(sprite.Action.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
@@ -459,13 +512,11 @@ namespace Server.Custom.Bridge
/// </summary>
private static Sprite Resolve(Catalog catalog, Readers readers, int body)
{
string key = Key(body);
lock (_sync)
{
Sprite cached;
if (catalog.ByKey.TryGetValue(key, out cached))
if (catalog.ByBody.TryGetValue(body, out cached))
return cached;
}
@@ -473,38 +524,88 @@ namespace Server.Custom.Bridge
? BridgeConfig.AssetPlayerDirection
: BridgeConfig.AssetCreatureDirection;
// Legacy first, always. The vendored decoder is what 787 of this client's bodies come
// out of, it is what phase 3 measured, and the UOP packages hold a different and
// mostly disjoint set (measured: of the 244 bodies they carry, 8 also have legacy
// art). So this is a fallback rather than a choice, and no body changes reader while
// a client sits still.
Sprite sprite = ResolveLegacy(key, readers, body, direction)
?? ResolveUop(key, readers, body, direction);
Sprite sprite = ResolveAny(readers, body, direction);
if (sprite == null)
return null;
lock (_sync)
{
if (!catalog.ByKey.ContainsKey(key))
if (!catalog.ByBody.ContainsKey(body))
{
catalog.ByKey[key] = sprite;
catalog.ByBody[body] = sprite;
catalog.Order.Add(sprite);
}
return catalog.ByKey[key];
return catalog.ByBody[body];
}
}
/// <summary>
/// One body's thumbnail: action 0 if it has one, otherwise the first action that does.
///
/// ── **Why there is a fallback at all** ──
///
/// Through phase 5 a body with no art at action 0 was simply absent, and on this
/// client **74 bodies are in exactly that state while carrying real art deeper** — 66
/// of them UOP, 8 legacy. Body 820's first drawn action is 23, and it is a horse.
/// They rendered as text on the bestiary for want of looking one action further.
///
/// ── **Why the key says which action it is** ──
///
/// The fallback's picture is `body/820/a23`, not `body/820/a0`. Naming it `a0` would
/// have been fewer changes downstream and a key that lies about its content, which is
/// the failure this protocol keeps meeting from other directions (§4.5, §4.8, §11.1).
///
/// ── **Why the ceiling is not a detail** ──
///
/// Scanning actions is the one thing that can walk off the end of a body's slots, and
/// the slots immediately after a body's are the **next body's**. Measured in phase 6:
/// 643 of 795 legacy bodies return a fully validated, correctly-sized picture one
/// action past their band, and 452 of those are byte-identical to body+1's action 0.
/// <see cref="BridgeAssetValidator.ResolveAnimation"/> refuses past the ceiling, so
/// this walk cannot produce one — see §4.10.
/// </summary>
private static Sprite ResolveAny(Readers readers, int body, int direction)
{
int actions, fileType;
string reason;
// The legacy ceiling. A body the legacy path cannot place at all still gets the UOP
// arm below, where an action is a named entry rather than an offset into a band.
if (!BridgeAssetValidator.ActionCount(body, out actions, out fileType, out reason))
actions = 0;
for (int action = PreferredAction; action < MaxAction; action++)
{
// Legacy first, always. The vendored decoder is what 787 of this client's bodies
// come out of, it is what phase 3 measured, and the UOP packages hold a different
// and mostly disjoint set (measured: of the 244 bodies they carry, 8 also have
// legacy art). So this is a fallback rather than a choice, and no body changes
// reader while a client sits still.
Sprite sprite = action < actions
? ResolveLegacy(Key(body, action), readers, body, action, direction)
: null;
if (sprite == null)
sprite = ResolveUop(Key(body, action), readers, body, action, direction);
if (sprite != null)
return sprite;
}
return null;
}
/// <summary>
/// ServUO's vendored <c>Animations</c> over <c>anim*.mul</c>, behind §4.5's validator.
/// </summary>
private static Sprite ResolveLegacy(string key, Readers readers, int body, int direction)
private static Sprite ResolveLegacy(string key, Readers readers, int body, int action, int direction)
{
int fileType, at;
string reason;
if (!BridgeAssetValidator.ResolveAnimation(body, CatalogAction, direction,
if (!BridgeAssetValidator.ResolveAnimation(body, action, direction,
out fileType, out at, out reason))
return null;
@@ -534,12 +635,12 @@ namespace Server.Custom.Bridge
try
{
return Decode(key, body, direction, fileType);
return Decode(key, body, action, direction, fileType);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] catalogue: body {0}: {1}: {2}",
body, e.GetType().Name, e.Message);
Console.WriteLine("[Bridge] catalogue: body {0} action {1}: {2}: {3}",
body, action, e.GetType().Name, e.Message);
return null;
}
}
@@ -558,9 +659,9 @@ namespace Server.Custom.Bridge
/// hash of a name carrying the body id, and the payload repeats that id in its own
/// header for <see cref="BridgeUop.Group.TryOpen"/> to check. A miss is a miss.
/// </summary>
private static Sprite ResolveUop(string key, Readers readers, int body, int direction)
private static Sprite ResolveUop(string key, Readers readers, int body, int action, int direction)
{
ulong hash = BridgeUop.HashOf(body, CatalogAction);
ulong hash = BridgeUop.HashOf(body, action);
byte[] payload = null;
string reason = null;
@@ -574,8 +675,8 @@ namespace Server.Custom.Bridge
if (!package.TryRead(hash, out payload, out reason))
{
Console.WriteLine("[Bridge] catalogue: body {0} in {1}: {2}",
body, BridgeUop.PackageName(n), reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} in {2}: {3}",
body, action, BridgeUop.PackageName(n), reason);
return null;
}
@@ -589,7 +690,8 @@ namespace Server.Custom.Bridge
if (!BridgeUop.Group.TryOpen(payload, body, out group, out reason))
{
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} uop: {2}",
body, action, reason);
return null;
}
@@ -607,7 +709,8 @@ namespace Server.Custom.Bridge
// exactly the same condition — so it is absent, silently. Anything else is a
// record this reader refused, and that is worth a line.
if (!empty)
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
Console.WriteLine("[Bridge] catalogue: body {0} action {1} uop: {2}",
body, action, reason);
return null;
}
@@ -621,6 +724,7 @@ namespace Server.Custom.Bridge
{
Key = key,
Body = body,
Action = action,
Direction = direction,
FileType = 0,
Png = png,
@@ -631,14 +735,14 @@ namespace Server.Custom.Bridge
};
}
private static Sprite Decode(string key, int body, int direction, int fileType)
private static Sprite Decode(string key, int body, int action, int direction, int fileType)
{
int hue = 0;
// `preserveHue: false` — the catalogue is the creature's own art, and a body-level hue
// from Body.def belongs to a specific mob rather than to the species. §5's key scheme
// is where a hued variant is expressed (`static/3922/h33`), not here.
Frame[] frames = Animations.GetAnimation(body, CatalogAction, direction, ref hue, false, true);
Frame[] frames = Animations.GetAnimation(body, action, direction, ref hue, false, true);
if (frames == null || frames.Length == 0 || frames[0] == null)
return null;
@@ -657,6 +761,7 @@ namespace Server.Custom.Bridge
{
Key = key,
Body = body,
Action = action,
Direction = direction,
FileType = fileType,
Png = png,
@@ -758,20 +863,28 @@ namespace Server.Custom.Bridge
// ── keys, cursors and the source id ──────────────────────────────────────────────────
private static string Key(int body)
private static string Key(int body, int action)
{
return "body/" + body.ToString(CultureInfo.InvariantCulture)
+ "/a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
+ "/a" + action.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// `body/&lt;id&gt;/a0`, and nothing else in this phase. A deeper key
/// (`body/400/a2/f3`) is well-formed under §5 and simply not served yet, so it comes
/// back `unsupported` rather than being silently read as its own first frame.
/// `body/&lt;id&gt;/a&lt;n&gt;`, and nothing else in this phase. A deeper key
/// (`body/400/a2/f3`) is well-formed under §5 and simply not served, so it comes back
/// `unsupported` rather than being silently read as its own first frame.
///
/// The action is parsed rather than required to be zero — 74 of this client's bodies
/// are catalogued at a different one (§11.2) — but a parsed action is not an accepted
/// one. <see cref="Render"/> serves a key only when it is the key the catalogue itself
/// chose for that body, which is what keeps §4.10's ceiling from being reachable
/// through a request: nothing the website can ask makes this decode an action the
/// catalogue did not already pick.
/// </summary>
private static bool TryParseKey(string key, out int body)
private static bool TryParseKey(string key, out int body, out int action)
{
body = 0;
action = -1;
if (key == null)
return false;
@@ -787,7 +900,14 @@ namespace Server.Custom.Bridge
if (body < 1 || body > MaxBody)
return false;
return parts[2] == "a" + CatalogAction.ToString(CultureInfo.InvariantCulture);
if (parts[2].Length < 2 || parts[2][0] != 'a')
return false;
if (!Int32.TryParse(parts[2].Substring(1), NumberStyles.None,
CultureInfo.InvariantCulture, out action))
return false;
return action >= 0 && action < MaxAction;
}
private static int ParseBodyCursor(string cursor)