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
{
///
/// **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. ResolveAnimation 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.
///
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();
var readers = new Dictionary();
var lengths = new Dictionary();
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();
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).
///
/// 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.
///
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 ───────────────────────────────────────────────────────────────────────────
///
/// 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);
}
}
}