From a9bd18e48ee75ed2fcde31c49309c5a7f2d9701f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 14 Sep 2026 01:09:38 -0500 Subject: [PATCH] feat(asset-bridge): the 73 bodies action 0 could not see, and the ceiling that makes looking safe (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- .../Custom/Bridge/BridgeAssetValidator.cs | 112 ++++++++++ overlay/Scripts/Custom/Bridge/BridgeAssets.cs | 7 +- .../Scripts/Custom/Bridge/BridgeCatalog.cs | 208 ++++++++++++++---- 3 files changed, 282 insertions(+), 45 deletions(-) diff --git a/overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs b/overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs index e4ed921..e1ecacc 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs @@ -406,11 +406,123 @@ namespace Server.Custom.Bridge return false; } + int actions = ActionsOf(translated, fileType); + + if (action >= actions) + { + // §4.10, measured in phase 6: this is the never-sweep rule again, one axis over. + // A body's slots are contiguous and the next body's begin immediately after them, + // so `index + action * 5` past the ceiling addresses ANOTHER BODY'S action — a + // real record, at a real offset, that every check below passes. Measured on this + // client: of 795 legacy bodies, 643 return a fully validated picture one action + // past their band and **452 of those are byte-identical to body+1's action 0**. + // Body 1 action 22 is an ettin; body 3 action 22 is an imp. Nothing downstream + // can tell, which is why the refusal has to be here. + reason = "body " + body + " has " + actions + " actions in file type " + fileType + + "; action " + action + " belongs to the next body"; + return false; + } + index = AnimIndexOf(translated, fileType) + (action * 5) + direction; return true; } + /// + /// How many actions the index reserves for a body — the only safe ceiling, and it is + /// the banding rather than the library's own answer. + /// + /// Animations.GetAnimLength exists and looks like the right source. It is not: + /// for a body reaching file type 5 as id 34 it answers **22** while + /// puts that body in the 65-slot band, which is **13**. The + /// two disagree on exactly one body of this client (reached by translation from body + /// 276), and taking the larger number is nine actions of somebody else's art. So the + /// count is derived from the same arithmetic that produces the offset, in the same + /// file, where the two cannot drift apart. + /// + public static bool ActionCount(int body, out int actions, out int fileType, out string reason) + { + reason = null; + actions = 0; + fileType = 0; + + if (body <= 0) + { + reason = "body " + body + " is not addressable"; + return false; + } + + int translated = body; + int hue = 0; + + try + { + Animations.Translate(ref translated, ref hue); + fileType = BodyConverter.Convert(ref translated); + } + catch (Exception e) + { + reason = "body.def/bodyconv.def lookup failed: " + e.GetType().Name; + return false; + } + + if (AnimDataPath(fileType) == null) + { + reason = "bodyconv sends body " + body + " to file type " + fileType + + ", which this client does not have"; + return false; + } + + actions = ActionsOf(translated, fileType); + + return true; + } + + /// + /// The banding of , read as an action count: a body's slots + /// are five directions per action, so the band size divided by five is how many + /// actions it owns. + /// + private static int ActionsOf(int body, int fileType) + { + return SlotsOf(body, fileType) / 5; + } + + /// + /// How many index slots 's arithmetic gives this body. The + /// bands are transcribed there and their sizes here, from the same source and in the + /// same order, because a ceiling that disagrees with an offset is worse than no + /// ceiling at all. + /// + private static int SlotsOf(int body, int fileType) + { + switch (fileType) + { + case 2: + return body < 200 ? 110 : 65; + + case 3: + if (body < 300) + return 65; + + return body < 400 ? 110 : 175; + + case 5: + // Body 34's exclusion again — it is in the second band here, so it owns 13 + // actions and not 22. This is the one body `GetAnimLength` is wrong about. + if (body < 200 && body != 34) + return 110; + + return body < 400 ? 65 : 175; + + default: // 1 and 4 share their banding + if (body < 200) + return 110; + + return body < 400 ? 65 : 175; + } + } + /// /// Animations.GetFileIndex's own arithmetic, which is private. The banding is /// per file type and the boundaries differ between them, so this is transcribed rather diff --git a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs index b6930ca..5cb2417 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs @@ -85,8 +85,13 @@ namespace Server.Custom.Bridge /// stock client is 235 new sprites and two of them player-character bodies; and the /// player-body set no longer carries ghost ids. Every client file is byte-identical /// and the answer is different, which is precisely what this number exists to say. + /// + /// **3** — phase 6 (§4.10, §11.2). A body with no art at action 0 is catalogued at + /// the first action that has any, and its key names that action. 74 more bodies on a + /// stock client, no existing key's bytes changed — but a body that was absent is now + /// a row, which is the same "unchanged input, different answer" this number covers. /// - public const int EXTRACTOR_VERSION = 2; + public const int EXTRACTOR_VERSION = 3; // ── the one slot (§3.2) ────────────────────────────────────────────────────────────── diff --git a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs index 1591f23..6cd82c6 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs @@ -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 /// Bodies are addressable to 2047; the sweep behind §4.8 covered exactly this. private const int MaxBody = 2047; - /// The catalogue is first frames only. Deep keys are phase 6. - private const int CatalogAction = 0; + /// + /// 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. + /// + private const int PreferredAction = 0; + + /// + /// How far the fallback looks for a body with no art at . + /// + /// 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 — + /// 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. + /// + private const int MaxAction = 35; public static void Initialize() { @@ -110,6 +133,14 @@ namespace Server.Custom.Bridge { public string Key; public int Body; + + /// + /// Which action this body's thumbnail came from — + /// 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. + /// + 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 ByKey = - new Dictionary(StringComparer.Ordinal); + + /// + /// 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. + /// + public readonly Dictionary ByBody = new Dictionary(); + public readonly List Order = new List(); /// The next body the scan has yet to look at. @@ -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 /// 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 /// 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]; } } + /// + /// 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. + /// refuses past the ceiling, so + /// this walk cannot produce one — see §4.10. + /// + 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; + } + /// /// ServUO's vendored Animations over anim*.mul, behind §4.5's validator. /// - 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 to check. A miss is a miss. /// - 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); } /// - /// `body/<id>/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/<id>/a<n>`, 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. 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. /// - 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)