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)