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 Ultima;
namespace Server.Custom
{
///
/// **Asset Bridge phase 0 — the spike** (docs/link/v8.md §16).
///
/// §4 decided to call ServUO's own vendored Ultima 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:
///
/// * LoadStatic walks bindata[count++] with no bound on count. 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.
/// * stream.Read(m_StreamBuffer, 0, length) ignores its return value, and
/// m_StreamBuffer is reused and only ever grown. A short read therefore decodes
/// **the previous asset's bytes** under this asset's id.
/// * LoadLand reads a fixed 2,024 bytes whatever length says.
/// * Art.GetLegalItemID returns **0** for an out-of-range id, so GetStatic
/// of a nonexistent id can hand back item 0's picture instead of nothing.
///
/// So every sweep here compares two answers: what says
/// about the index entry *before* the call, and what Ultima 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 Ultima.Gumps**, which is a safety rule and not a preference
/// (v8.md §4.1) — with one deliberate exception, the opt-in gump 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; deploy.ps1 copies only overlay/.
/// In game / from BridgeRigDriver: [assetprobe [section] [stock|patched].
/// Flag: AssetProbeOnStart. Build the patched client with
/// tools/patch_client.ps1.
///
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 _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 ────────────────────────────────────────────────────────────────────────────
///
/// 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.
///
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");
}
/// §5.2: ask the shard which bodies are player characters; never hardcode them.
private static List ReadPlayerBodies()
{
var list = new List();
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 ──────────────────────────────────────────────────────
///
/// Re-points Ultima.Files at the stock or the patched client and reloads every
/// index built from it.
///
/// **Not Files.SetMulPath(string).** That overload keeps any entry already
/// holding an absolute path — its own comment reads // absolut dir ignore — 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.
///
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;
}
///
/// Every key this protocol reads, lowercase because that is how Files.MulPath is
/// keyed. Gump keys are absent on purpose (§4.1).
///
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 ────────────────────────────────────────────────────────────────────────────
///
/// §1's premise, verified from inside the shard rather than argued: the client files
/// are already here, and the shard already knows where.
///
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 ───────────────────────────────────────────────────────────────────────────
///
/// Sweeps every body id, taking BodyConverter.Convert'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 anim5, where
/// this client has nothing, and asking anim2 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.
///
private static void SectionBodies()
{
Head("bodies — Animations.GetAnimation, one direction, first frame");
int direction = Config.Get("Bridge.AssetProbeCreatureDirection", 1);
int decoded = 0, empty = 0, faulted = 0;
var byFileType = new int[8];
var faults = new List();
for (int body = 0; body < 2048; body++)
{
Checkpoint("bodies", body);
int translated = body;
int fileType;
try
{
fileType = BodyConverter.Convert(ref translated);
}
catch (Exception e)
{
faulted++;
faults.Add("body " + body + " BodyConverter.Convert: " + e.GetType().Name + ": " + e.Message);
continue;
}
if (fileType >= 0 && fileType < byFileType.Length)
byFileType[fileType]++;
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++;
frames[0].Bitmap.Dispose();
}
else
{
empty++;
}
}
catch (Exception e)
{
faulted++;
if (faults.Count < 40)
faults.Add("body " + body + " (fileType " + fileType + "): " + e.GetType().Name + ": " + e.Message);
}
}
Say("direction " + direction + " (creature default — §5.1)");
Say(string.Format(" decoded {0} empty {1} FAULTED {2}", decoded, empty, faulted));
Say(" by file type: " + string.Join(", ", FileTypeCounts(byFileType)));
if (faults.Count > 0)
{
Say("");
Say(" faults:");
foreach (var f in faults)
Say(" " + f);
}
Say("");
}
private static string[] FileTypeCounts(int[] byFileType)
{
var parts = new List();
for (int i = 0; i < byFileType.Length; i++)
{
if (byFileType[i] > 0)
parts.Add(i + "=" + byFileType[i]);
}
return parts.ToArray();
}
// ── players ──────────────────────────────────────────────────────────────────────────
///
/// 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).
///
/// Six of them are expected to report nothing on the legacy path: both human 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.
///
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 decoded = 0, absent = 0;
foreach (var pb in _playerBodies)
{
Checkpoint("players", pb.Body);
int translated = pb.Body;
int fileType = BodyConverter.Convert(ref translated);
string outcome;
try
{
int hue = 0;
var frames = Animations.GetAnimation(pb.Body, 0, direction, ref hue, false, true);
if (frames != null && frames.Length > 0 && frames[0] != null && frames[0].Bitmap != null)
{
var bmp = frames[0].Bitmap;
outcome = "decoded " + bmp.Width + "x" + bmp.Height;
bmp.Dispose();
decoded++;
}
else
{
outcome = "no art on the legacy path (UOP-only — phase 4)";
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, fileType, outcome));
}
Say("");
Say(string.Format(" {0} decoded, {1} absent, of {2}", decoded, absent, _playerBodies.Count));
Say("");
}
// ── cliloc ───────────────────────────────────────────────────────────────────────────
///
/// Runs 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
/// website/server/tools/cliloc-export --tsv.
///
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 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);
}
///
/// 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.
///
private static void CompareToReference(List entries, string reference)
{
if (!File.Exists(reference))
{
Say(" reference not found: " + reference);
return;
}
var theirs = new Dictionary();
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();
foreach (var entry in entries)
ours[entry.Number] = entry.Text;
int matched = 0, differed = 0, onlyOurs = 0, onlyTheirs = 0;
var examples = new List();
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 ───────────────────────────────────────────────────────
///
/// 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 hasExtra: true, and FileIndex.cs's own comment says
/// that branch exists for gumpartlegacy.uop.
///
/// AccessViolationException 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
/// Ultima.Gumps" is a safety rule rather than a scoping preference. Never part
/// of "all"; never run on anything but a rig.
///
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 ────────────────────────────────────────────────────────────────────────────
///
/// The four counts that matter, and one of them is the point of the whole probe.
///
/// RefusedButDecoded 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.
///
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 Examples = new List();
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 ───────────────────────────────────────────────────────────────────────────
///
/// 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.
///
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);
}
}
///
/// **Validate before calling** — the response the org lead chose for §4.2's residual risk,
/// prototyped here so phase 1 adopts it with measurements rather than on faith.
///
/// The principle: `Ultima`'s decoders take their bounds from the file they are reading, so
/// the extractor must decide whether a record is worth handing over *before* handing it
/// over. Every check below is against the index entry and the record header — cheap, and
/// enough to turn an uncatchable corrupted-state exception into a skipped asset.
///
/// It cannot be complete and does not claim to be. It closes the shapes that reading the
/// source showed are reachable; the probe's REFUSED-BUT-DECODED count is what says whether
/// the boundary is drawn in the right place.
///
/// Promoted into the overlay in phase 1.
///
public static class BridgeAssetValidator
{
public enum Verdict
{
/// Nothing at this id, and the index says so honestly.
Absent,
/// The entry is self-consistent and inside its file.
Ok,
/// The entry claims something the file cannot support. Do not decode it.
Refused
}
/// Land tiles decode a fixed 44×44 diamond: 2 × (2+4+…+44) ushorts.
public const int LandRecordBytes = 2024;
///
/// A ceiling on decoded art dimensions. `LoadStatic` allocates
/// new Bitmap(width, height) straight from two ushorts in the record, so a
/// corrupt header asks for up to 65535×65535 — an 8 GB allocation, from a file. Real
/// art is a couple of hundred pixels at most.
///
public const int MaxArtDimension = 1024;
///
/// Builds our own index over the same files, with the same constructor arguments
/// Art uses — including hasExtra: false, which is the whole reason the
/// art path is safe where the gump path is not (§4.1).
///
public static FileIndex OpenArtIndex()
{
if (ArtDataPath() == null)
return null;
return new FileIndex("Artidx.mul", "Art.mul", "artLegacyMUL.uop", 0x10000, 4, ".tga", 0x13FDC, false);
}
///
/// The file an art index entry's lookup is an offset **into** — which is not
/// art.mul on any current client.
///
/// This cost a whole probe run to learn and it is the single most important thing
/// phase 1 must not get wrong. FileIndex's UOP constructor ends with a bare
/// MulPath = uopPath: **when artLegacyMUL.uop exists it wins outright**,
/// and art.mul / artidx.mul are never opened at all. A validator that
/// bounds offsets against art.mul while the index holds UOP offsets is not
/// merely approximate, it is nonsense — the first run of this probe refused 34,299
/// perfectly good statics for "declaring 10533x2085" because it was reading UOP
/// offsets into the wrong file.
///
/// So the resolution order here mirrors FileIndex's exactly, and anything that
/// needs the bytes behind an entry must ask this rather than assume.
///
public static string ArtDataPath()
{
var uop = Files.GetFilePath("artlegacymul.uop");
if (uop != null)
return uop;
return Files.GetFilePath("art.mul");
}
public static long MulLength(string path)
{
if (path == null)
return 0;
try
{
return new FileInfo(path).Length;
}
catch
{
return 0;
}
}
///
/// Judges one index entry.
///
/// The check FileIndex.Seek is missing is the last one: it tests
/// Stream.Length < e.lookup — that the record *starts* inside the file — and
/// never that it *ends* inside it. A record that begins two bytes before EOF and
/// declares a length of 4,000 passes, and stream.Read then returns a short count
/// that the decoders discard, leaving the previous asset's bytes in the shared buffer.
///
public static Verdict CheckEntry(FileIndex index, int at, long mulLength, long verdataLength, out string reason)
{
reason = null;
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
{
reason = "index " + at + " out of range";
return Verdict.Absent;
}
Entry3D e = index.Index[at];
if (e.lookup < 0)
{
reason = "lookup " + e.lookup;
return Verdict.Absent;
}
bool patched = (e.length & (1 << 31)) != 0;
int length = e.length & 0x7FFFFFFF;
if (!patched && e.length < 0)
{
reason = "length " + e.length;
return Verdict.Absent;
}
if (length == 0)
{
reason = "lookup " + e.lookup + ", length 0";
return Verdict.Absent;
}
long ceiling = patched ? verdataLength : mulLength;
if (ceiling <= 0)
{
reason = (patched ? "verdata.mul" : "the art data file") + " has no length";
return Verdict.Refused;
}
if (e.lookup >= ceiling)
{
reason = "lookup " + e.lookup + " past the end of "
+ (patched ? "verdata.mul" : "the mul") + " (" + ceiling + ")";
return Verdict.Refused;
}
// The missing check. A short read is silent, and its consequence is the PREVIOUS
// asset's picture served under this id.
if (e.lookup + (long)length > ceiling)
{
reason = "record runs " + (e.lookup + (long)length - ceiling) + " bytes past the end of "
+ (patched ? "verdata.mul" : "the mul");
return Verdict.Refused;
}
return Verdict.Ok;
}
///
/// `LoadLand` reads 2,024 bytes regardless of the declared length, so a shorter record
/// reads past the end of a buffer sized from that length.
///
public static bool LandLengthSane(FileIndex index, int at, out string reason)
{
reason = null;
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
return true;
int length = index.Index[at].length & 0x7FFFFFFF;
if (length > 0 && length < LandRecordBytes)
{
reason = "land record is " + length + " bytes; LoadLand always reads " + LandRecordBytes;
return false;
}
return true;
}
///
/// Walks a static record's own row table the way LoadStatic will, and refuses
/// it if that walk would read outside the record.
///
/// This is the check with teeth. LoadStatic's inner loop guards the write into
/// the bitmap (xOffset > delta, xOffset + xRun > delta) and does
/// nothing at all about the read cursor, which advances until it happens to find a
/// zero pair — potentially far outside a pinned array. Simulating the same walk with
/// a bound is the cheapest way to know whether handing the id over is safe.
///
public static bool StaticRecordSane(byte[] record, int length, out string reason)
{
reason = null;
if (length < 8)
{
reason = "record is " + length + " bytes; a static header needs 8";
return false;
}
int words = length / 2;
int width = ReadUInt16(record, 4);
int height = ReadUInt16(record, 6);
// LoadStatic returns null for these rather than misbehaving, so it is not a refusal.
if (width <= 0 || height <= 0)
return true;
if (width > MaxArtDimension || height > MaxArtDimension)
{
reason = "declares " + width + "x" + height + ", past the " + MaxArtDimension + "px ceiling";
return false;
}
// The row-lookup table: height ushorts starting at word 4.
if (4 + height > words)
{
reason = "row table (" + height + " entries) does not fit in a " + length + "-byte record";
return false;
}
int start = height + 4;
for (int y = 0; y < height; y++)
{
int cursor = start + ReadUInt16(record, (4 + y) * 2);
while (true)
{
// Two ushorts for the run header, and they must both be inside the record.
if (cursor < 0 || cursor + 1 >= words)
{
reason = "row " + y + " reads at word " + cursor + ", past the record's " + words;
return false;
}
int xOffset = ReadUInt16(record, cursor * 2);
int xRun = ReadUInt16(record, (cursor + 1) * 2);
cursor += 2;
if (xOffset + xRun == 0)
break;
// LoadStatic stops the row here, so the read cursor stops with it.
if (xOffset > width || xOffset + xRun > width)
break;
if (cursor + xRun > words)
{
reason = "row " + y + " declares a " + xRun + "-pixel run running past the record";
return false;
}
cursor += xRun;
}
}
return true;
}
private static int ReadUInt16(byte[] b, int at)
{
return b[at] | (b[at + 1] << 8);
}
///
/// Reads a record's actual bytes so can walk it.
///
/// Holds its own handles rather than borrowing the library's, because FileIndex
/// hands out the stream it decodes from and moving that stream's position underneath
/// the decoder would be its own bug. Opened FileShare.ReadWrite to match how
/// FileIndex opens the same files.
///
public sealed class RecordReader : IDisposable
{
private readonly FileStream _mul;
private readonly FileStream _verdata;
private byte[] _scratch = new byte[64 * 1024];
public RecordReader(string mulPath, string verdataPath)
{
_mul = Open(mulPath);
_verdata = Open(verdataPath);
}
private static FileStream Open(string path)
{
if (path == null || !File.Exists(path))
return null;
try
{
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
}
catch
{
return null;
}
}
///
/// True when the record at is safe to hand to
/// Art.GetStatic. A record that cannot be read at all is reported sane —
/// has already judged the entry, and this must not
/// invent a second reason to refuse.
///
public bool StaticSane(FileIndex index, int at, out string reason)
{
reason = null;
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
return true;
Entry3D e = index.Index[at];
bool patched = (e.length & (1 << 31)) != 0;
int length = e.length & 0x7FFFFFFF;
var stream = patched ? _verdata : _mul;
if (stream == null || length <= 0 || e.lookup < 0)
return true;
if (_scratch.Length < length)
_scratch = new byte[length];
int read;
try
{
stream.Seek(e.lookup, SeekOrigin.Begin);
read = stream.Read(_scratch, 0, length);
}
catch (Exception ex)
{
reason = "cannot read the record: " + ex.GetType().Name;
return false;
}
// The short read the decoders discard. Refusing here is the whole point: the
// library would decode whatever the shared buffer happened to hold.
if (read < length)
{
reason = "short read — " + read + " of " + length + " bytes available";
return false;
}
return StaticRecordSane(_scratch, length, out reason);
}
public void Dispose()
{
if (_mul != null)
_mul.Dispose();
if (_verdata != null)
_verdata.Dispose();
}
}
}
}