feat(asset-bridge): the UOP animation reader, and 235 bodies the legacy path cannot see (Phase 4)

ServUO's vendored `Ultima.Animations` reads legacy `anim*.mul` only -- it builds its
five FileIndexes with the constructor that passes `uopFile: null` -- so everything a
modern client moved into `AnimationFrame*.uop` is invisible to it. This adds the one
reader docs/link/v8.md 4.3 reserved for phase 4, and wires it in as a fallback beneath
the legacy path.

What it actually recovers is not what the plan expected, and the difference was
measured before any of this was written:

  - Of the EIGHT player-character bodies 4.8 assigned to this phase, only TWO are in
    the client at all: gargoyles 666 and 667, in AnimationFrame3.uop. The six ghost
    bodies (human 402/403, elf 607/608, gargoyle 694/695) are in no package. The five
    packages hold 10,724 entries between them and the
    `build/animationlegacyframe/%06d/%02d.bin` name scheme claims every one, so there
    is no other naming they could be hiding under.
  - The same fallback reaches 233 further bodies the catalogue had nothing for, so the
    working set goes from 787 to 1,022 (57 Monster, 26 Animal, 97 Equipment, 50
    unlisted, 3 Human, 2 Sea). The catalogue was already 366 Equipment bodies before
    this, so its character does not change.

Decided with the org lead before building: the fallback applies to every body rather
than to player bodies alone; ghost ids leave the player-body set entirely (no client
has art for any of them, and listing them only advertised keys that cannot exist); the
UOP path gets its own PNG encoder rather than Bitmap.Save; and a host without
libgdiplus keeps the flat NO_IMAGING refusal rather than serving a partial catalogue.

Three things about the reader:

  - It is not the never-sweep rule being broken. That rule exists because a legacy
    index is addressed by POSITION, so asking the wrong file returns a giant spider
    that decodes cleanly. A UOP entry is addressed by the hash of a name containing the
    body id, and the payload declares that id again for `Group.TryOpen` to check, so a
    hit is proof of identity. Measured: no hash appears in two packages.
  - Validate-as-we-go replaces validate-before-calling, because here we ARE the
    library: the block chain is bounded against the file, the record against the file,
    the inflated length against the declared one, the frame table against the payload,
    and every run header against both the record's remaining bytes and the bitmap it
    writes into. Measured the way 4.5 was -- across every UOP body on a stock client it
    refuses nothing that carries art. The one body it refuses (286) declares a 0x0
    frame, which the vendored decoder treats as absent too.
  - No System.Drawing anywhere in it, which is what 4.4 promised: the decode fills a
    ushort[] of ARGB1555 and BridgePng encodes that directly (zlib around net48's
    raw-deflate-only DeflateStream, CRC32, one IDAT, filter 0).

EXTRACTOR_VERSION 1 -> 2: every client file is byte-identical and the answer is
different, which is exactly what that number exists to say. The UOP packages join
`assets.sources` and the catalogue id, so patching one is drift; `Ultima.Files` cannot
resolve them (its table predates UOP animations) so BridgeUop.FindClientFile does it,
case-insensitively by enumeration for Linux hosts. Manifest and fetch rows carry a new
`source` field (`legacy` / `uop`).

Protocol stays 8 -- no message shape changed, only fields added.

