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); } } }