Asset Bridge phase 1 (docs/link/v8.md §16). Sidecar half: RunicGateway/link#41. Docs half: RunicGateway/docs#236. The transport for protocol 8, plus phase 0's validator promoted into the overlay and extended to animations — which is where the interesting part is. ## 357 of the 1,144 "decodable" bodies are wrong pictures, on a STOCK client Phase 0 measured the art path and left the animation half unbuilt. It has the same defect, and it is worse: `GetAnimation` decodes through `new MemoryStream(m_StreamBuffer, false)` — the whole shared buffer, not the `length` bytes just read into it — so a truncated or absent record does not even hit end-of-stream. It sails on into the previous animation's bytes. Measured directly, because no count could tell: | Decode body 320 (`lookup 22638982, length 0`) straight after… | Comes back | |---|---| | body 12, the dragon | the dragon, 176x167, identical hash | | body 34, the wolf | the wolf's dimensions, 35x34 | | body 400, the human male | the human, 27x63, identical hash | The catalogue is **787 bodies, not 1,144**. Importing the other 357 would have written duplicate creature portraits into the site showing whichever body the walk decoded before them. The record walk refused **0** real bodies on the stock client — the false-refusal measurement §4.5 says the boundary depends on. ## And four of the twelve player bodies, not six §5.2 listed the elf ghosts (607, 608) as decoding. Their index entry is `length 0`; what came back was the elf female at her exact dimensions, because 606 is what the walk decoded immediately before. Confirmed the same way — 607 after the dragon is the dragon. Phase 4's UOP decoder now covers eight ids rather than six. ## What is here - **`overlay/Scripts/Custom/Bridge/BridgeAssets.cs`** — the plane. Accepts on the Core thread, hands off to a dedicated asset worker, returns immediately. Three rules, all answering a specific failure: - **one slot**, second request answered `bridge.busy` (425). `Emit`'s queue is bounded in *lines*, so 10,000 queued 200 KB replies is 2 GB of shard memory; the bound that holds is flow control, on the side where the memory is. - **byte budgets** (`AssetBatchBytes`, 512 KiB) under the sidecar's new 1 MiB cap. The factor of two is load-bearing: a page always admits its first item, so it may overshoot by one, and the headroom is what makes that land on the wire. - **replies, never events** — no `reqId`, no answer. An uncorrelated frame is an event by definition, and §3.1 is why none of this may be one. - **`PageBuilder`** — one paging envelope (`more`/`cursor`/`cut`) for all five families that will page, defined before the first one needs it. `cut` matters: "short page" has three meanings and only `end` means finished. - **`assets.sources`** — stage 1 of the import gate, its first user. - **`BridgeAssetValidator.cs`** — promoted from `tools/`, plus `ResolveAnimation` (the never-sweep-file-types rule as code, with no loop and no fallback), `AnimationRecordSane` and the frame walk. - **`EXTRACTOR_VERSION`**, **`overlay.toml` protocol 7 → 8**, `AssetsEnabled`. ## Hashing had to come off the request path §6's gate is (size, mtime) first, hash only when those differ. The first call has nothing cached, so that still means hashing 1.06 GB — inside the sidecar's 10 s reply timeout it does not fit. So hashes are computed on their own thread (deliberately not the single-slot worker, which would answer every status poll `bridge.busy` for the whole pass) and the reply carries `hashing`/`complete`. Measured on the real rig: first call instant with `sha256: null`, second call **44 ms** with every hash present. ## Verified on the wire, not just compiled Real ServUO 57.4 + the real sidecar + the real client. `GET /assets/sources` → 200, `X-UOLink-Version: 8`, `imaging: {ok: true}`, and §4.6's diagnostic firing on a live client: `artDataFile: artlegacymul.uop`, with `art.mul` and `artidx.mul` both carrying `shadowedBy`. Live events kept flowing through the new capped reader with no warnings. Not exercised live: the disabled-plane 403 and the busy 425 (both unit-tested on the sidecar side; the shard halves are a config read and a lock). - [x] AI-assisted — Claude Code (Opus 5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1066 lines
45 KiB
C#
1066 lines
45 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
using Server.Commands;
|
|
using Server.Custom.Bridge;
|
|
|
|
using Ultima;
|
|
|
|
namespace Server.Custom
|
|
{
|
|
/// <summary>
|
|
/// **Asset Bridge phase 0 — the spike** (docs/link/v8.md §16).
|
|
///
|
|
/// §4 decided to call ServUO's own vendored <c>Ultima</c> decoders rather than reimplement
|
|
/// them. That decision rests on a probe run from **PowerShell** against a **stock** client,
|
|
/// and neither of those is the environment the extractor will actually live in. This runs
|
|
/// the same decoders from **inside a running ServUO**, against a **deliberately patched**
|
|
/// client, and its whole job is to find a fault on a path we call before eight phases are
|
|
/// built on top of one.
|
|
///
|
|
/// What "a fault" means here is wider than a crash, and the wider half is the dangerous
|
|
/// half. `Ultima`'s decoders take their bounds from the files they are reading, so a
|
|
/// malformed record does not usually throw — it produces **a confident, wrong picture**.
|
|
/// Four such shapes are known from reading the source and each has its own counter below:
|
|
///
|
|
/// * <c>LoadStatic</c> walks <c>bindata[count++]</c> with no bound on <c>count</c>. The
|
|
/// two guards in that loop bound the *write* into the bitmap and not the *read* out of
|
|
/// the record, so a record whose row table points outside itself reads adjacent heap.
|
|
/// * <c>stream.Read(m_StreamBuffer, 0, length)</c> ignores its return value, and
|
|
/// <c>m_StreamBuffer</c> is reused and only ever grown. A short read therefore decodes
|
|
/// **the previous asset's bytes** under this asset's id.
|
|
/// * <c>LoadLand</c> reads a fixed 2,024 bytes whatever <c>length</c> says.
|
|
/// * <c>Art.GetLegalItemID</c> returns **0** for an out-of-range id, so <c>GetStatic</c>
|
|
/// of a nonexistent id can hand back item 0's picture instead of nothing.
|
|
///
|
|
/// So every sweep here compares two answers: what <see cref="BridgeAssetValidator"/> says
|
|
/// about the index entry *before* the call, and what <c>Ultima</c> does *with* it. The
|
|
/// interesting cell is not the error count. It is **REFUSED-BUT-DECODED** — a record the
|
|
/// validator rejects and the library cheerfully returns a bitmap for. Those are the wrong
|
|
/// pictures, and they are invisible to any success count.
|
|
///
|
|
/// **Nothing here calls <c>Ultima.Gumps</c>**, which is a safety rule and not a preference
|
|
/// (v8.md §4.1) — with one deliberate exception, the opt-in <c>gump</c> section, whose
|
|
/// entire purpose is to reproduce the process-killing access violation from inside ServUO
|
|
/// so the rule has evidence behind it. It is off by default and it **takes the shard down**.
|
|
///
|
|
/// Test scaffolding. Never deployed; <c>deploy.ps1</c> copies only <c>overlay/</c>.
|
|
/// In game / from <c>BridgeRigDriver</c>: <c>[assetprobe [section] [stock|patched]</c>.
|
|
/// Flag: <c>AssetProbeOnStart</c>. Build the patched client with
|
|
/// <c>tools/patch_client.ps1</c>.
|
|
/// </summary>
|
|
public static class BridgeAssetProbe
|
|
{
|
|
// Where the run writes. The checkpoint is the point of the whole arrangement: some of
|
|
// these faults are corrupted-state exceptions that no catch block sees, so the last id
|
|
// written to disk is the only evidence of where the process died.
|
|
private static readonly string OutputDir = Path.Combine(Core.BaseDirectory, "Logs", "AssetProbe");
|
|
|
|
private static string _checkpointPath;
|
|
private static StreamWriter _report;
|
|
private static readonly object _sync = new object();
|
|
private static bool _running;
|
|
|
|
// Snapshot of the player-character body ids, taken on the Core thread (§5.2). Not
|
|
// hardcoded: RaceDefinitions.cs passes the gargoyle's ghost bodies in the opposite order
|
|
// to the other races, and a shard that calls RegisterRace adds ids no table of ours holds.
|
|
private static List<PlayerBody> _playerBodies;
|
|
|
|
private struct PlayerBody
|
|
{
|
|
public string Race;
|
|
public string Slot;
|
|
public int Body;
|
|
|
|
public PlayerBody(string race, string slot, int body)
|
|
{
|
|
Race = race;
|
|
Slot = slot;
|
|
Body = body;
|
|
}
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
CommandSystem.Register("assetprobe", AccessLevel.Administrator, Probe_OnCommand);
|
|
|
|
if (Config.Get("Bridge.AssetProbeOnStart", false))
|
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Begin(null, "all", null));
|
|
}
|
|
|
|
[Usage("assetprobe [all|paths|statics|land|bodies|players|cliloc|gump] [stock|patched]")]
|
|
[Description("Drives ServUO's vendored Ultima decoders against a client and reports every fault and every wrong picture.")]
|
|
private static void Probe_OnCommand(CommandEventArgs e)
|
|
{
|
|
var section = e.Arguments.Length > 0 ? e.Arguments[0].ToLowerInvariant() : "all";
|
|
var which = e.Arguments.Length > 1 ? e.Arguments[1].ToLowerInvariant() : null;
|
|
|
|
Begin(e.Mobile, section, which);
|
|
}
|
|
|
|
// ── Entry ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Reads what only the Core thread may read, then hands the sweep to a background
|
|
/// thread. That split is not tidiness — it is the §8 threading shape this protocol
|
|
/// introduces, rehearsed here: the decode must run OFF the Core thread (a 66,000-id
|
|
/// sweep would freeze the shard), and the world reads it depends on must run ON it.
|
|
/// </summary>
|
|
public static void Begin(Mobile from, string section, string which)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_running)
|
|
{
|
|
Tell(from, "already running — one sweep at a time, so the checkpoint means something");
|
|
return;
|
|
}
|
|
|
|
_running = true;
|
|
}
|
|
|
|
// Core-thread reads first.
|
|
_playerBodies = ReadPlayerBodies();
|
|
|
|
var thread = new Thread(() => Run(from, section, which));
|
|
thread.IsBackground = true;
|
|
thread.Name = "BridgeAssetProbe";
|
|
thread.Priority = ThreadPriority.BelowNormal;
|
|
thread.Start();
|
|
|
|
Tell(from, "started on a background thread — output in Logs/AssetProbe");
|
|
}
|
|
|
|
/// <summary>§5.2: ask the shard which bodies are player characters; never hardcode them.</summary>
|
|
private static List<PlayerBody> ReadPlayerBodies()
|
|
{
|
|
var list = new List<PlayerBody>();
|
|
|
|
foreach (var race in Race.AllRaces)
|
|
{
|
|
if (race == null)
|
|
continue;
|
|
|
|
list.Add(new PlayerBody(race.Name, "male", race.MaleBody));
|
|
list.Add(new PlayerBody(race.Name, "female", race.FemaleBody));
|
|
list.Add(new PlayerBody(race.Name, "male ghost", race.MaleGhostBody));
|
|
list.Add(new PlayerBody(race.Name, "female ghost", race.FemaleGhostBody));
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
private static void Run(Mobile from, string section, string which)
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(OutputDir);
|
|
|
|
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
|
|
_checkpointPath = Path.Combine(OutputDir, "checkpoint.txt");
|
|
_report = new StreamWriter(Path.Combine(OutputDir, "report-" + stamp + ".txt"), false, new UTF8Encoding(false));
|
|
_report.AutoFlush = true;
|
|
|
|
Say("Asset Bridge phase 0 probe — " + DateTime.UtcNow.ToString("u", CultureInfo.InvariantCulture));
|
|
Say("section=" + section + " client=" + (which ?? "(config default)"));
|
|
Say("");
|
|
|
|
// Caching off, or a full sweep holds ~49,000 live Bitmaps. That is not only memory:
|
|
// every Bitmap is a GDI object and Windows caps a process at 10,000 of them, so a
|
|
// cached sweep fails partway through for a reason that has nothing to do with the
|
|
// files being read. Each bitmap below is disposed as soon as it is measured.
|
|
Files.CacheData = false;
|
|
|
|
if (!PointAtClient(which))
|
|
return;
|
|
|
|
bool all = section == "all";
|
|
|
|
if (all || section == "paths") SectionPaths();
|
|
if (all || section == "statics") SectionStatics();
|
|
if (all || section == "land") SectionLand();
|
|
if (all || section == "bodies") SectionBodies();
|
|
if (all || section == "players") SectionPlayers();
|
|
if (all || section == "cliloc") SectionCliloc();
|
|
|
|
// Never part of "all". This one is expected to kill the process.
|
|
if (section == "gump") SectionGump();
|
|
|
|
Checkpoint("done", 0);
|
|
Say("");
|
|
Say("complete.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Say("PROBE FAILED: " + e);
|
|
}
|
|
finally
|
|
{
|
|
if (_report != null)
|
|
{
|
|
_report.Dispose();
|
|
_report = null;
|
|
}
|
|
|
|
lock (_sync)
|
|
_running = false;
|
|
}
|
|
}
|
|
|
|
// ── Pointing Ultima at a client ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Re-points <c>Ultima.Files</c> at the stock or the patched client and reloads every
|
|
/// index built from it.
|
|
///
|
|
/// **Not <c>Files.SetMulPath(string)</c>.** That overload keeps any entry already
|
|
/// holding an absolute path — its own comment reads <c>// absolut dir ignore</c> — and
|
|
/// it *writes* absolute paths. So it works exactly once: the second call, and every
|
|
/// call that would switch back, is a silent no-op, and the probe would report a run
|
|
/// against the patched client while reading the stock one. The two-argument overload
|
|
/// writes the key directly and is the only one that can be called twice.
|
|
/// </summary>
|
|
private static bool PointAtClient(string which)
|
|
{
|
|
var patched = Config.Get("Bridge.AssetProbeClient", (string)null);
|
|
|
|
if (which == null)
|
|
which = string.IsNullOrEmpty(patched) ? "stock" : "patched";
|
|
|
|
string root;
|
|
|
|
if (which == "patched")
|
|
{
|
|
if (string.IsNullOrEmpty(patched))
|
|
{
|
|
Say("no Bridge.AssetProbeClient configured — build one with tools/patch_client.ps1");
|
|
return false;
|
|
}
|
|
|
|
root = patched;
|
|
}
|
|
else
|
|
{
|
|
// What the shard itself resolved at boot: the §1 premise, read rather than assumed.
|
|
root = Core.DataDirectories.Count > 0 ? Core.DataDirectories[0] : Files.Directory;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
|
{
|
|
Say("client directory does not exist: " + (root ?? "(null)"));
|
|
return false;
|
|
}
|
|
|
|
foreach (var file in InterestingFiles)
|
|
{
|
|
var full = Path.Combine(root, file);
|
|
// An absent file must resolve to nothing, NOT fall through to the stock client —
|
|
// otherwise a patched tree missing a file quietly borrows the real one and the
|
|
// run proves nothing.
|
|
Files.SetMulPath(File.Exists(full) ? full : string.Empty, file);
|
|
}
|
|
|
|
Verdata.Initialize();
|
|
Art.Reload();
|
|
Animations.Reload();
|
|
BodyConverter.Initialize();
|
|
Hues.Initialize();
|
|
|
|
Say("pointed at the " + which + " client: " + root);
|
|
Say("verdata patches loaded: " + (Verdata.Patches == null ? 0 : Verdata.Patches.Length));
|
|
Say("");
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every key this protocol reads, lowercase because that is how <c>Files.MulPath</c> is
|
|
/// keyed. Gump keys are absent on purpose (§4.1).
|
|
/// </summary>
|
|
private static readonly string[] InterestingFiles =
|
|
{
|
|
"anim.idx", "anim.mul", "anim2.idx", "anim2.mul", "anim3.idx", "anim3.mul",
|
|
"anim4.idx", "anim4.mul", "anim5.idx", "anim5.mul",
|
|
"art.mul", "artidx.mul", "artlegacymul.uop",
|
|
"body.def", "bodyconv.def", "hues.mul", "verdata.mul", "cliloc.enu"
|
|
};
|
|
|
|
// ── paths ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// §1's premise, verified from inside the shard rather than argued: the client files
|
|
/// are already here, and the shard already knows where.
|
|
/// </summary>
|
|
private static void SectionPaths()
|
|
{
|
|
Head("paths");
|
|
|
|
Say("Core.DataDirectories (" + Core.DataDirectories.Count + "):");
|
|
|
|
foreach (var dir in Core.DataDirectories)
|
|
Say(" " + dir);
|
|
|
|
Say("Ultima.Files.Directory: " + (Files.Directory ?? "(null)"));
|
|
Say("");
|
|
|
|
foreach (var file in InterestingFiles)
|
|
{
|
|
var path = Files.GetFilePath(file);
|
|
|
|
if (path == null)
|
|
{
|
|
Say(string.Format(" {0,-22} ABSENT", file));
|
|
continue;
|
|
}
|
|
|
|
var info = new FileInfo(path);
|
|
Say(string.Format(
|
|
" {0,-22} {1,14:N0} bytes {2}", file, info.Length,
|
|
info.LastWriteTimeUtc.ToString("u", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
Say("");
|
|
}
|
|
|
|
// ── statics ──────────────────────────────────────────────────────────────────────────
|
|
|
|
private static void SectionStatics()
|
|
{
|
|
Head("statics — Art.GetStatic");
|
|
|
|
var index = BridgeAssetValidator.OpenArtIndex();
|
|
|
|
if (index == null)
|
|
{
|
|
Say("no art index — artidx.mul/art.mul did not resolve");
|
|
return;
|
|
}
|
|
|
|
var dataPath = BridgeAssetValidator.ArtDataPath();
|
|
long mulLength = BridgeAssetValidator.MulLength(dataPath);
|
|
long verdataLength = BridgeAssetValidator.MulLength(Files.GetFilePath("verdata.mul"));
|
|
|
|
Say(" index offsets are into: " + dataPath);
|
|
|
|
var tally = new Tally();
|
|
int max = Config.Get("Bridge.AssetProbeMaxStatic", 0xFFFF);
|
|
|
|
using (var reader = new BridgeAssetValidator.RecordReader(
|
|
dataPath, Files.GetFilePath("verdata.mul")))
|
|
{
|
|
for (int id = 0; id <= max; id++)
|
|
{
|
|
Checkpoint("statics", id);
|
|
|
|
// The validator's verdict, taken from the index entry BEFORE the library is
|
|
// asked. 0x4000 is the static offset Art applies internally.
|
|
string reason;
|
|
var verdict = BridgeAssetValidator.CheckEntry(index, id + 0x4000, mulLength, verdataLength, out reason);
|
|
|
|
// The entry can be well-formed and the record inside it still hostile, so a
|
|
// passing entry gets its row table walked before the library sees the id.
|
|
// This is the check with teeth — LoadStatic's read cursor is unbounded.
|
|
if (verdict == BridgeAssetValidator.Verdict.Ok)
|
|
{
|
|
string deepReason;
|
|
|
|
if (!reader.StaticSane(index, id + 0x4000, out deepReason))
|
|
{
|
|
verdict = BridgeAssetValidator.Verdict.Refused;
|
|
reason = deepReason;
|
|
}
|
|
}
|
|
|
|
// checkmaxid:false deliberately. With it true, GetLegalItemID maps an
|
|
// out-of-range id to 0 and the call returns ITEM 0's picture — the
|
|
// out-of-range answer would be a real bitmap of the wrong thing, which is
|
|
// precisely the confusion being counted.
|
|
Bitmap bmp = null;
|
|
string thrown = null;
|
|
|
|
try
|
|
{
|
|
bmp = Art.GetStatic(id, false);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
thrown = e.GetType().Name + ": " + e.Message;
|
|
}
|
|
|
|
Record(tally, verdict, reason, bmp != null, thrown, "static/" + id);
|
|
|
|
if (bmp != null)
|
|
bmp.Dispose();
|
|
}
|
|
}
|
|
|
|
tally.Report("statics 0.." + max);
|
|
}
|
|
|
|
// ── land ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
private static void SectionLand()
|
|
{
|
|
Head("land — Art.GetLand");
|
|
|
|
var index = BridgeAssetValidator.OpenArtIndex();
|
|
|
|
if (index == null)
|
|
{
|
|
Say("no art index");
|
|
return;
|
|
}
|
|
|
|
long mulLength = BridgeAssetValidator.MulLength(BridgeAssetValidator.ArtDataPath());
|
|
long verdataLength = BridgeAssetValidator.MulLength(Files.GetFilePath("verdata.mul"));
|
|
|
|
var tally = new Tally();
|
|
|
|
for (int id = 0; id < 0x4000; id++)
|
|
{
|
|
Checkpoint("land", id);
|
|
|
|
string reason;
|
|
var verdict = BridgeAssetValidator.CheckEntry(index, id, mulLength, verdataLength, out reason);
|
|
|
|
// LoadLand reads a fixed 2,024 bytes whatever the record says, so a short record
|
|
// reads past the buffer. The validator's land rule is the only thing standing
|
|
// between that and an out-of-bounds read.
|
|
if (verdict == BridgeAssetValidator.Verdict.Ok)
|
|
{
|
|
string landReason;
|
|
|
|
if (!BridgeAssetValidator.LandLengthSane(index, id, out landReason))
|
|
{
|
|
verdict = BridgeAssetValidator.Verdict.Refused;
|
|
reason = landReason;
|
|
}
|
|
}
|
|
|
|
Bitmap bmp = null;
|
|
string thrown = null;
|
|
|
|
try
|
|
{
|
|
bmp = Art.GetLand(id);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
thrown = e.GetType().Name + ": " + e.Message;
|
|
}
|
|
|
|
Record(tally, verdict, reason, bmp != null, thrown, "land/" + id);
|
|
|
|
if (bmp != null)
|
|
bmp.Dispose();
|
|
}
|
|
|
|
tally.Report("land 0..16383");
|
|
}
|
|
|
|
// ── bodies ───────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Sweeps every body id, taking <c>BodyConverter.Convert</c>'s answer and stopping
|
|
/// there.
|
|
///
|
|
/// **It must never ask the other anim files when that answer yields nothing** (v8.md
|
|
/// §4.3). Doing so does not find missing art: gargoyle 666 maps to <c>anim5</c>, where
|
|
/// this client has nothing, and asking <c>anim2</c> for index 666 returns 175
|
|
/// decodable frames of a giant spider. Every one of those reads reports success, and
|
|
/// nothing downstream can tell. A "0 rows" outcome is the correct answer.
|
|
///
|
|
/// So the sweep records the file type each body resolved to and whether that file
|
|
/// answered — and never a second opinion. <c>ResolveAnimation</c> is that rule as
|
|
/// code, and this sweep is now one of its callers rather than its own transcription.
|
|
///
|
|
/// **Phase 1 added the validator to this sweep**, which phase 0 ran without one:
|
|
/// it reported "1,144 decoded, 0 faults" while the patched client's verdata entry for
|
|
/// body 34 pointed past verdata.mul's own end and the wolf rendered something else,
|
|
/// counted among those 1,144. REFUSED-BUT-DECODED is the cell that says so, and it is
|
|
/// the same cell the art sweeps have had since phase 0.
|
|
/// </summary>
|
|
private static void SectionBodies()
|
|
{
|
|
Head("bodies — Animations.GetAnimation, one direction, first frame");
|
|
|
|
int direction = Config.Get("Bridge.AssetProbeCreatureDirection", 1);
|
|
var tally = new Tally();
|
|
var byFileType = new int[8];
|
|
int unresolved = 0;
|
|
|
|
var indexes = new Dictionary<int, FileIndex>();
|
|
var readers = new Dictionary<int, BridgeAssetValidator.RecordReader>();
|
|
var lengths = new Dictionary<int, long>();
|
|
|
|
string verdataPath = Files.GetFilePath("verdata.mul");
|
|
long verdataLength = BridgeAssetValidator.MulLength(verdataPath);
|
|
|
|
try
|
|
{
|
|
for (int body = 0; body < 2048; body++)
|
|
{
|
|
Checkpoint("bodies", body);
|
|
|
|
int fileType, at;
|
|
string reason;
|
|
|
|
if (!BridgeAssetValidator.ResolveAnimation(body, 0, direction, out fileType, out at, out reason))
|
|
{
|
|
// The never-sweep-file-types rule's own outcome (§4.3): bodyconv sent this
|
|
// body to a file this client does not have, so we report nothing and ask
|
|
// no other file. Asking anim2 for gargoyle 666 returns a giant spider.
|
|
unresolved++;
|
|
continue;
|
|
}
|
|
|
|
if (fileType >= 0 && fileType < byFileType.Length)
|
|
byFileType[fileType]++;
|
|
|
|
if (!indexes.ContainsKey(fileType))
|
|
{
|
|
string dataPath = BridgeAssetValidator.AnimDataPath(fileType);
|
|
|
|
indexes[fileType] = BridgeAssetValidator.OpenAnimIndex(fileType);
|
|
lengths[fileType] = BridgeAssetValidator.MulLength(dataPath);
|
|
readers[fileType] = new BridgeAssetValidator.RecordReader(dataPath, verdataPath);
|
|
}
|
|
|
|
var index = indexes[fileType];
|
|
var verdict = BridgeAssetValidator.CheckEntry(
|
|
index, at, lengths[fileType], verdataLength, out reason);
|
|
|
|
// Only the entry has been judged so far. The record behind it is where the
|
|
// frame table and the unbounded run headers live.
|
|
if (verdict == BridgeAssetValidator.Verdict.Ok
|
|
&& !readers[fileType].AnimationSane(index, at, 1, out reason))
|
|
{
|
|
verdict = BridgeAssetValidator.Verdict.Refused;
|
|
}
|
|
|
|
bool decoded = false;
|
|
string thrown = null;
|
|
|
|
try
|
|
{
|
|
int hue = 0;
|
|
var frames = Animations.GetAnimation(body, 0, direction, ref hue, false, true);
|
|
|
|
if (frames != null && frames.Length > 0 && frames[0] != null && frames[0].Bitmap != null)
|
|
{
|
|
decoded = true;
|
|
frames[0].Bitmap.Dispose();
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
thrown = e.GetType().Name + ": " + e.Message;
|
|
}
|
|
|
|
Record(tally, verdict, reason, decoded, thrown, "body/" + body + "/a0");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
foreach (var reader in readers.Values)
|
|
{
|
|
if (reader != null)
|
|
reader.Dispose();
|
|
}
|
|
}
|
|
|
|
Say("direction " + direction + " (creature default — §5.1)");
|
|
Say(string.Format(" bodyconv resolves nowhere (correct — never swept): {0:N0}", unresolved));
|
|
Say(" by file type: " + string.Join(", ", FileTypeCounts(byFileType)));
|
|
Say("");
|
|
tally.Report("bodies 0..2047, action 0, first frame");
|
|
}
|
|
|
|
private static string[] FileTypeCounts(int[] byFileType)
|
|
{
|
|
var parts = new List<string>();
|
|
|
|
for (int i = 0; i < byFileType.Length; i++)
|
|
{
|
|
if (byFileType[i] > 0)
|
|
parts.Add(i + "=" + byFileType[i]);
|
|
}
|
|
|
|
return parts.ToArray();
|
|
}
|
|
|
|
// ── players ──────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The twelve (on stock 57.4) player-character bodies, each at direction 0 — head-on,
|
|
/// because a character is a portrait and should look at you (§5.1).
|
|
///
|
|
/// Most of them are expected to have no art on the legacy path — the ghosts and every
|
|
/// gargoyle body are UOP-only. **That is the measurement, not a failure**: it is what
|
|
/// phase 4's UOP reader exists for, and a probe that flagged it red would teach an
|
|
/// operator to ignore the panel.
|
|
///
|
|
/// **What is a failure is the middle column.** Phase 0 ran this without the validator
|
|
/// and read the library's answer as the truth, which made it report six of twelve
|
|
/// decoding. Two of those six — the elf ghosts — have an index entry of `length 0` and
|
|
/// were returning whatever body was decoded immediately before them, at that body's
|
|
/// exact dimensions. Four of twelve have art on a stock client, not six.
|
|
/// </summary>
|
|
private static void SectionPlayers()
|
|
{
|
|
Head("player bodies — Race.AllRaces, direction 0");
|
|
|
|
if (_playerBodies == null || _playerBodies.Count == 0)
|
|
{
|
|
Say("no races registered (was the Core-thread snapshot taken?)");
|
|
return;
|
|
}
|
|
|
|
int direction = Config.Get("Bridge.AssetProbePlayerDirection", 0);
|
|
int real = 0, absent = 0, wrong = 0;
|
|
|
|
string verdataPath = Files.GetFilePath("verdata.mul");
|
|
long verdataLength = BridgeAssetValidator.MulLength(verdataPath);
|
|
|
|
foreach (var pb in _playerBodies)
|
|
{
|
|
Checkpoint("players", pb.Body);
|
|
|
|
int fileType, at;
|
|
string reason;
|
|
bool resolved = BridgeAssetValidator.ResolveAnimation(
|
|
pb.Body, 0, direction, out fileType, out at, out reason);
|
|
|
|
// What the validator says BEFORE the library is asked. This is the whole point of
|
|
// the section: phase 0 reported "6 of 12 decode" from the library's answer alone,
|
|
// and two of those six were the previous body's picture.
|
|
var verdict = BridgeAssetValidator.Verdict.Absent;
|
|
|
|
if (resolved)
|
|
{
|
|
string dataPath = BridgeAssetValidator.AnimDataPath(fileType);
|
|
var index = BridgeAssetValidator.OpenAnimIndex(fileType);
|
|
long length = BridgeAssetValidator.MulLength(dataPath);
|
|
|
|
using (var reader = new BridgeAssetValidator.RecordReader(dataPath, verdataPath))
|
|
{
|
|
verdict = BridgeAssetValidator.CheckEntry(index, at, length, verdataLength, out reason);
|
|
|
|
if (verdict == BridgeAssetValidator.Verdict.Ok
|
|
&& !reader.AnimationSane(index, at, 1, out reason))
|
|
{
|
|
verdict = BridgeAssetValidator.Verdict.Refused;
|
|
}
|
|
}
|
|
}
|
|
|
|
string outcome;
|
|
|
|
try
|
|
{
|
|
int hue = 0;
|
|
var frames = Animations.GetAnimation(pb.Body, 0, direction, ref hue, false, true);
|
|
bool gotBitmap = frames != null && frames.Length > 0
|
|
&& frames[0] != null && frames[0].Bitmap != null;
|
|
string size = null;
|
|
|
|
if (gotBitmap)
|
|
{
|
|
var bmp = frames[0].Bitmap;
|
|
size = bmp.Width + "x" + bmp.Height;
|
|
bmp.Dispose();
|
|
}
|
|
|
|
if (verdict == BridgeAssetValidator.Verdict.Ok && gotBitmap)
|
|
{
|
|
outcome = "art, " + size;
|
|
real++;
|
|
}
|
|
else if (gotBitmap)
|
|
{
|
|
// The elf ghosts land here on a stock client: index entry `length 0`, and
|
|
// a bitmap the exact size of whatever was decoded last.
|
|
outcome = "WRONG PICTURE " + size + " — " + reason;
|
|
wrong++;
|
|
}
|
|
else
|
|
{
|
|
outcome = "no art on the legacy path (UOP-only — phase 4): " + reason;
|
|
absent++;
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
outcome = "FAULTED " + e.GetType().Name + ": " + e.Message;
|
|
}
|
|
|
|
Say(string.Format(" {0,-10} {1,-14} body {2,-5} fileType {3,-3} {4}",
|
|
pb.Race, pb.Slot, pb.Body, resolved ? fileType.ToString() : "-", outcome));
|
|
}
|
|
|
|
Say("");
|
|
Say(string.Format(" {0} with art, {1} absent, {2} WRONG PICTURES, of {3}",
|
|
real, absent, wrong, _playerBodies.Count));
|
|
Say("");
|
|
}
|
|
|
|
// ── cliloc ───────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Runs <see cref="BridgeMythicCliloc"/> against the client's own Cliloc.enu and, when
|
|
/// a reference is configured, diffs it against UOFiddler's output entry by entry.
|
|
///
|
|
/// The reference is what makes this a test rather than a demonstration. A decompressor
|
|
/// that is subtly wrong still produces a plausible table — mostly-right strings with a
|
|
/// few mangled ones is the expected shape of a bug in an inverse-BWT coder, and a row
|
|
/// count alone would pass it. Produce the reference with
|
|
/// <c>website/server/tools/cliloc-export --tsv</c>.
|
|
/// </summary>
|
|
private static void SectionCliloc()
|
|
{
|
|
Head("cliloc — the ported Mythic reader (§9)");
|
|
|
|
var path = Files.GetFilePath("cliloc.enu");
|
|
|
|
if (path == null)
|
|
{
|
|
Say("cliloc.enu did not resolve");
|
|
return;
|
|
}
|
|
|
|
Checkpoint("cliloc", 0);
|
|
|
|
var started = DateTime.UtcNow;
|
|
|
|
List<BridgeMythicCliloc.Entry> entries;
|
|
string warning, error;
|
|
|
|
if (!BridgeMythicCliloc.TryLoadFile(path, out entries, out warning, out error))
|
|
{
|
|
Say("FAILED: " + error);
|
|
return;
|
|
}
|
|
|
|
var elapsed = DateTime.UtcNow - started;
|
|
|
|
int blank = 0;
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
if (string.IsNullOrEmpty(entry.Text))
|
|
blank++;
|
|
}
|
|
|
|
Say(string.Format(" {0:N0} entries in {1:N0} ms ({2:N0} blank, {3:N0} would be stored)",
|
|
entries.Count, elapsed.TotalMilliseconds, blank, entries.Count - blank));
|
|
|
|
if (warning != null)
|
|
Say(" WARNING: " + warning);
|
|
|
|
var reference = Config.Get("Bridge.AssetProbeClilocRef", (string)null);
|
|
|
|
if (string.IsNullOrEmpty(reference))
|
|
{
|
|
Say(" no Bridge.AssetProbeClilocRef set — row count only, which proves nothing about the strings");
|
|
Say("");
|
|
return;
|
|
}
|
|
|
|
CompareToReference(entries, reference);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diffs against a UOFiddler-produced tab-separated table. The comparison is
|
|
/// deliberately two-sided: an id we produced and it did not is as much a defect as a
|
|
/// mismatched string, and only checking the ids we happen to hold would hide a table
|
|
/// that stopped early.
|
|
/// </summary>
|
|
private static void CompareToReference(List<BridgeMythicCliloc.Entry> entries, string reference)
|
|
{
|
|
if (!File.Exists(reference))
|
|
{
|
|
Say(" reference not found: " + reference);
|
|
return;
|
|
}
|
|
|
|
var theirs = new Dictionary<int, string>();
|
|
|
|
foreach (var line in File.ReadAllLines(reference))
|
|
{
|
|
var tab = line.IndexOf('\t');
|
|
|
|
if (tab <= 0)
|
|
continue;
|
|
|
|
int number;
|
|
|
|
if (!int.TryParse(line.Substring(0, tab), NumberStyles.Integer, CultureInfo.InvariantCulture, out number))
|
|
continue;
|
|
|
|
theirs[number] = line.Substring(tab + 1);
|
|
}
|
|
|
|
var ours = new Dictionary<int, string>();
|
|
|
|
foreach (var entry in entries)
|
|
ours[entry.Number] = entry.Text;
|
|
|
|
int matched = 0, differed = 0, onlyOurs = 0, onlyTheirs = 0;
|
|
var examples = new List<string>();
|
|
|
|
foreach (var pair in ours)
|
|
{
|
|
string theirText;
|
|
|
|
if (!theirs.TryGetValue(pair.Key, out theirText))
|
|
{
|
|
onlyOurs++;
|
|
continue;
|
|
}
|
|
|
|
// The reference is written by a tool that collapses tabs and newlines to spaces,
|
|
// so compare on the same footing rather than reporting whitespace as a defect.
|
|
if (Flatten(pair.Value) == theirText)
|
|
{
|
|
matched++;
|
|
}
|
|
else
|
|
{
|
|
differed++;
|
|
|
|
if (examples.Count < 10)
|
|
{
|
|
examples.Add(" #" + pair.Key
|
|
+ "\n ours: " + Truncate(Flatten(pair.Value))
|
|
+ "\n theirs: " + Truncate(theirText));
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var key in theirs.Keys)
|
|
{
|
|
if (!ours.ContainsKey(key))
|
|
onlyTheirs++;
|
|
}
|
|
|
|
Say(string.Format(" vs UOFiddler: {0:N0} identical, {1:N0} differ, {2:N0} only ours, {3:N0} only theirs",
|
|
matched, differed, onlyOurs, onlyTheirs));
|
|
|
|
if (differed == 0 && onlyOurs == 0 && onlyTheirs == 0)
|
|
Say(" IDENTICAL — the port reproduces UOFiddler's table exactly");
|
|
|
|
foreach (var example in examples)
|
|
Say(example);
|
|
|
|
Say("");
|
|
}
|
|
|
|
private static string Flatten(string s)
|
|
{
|
|
return s.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
|
|
}
|
|
|
|
private static string Truncate(string s)
|
|
{
|
|
return s.Length <= 90 ? s : s.Substring(0, 90) + "…";
|
|
}
|
|
|
|
// ── gump: the deliberate crash ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Reproduces §4.1's access violation **from inside a running ServUO**, which is the
|
|
/// only place the claim actually matters. `Gumps` is the one decoder that builds its
|
|
/// `FileIndex` with <c>hasExtra: true</c>, and <c>FileIndex.cs</c>'s own comment says
|
|
/// that branch exists for <c>gumpartlegacy.uop</c>.
|
|
///
|
|
/// <c>AccessViolationException</c> is a corrupted-state exception and .NET Framework
|
|
/// 4.8 does not deliver it to an ordinary catch, so **this takes the shard down** and
|
|
/// there is no in-process defence. That is the finding, and the reason "nothing calls
|
|
/// <c>Ultima.Gumps</c>" is a safety rule rather than a scoping preference. Never part
|
|
/// of "all"; never run on anything but a rig.
|
|
/// </summary>
|
|
private static void SectionGump()
|
|
{
|
|
Head("gump — DELIBERATE CRASH (§4.1)");
|
|
Say(" This is expected to kill the process. Nothing in Protocol 8 calls Ultima.Gumps.");
|
|
Say(" If the shard survives this section, that is itself the finding — record it.");
|
|
|
|
Checkpoint("gump", 2);
|
|
|
|
try
|
|
{
|
|
var bmp = Ultima.Gumps.GetGump(2);
|
|
Say(" SURVIVED: GetGump(2) returned " + (bmp == null ? "null" : bmp.Width + "x" + bmp.Height));
|
|
|
|
if (bmp != null)
|
|
bmp.Dispose();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Say(" caught (so it was not a corrupted-state exception): " + e.GetType().Name + ": " + e.Message);
|
|
}
|
|
|
|
Say("");
|
|
}
|
|
|
|
// ── Tally ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The four counts that matter, and one of them is the point of the whole probe.
|
|
///
|
|
/// <c>RefusedButDecoded</c> is a record the validator rejects and the library returned
|
|
/// a picture for anyway. On a stock client that number should be zero. On a patched
|
|
/// one it is the population of wrong pictures — the failure this protocol most needs
|
|
/// to avoid, because it raises no error anywhere and no success count can see it.
|
|
/// </summary>
|
|
private sealed class Tally
|
|
{
|
|
public int Ok; // validator passed, decoded
|
|
public int Absent; // validator says nothing there, library agreed
|
|
public int AbsentButDecoded; // NOTHING is there, and the library returned a picture
|
|
public int Refused; // validator refused, library also returned nothing
|
|
public int RefusedButDecoded; // validator refused, library returned a picture anyway
|
|
public int OkButNothing; // validator passed, library returned nothing
|
|
public int Threw; // the library threw
|
|
public readonly List<string> Examples = new List<string>();
|
|
|
|
public void Report(string label)
|
|
{
|
|
Say(string.Format(" {0}:", label));
|
|
Say(string.Format(" ok {0:N0} absent {1:N0} refused {2:N0} threw {3:N0}", Ok, Absent, Refused, Threw));
|
|
Say(string.Format(" validator passed but nothing decoded: {0:N0}", OkButNothing));
|
|
Say(string.Format(" WRONG PICTURES, empty record: {0:N0}", AbsentButDecoded));
|
|
Say(string.Format(" WRONG PICTURES, bad record: {0:N0}", RefusedButDecoded));
|
|
|
|
if (Examples.Count > 0)
|
|
{
|
|
Say(" examples:");
|
|
|
|
foreach (var example in Examples)
|
|
Say(" " + example);
|
|
}
|
|
|
|
Say("");
|
|
}
|
|
}
|
|
|
|
private static void Record(Tally tally, BridgeAssetValidator.Verdict verdict, string reason, bool decoded, string thrown, string key)
|
|
{
|
|
if (thrown != null)
|
|
{
|
|
tally.Threw++;
|
|
|
|
if (tally.Examples.Count < 20)
|
|
tally.Examples.Add(key + " THREW " + thrown + (reason == null ? "" : " [validator: " + reason + "]"));
|
|
|
|
return;
|
|
}
|
|
|
|
switch (verdict)
|
|
{
|
|
case BridgeAssetValidator.Verdict.Ok:
|
|
if (decoded)
|
|
tally.Ok++;
|
|
else
|
|
tally.OkButNothing++;
|
|
|
|
break;
|
|
|
|
case BridgeAssetValidator.Verdict.Absent:
|
|
// An empty record that still yields a bitmap is not a disagreement about
|
|
// strictness. It is the shared-buffer defect: LoadStatic reuses
|
|
// m_StreamBuffer, only ever grows it, and discards stream.Read's return, so
|
|
// a zero-length record decodes whatever the PREVIOUS asset left behind.
|
|
if (decoded)
|
|
{
|
|
tally.AbsentButDecoded++;
|
|
|
|
if (tally.Examples.Count < 20)
|
|
tally.Examples.Add(key + " has no record (" + reason + ") — the library returned a picture");
|
|
}
|
|
else
|
|
{
|
|
tally.Absent++;
|
|
}
|
|
|
|
break;
|
|
|
|
case BridgeAssetValidator.Verdict.Refused:
|
|
if (decoded)
|
|
{
|
|
tally.RefusedButDecoded++;
|
|
|
|
if (tally.Examples.Count < 20)
|
|
tally.Examples.Add(key + " REFUSED (" + reason + ") — library returned a picture anyway");
|
|
}
|
|
else
|
|
{
|
|
tally.Refused++;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── Output ───────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Writes the id the probe is **about to** touch, then flushes.
|
|
///
|
|
/// Learned the expensive way from the PowerShell probes and it matters more here: an
|
|
/// access violation is not catchable and does not unwind, so nothing in this file runs
|
|
/// after one. The last line in this file is the only evidence of which id killed the
|
|
/// shard.
|
|
/// </summary>
|
|
private static void Checkpoint(string section, int id)
|
|
{
|
|
try
|
|
{
|
|
File.WriteAllText(_checkpointPath, section + " " + id + " @ "
|
|
+ DateTime.UtcNow.ToString("u", CultureInfo.InvariantCulture) + Environment.NewLine);
|
|
}
|
|
catch
|
|
{
|
|
// A checkpoint that cannot be written must not stop the sweep.
|
|
}
|
|
}
|
|
|
|
private static void Head(string title)
|
|
{
|
|
Say("── " + title + " " + new string('─', Math.Max(0, 70 - title.Length)));
|
|
}
|
|
|
|
private static void Say(string text)
|
|
{
|
|
Console.WriteLine("[assetprobe] {0}", text);
|
|
|
|
var report = _report;
|
|
|
|
if (report != null)
|
|
{
|
|
try
|
|
{
|
|
report.WriteLine(text);
|
|
}
|
|
catch
|
|
{
|
|
// Reporting must never be the thing that fails the run.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void Tell(Mobile to, string text)
|
|
{
|
|
Console.WriteLine("[assetprobe] {0}", text);
|
|
|
|
if (to != null)
|
|
to.SendMessage(text);
|
|
}
|
|
}
|
|
}
|