Measured on the live rig (real sidecar, real ServUO, this machine's client):
1,022 rows in ONE page, 1,409 ms cold; six player bodies, all six with art for the
first time (400/401/605/606 legacy, 666/667 uop), all at direction 0; 1,016 at
direction 1; the six ghost bodies absent; 45 duplicate-hash groups of which exactly one
is new, bodies 1531/1532, two distinct records whose first frames match -- legitimate,
and provable only because each payload declares its own body id. The gargoyles were
rendered and looked at, because 4.3's whole point is that this failure mode produces
confident, wrong pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-11 04:55:11 -05:00
parent c71712c734
commit efbd45685c
4 changed files with 1281 additions and 28 deletions

View File

@@ -19,7 +19,9 @@ namespace Server.Custom.Bridge
/// 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 **787 sprites at about a kilobyte each**.
/// 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).
///
/// Two request kinds, which are §6's two stages for assets rather than for sources:
///
@@ -70,6 +72,17 @@ namespace Server.Custom.Bridge
/// from the library's reused stream buffer. That is 357 of the 1,144 bodies the library
/// claims on a stock client, and importing them would have written 357 duplicate
/// portraits whose subject depended on the order this walk happened to run in.
///
/// ── **The UOP fallback, and why it cannot reintroduce that** ──
///
/// Phase 4 added <see cref="BridgeUop"/> beneath the legacy reader: a body the vendored
/// path has nothing for is looked for in the UOP packages before it is reported absent.
/// That is where two of the six player-character bodies live — `Bodyconv.def` sends
/// gargoyles 666 and 667 to `anim5`, at an index past the end of `anim5.idx` — and 233
/// other bodies besides. It cannot produce a wrong picture the way a legacy sweep would,
/// because a UOP entry is addressed by the hash of a name that contains the body id and
/// the payload then declares that id again, which
/// <see cref="BridgeUop.Group.TryOpen"/> checks.
/// </summary>
public static class BridgeCatalog
{
@@ -103,6 +116,15 @@ namespace Server.Custom.Bridge
public byte[] Png;
public int Width;
public int Height;
/// <summary>
/// Which reader produced it: `legacy` for ServUO's vendored <c>Animations</c> over
/// <c>anim*.mul</c>, `uop` for phase 4's own reader over
/// <c>AnimationFrame*.uop</c> (§4.3, §4.9). On the wire so that an operator
/// looking at a wrong picture can tell which half of the extractor to doubt, and
/// so the acceptance walk can prove the fallback fired at all.
/// </summary>
public string Source;
}
private sealed class Catalog
@@ -243,6 +265,7 @@ namespace Server.Custom.Bridge
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append('}');
if (!page.TryAdd(item.ToString(), "b:" + body.ToString(CultureInfo.InvariantCulture)))
@@ -446,6 +469,7 @@ namespace Server.Custom.Bridge
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.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("\"}");
return item.ToString();
@@ -474,6 +498,34 @@ 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);
if (sprite == null)
return null;
lock (_sync)
{
if (!catalog.ByKey.ContainsKey(key))
{
catalog.ByKey[key] = sprite;
catalog.Order.Add(sprite);
}
return catalog.ByKey[key];
}
}
/// <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)
{
int fileType, at;
string reason;
@@ -505,11 +557,9 @@ namespace Server.Custom.Bridge
if (!reader.AnimationSane(index, at, 1, out reason))
return null;
Sprite sprite;
try
{
sprite = Decode(key, body, direction, fileType);
return Decode(key, body, direction, fileType);
}
catch (Exception e)
{
@@ -517,20 +567,93 @@ namespace Server.Custom.Bridge
body, e.GetType().Name, e.Message);
return null;
}
}
if (sprite == null)
return null;
/// <summary>
/// Phase 4's own reader over <c>AnimationFrame*.uop</c> (§4.3, §4.9), for the bodies
/// the legacy path has nothing for.
///
/// On this machine's client that is **235 bodies** the catalogue could not reach
/// before, including the two gargoyle player bodies — `Bodyconv.def` sends 666 and 667
/// to `anim5`, at an index past the end of `anim5.idx`, and the art has been in
/// `AnimationFrame3.uop` all along.
///
/// Nothing here can produce §4.8's failure. A legacy index is addressed by position,
/// so a wrong lookup is another creature's picture; a UOP entry is addressed by the
/// 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)
{
ulong hash = BridgeUop.HashOf(body, CatalogAction);
lock (_sync)
byte[] payload = null;
string reason = null;
foreach (int n in BridgeUop.Packages)
{
if (!catalog.ByKey.ContainsKey(key))
BridgeUop.Package package = readers.Package(n);
if (package == null || !package.Has(hash))
continue;
if (!package.TryRead(hash, out payload, out reason))
{
catalog.ByKey[key] = sprite;
catalog.Order.Add(sprite);
Console.WriteLine("[Bridge] catalogue: body {0} in {1}: {2}",
body, BridgeUop.PackageName(n), reason);
return null;
}
return catalog.ByKey[key];
break;
}
if (payload == null)
return null;
BridgeUop.Group group;
if (!BridgeUop.Group.TryOpen(payload, body, out group, out reason))
{
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
return null;
}
int frame = group.DirectionAt(direction);
if (frame < 0)
return null;
BridgeUop.Pixels pixels;
bool empty;
if (!group.TryDecode(frame, out pixels, out empty, out reason))
{
// A 0x0 frame is no art rather than damage — the vendored decoder returns early on
// 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);
return null;
}
byte[] png = BridgePng.FromArgb1555(pixels.Argb1555, pixels.Width, pixels.Height);
if (png == null)
return null;
return new Sprite
{
Key = key,
Body = body,
Direction = direction,
FileType = 0,
Png = png,
Width = pixels.Width,
Height = pixels.Height,
Sha256 = Hash(png),
Source = "uop"
};
}
private static Sprite Decode(string key, int body, int direction, int fileType)
@@ -564,7 +687,8 @@ namespace Server.Custom.Bridge
Png = png,
Width = bitmap.Width,
Height = bitmap.Height,
Sha256 = Hash(png)
Sha256 = Hash(png),
Source = "legacy"
};
}
@@ -674,21 +798,32 @@ namespace Server.Custom.Bridge
/// <summary>
/// Asked of the shard, never hardcoded.
///
/// Every registered race carries four body ids, and a shard that calls `RegisterRace`
/// adds ids no table of ours could contain. Even on stock ServUO a hardcoded list
/// would already be wrong in a way that is easy to miss: `RaceDefinitions.cs` passes
/// the gargoyle's ghost bodies in the OPPOSITE order to the other two races.
/// Every registered race carries its living male and female body ids, and a shard that
/// calls `RegisterRace` adds ids no table of ours could contain — which is the whole
/// argument against a hardcoded list, and it was never hypothetical: stock ServUO's
/// own `RaceDefinitions.cs` passes the gargoyle's ghost bodies in the OPPOSITE order
/// to the other two races.
///
/// This set is the whole of what §5.1 gives direction 0 — head-on, facing the viewer,
/// because a character is a portrait and should look at you. Everything else takes
/// direction 1, the front three-quarter, because head-on is the least legible view of
/// a four-legged creature: a wolf seen from the front is a dark blob.
///
/// **Ghost bodies are deliberately not in it** (§5.2, decided 2026-09-10 in phase 4).
/// A race declares four ids and two of them are its ghosts, and no UO client has art
/// for any of them: 402/403 and 694/695 read `lookup -1` in `anim.idx`, 607/608 read
/// `length 0` — the §4.8 shape, where the library hands back the previously-decoded
/// body's picture — and none of the six is in any `AnimationFrame*.uop`, which phase 4
/// established by claiming all 10,724 entries of the five packages with the one name
/// scheme. Listing them only advertised keys that cannot exist. A shard whose client
/// does ship ghost art still gets it: the body is catalogued like any other, at
/// direction 1 rather than 0.
/// </summary>
/// <summary>
/// Cached for the life of the process: `RegisterRace` runs at Configure time, before
/// anything on this plane can be asked a question, so the set cannot change under a
/// running shard. Rebuilding it per body would enumerate every race 2,047 times per
/// scan to answer a question whose answer is twelve integers.
/// scan to answer a question whose answer is six integers.
/// </summary>
private static HashSet<int> _playerBodies;
@@ -710,8 +845,6 @@ namespace Server.Custom.Bridge
set.Add(race.MaleBody);
set.Add(race.FemaleBody);
set.Add(race.MaleGhostBody);
set.Add(race.FemaleGhostBody);
}
}
catch (Exception e)
@@ -829,12 +962,22 @@ namespace Server.Custom.Bridge
.Append(':').Append(BridgeConfig.AssetPlayerDirection)
.Append(':').Append(BridgeConfig.AssetCreatureDirection);
var paths = new List<string>();
for (int fileType = 1; fileType <= 5; fileType++)
paths.Add(BridgeAssetValidator.AnimDataPath(fileType));
// Since phase 4 the catalogue's bytes depend on the UOP packages too — 235 of its
// bodies come out of them — so patching one has to change the catalogue id, exactly as
// patching an anim*.mul does. Leaving them out would let an operator replace a
// gargoyle and have an Update find nothing to do.
foreach (int n in BridgeUop.Packages)
paths.Add(BridgeUop.PackagePath(n));
foreach (string path in paths)
{
sb.Append('|');
string path = BridgeAssetValidator.AnimDataPath(fileType);
if (path == null)
continue;
@@ -883,10 +1026,11 @@ namespace Server.Custom.Bridge
}
/// <summary>
/// The five anim files' index and record readers, opened for one reply and closed with
/// it. Holding them across replies would keep handles on the operator's client files
/// for as long as the cache lives, for no gain: opening five is microseconds and a
/// page decodes hundreds of sprites through them.
/// The five anim files' index and record readers — and, since phase 4, the five UOP
/// packages beside them — opened for one reply and closed with it. Holding them across
/// replies would keep handles on the operator's client files for as long as the cache
/// lives, for no gain: opening them is microseconds and a page decodes hundreds of
/// sprites through them.
/// </summary>
private sealed class Readers : IDisposable
{
@@ -896,6 +1040,9 @@ namespace Server.Custom.Bridge
private readonly long[] _length = new long[6];
private readonly bool[] _open = new bool[6];
private readonly Dictionary<int, BridgeUop.Package> _packages =
new Dictionary<int, BridgeUop.Package>();
public readonly long VerdataLength;
public Readers()
@@ -958,8 +1105,42 @@ namespace Server.Custom.Bridge
return fileType >= 1 && fileType <= 5 ? _reader[fileType] : null;
}
/// <summary>
/// One <c>AnimationFrame*.uop</c>, opened on first use. A package this client does
/// not ship is a null that is cached as one: the miss must not be re-resolved and
/// re-opened once per body across a 2,047-body walk.
/// </summary>
public BridgeUop.Package Package(int n)
{
BridgeUop.Package package;
if (_packages.TryGetValue(n, out package))
return package;
package = BridgeUop.Package.Open(BridgeUop.PackagePath(n));
_packages[n] = package;
return package;
}
public void Dispose()
{
foreach (var package in _packages.Values)
{
if (package == null)
continue;
try
{
package.Dispose();
}
catch
{
// Closing a read-only handle. Nothing useful is left to do.
}
}
for (int i = 1; i <= 5; i++)
{
if (_reader[i] == null)