From c79455a050cfb0f486265b2415625387a5f10921 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:28:26 +0000 Subject: [PATCH] Read game output with MarkupString's decoder instead of our own parsers AnsiParser, MxpParser, PuebloParser and ILineParser are replaced by MarkupLineParser, one MarkupString Decoder per session (WithVt, WithMxp, WithPueblo) folded into the existing StyledLine model. The old parser test suites are kept and run against the adapter as the capability inventory; the gaps they found were fixed in MarkupString 2.27.0. Behaviour changes the tests now record:   is U+00A0, erase-line erases, Pueblo shows its label, VERSION values are quoted, a picture that would cover text goes inline, and a forged non-web link is text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7WA8C5XG14TAWB6b9V --- CHANGELOG.md | 6 + CLAUDE.md | 11 +- Directory.Packages.props | 9 + .../Protocols/ChannelCapture.cs | 7 +- .../Protocols/MxpClientSupport.cs | 70 + src/SharpMUTerm.Core/Protocols/MxpEvents.cs | 15 + src/SharpMUTerm.Core/Protocols/MxpLineMode.cs | 16 - .../Protocols/MxpParser.Definitions.cs | 578 ------ src/SharpMUTerm.Core/Protocols/MxpParser.cs | 1682 ----------------- .../Protocols/MxpTagCategory.cs | 32 - .../Protocols/PuebloParser.cs | 786 -------- src/SharpMUTerm.Core/Session/WorldSession.cs | 45 +- src/SharpMUTerm.Core/SharpMUTerm.Core.csproj | 4 + src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 8 +- src/SharpMUTerm.Core/Text/AnsiParser.cs | 294 --- src/SharpMUTerm.Core/Text/ILineParser.cs | 24 - src/SharpMUTerm.Core/Text/MarkupLineParser.cs | 714 +++++++ src/SharpMUTerm.Core/Text/SgrCodes.cs | 205 -- src/SharpMUTerm.Core/Text/TextStyle.cs | 2 +- src/SharpMUTerm.Tui/LinkPayload.cs | 2 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 16 +- src/SharpMUTerm.Web/WebPageFetcher.cs | 8 +- .../Protocols/MxpDefinitionTests.cs | 106 +- .../Protocols/MxpImageTests.cs | 13 +- .../Protocols/MxpLineModeTests.cs | 71 +- .../Protocols/MxpParserTests.cs | 52 +- .../Protocols/MxpSendMenuTests.cs | 5 +- .../Protocols/MxpTagCategoryTests.cs | 97 - .../Protocols/ParserBoundaryTests.cs | 10 +- .../Protocols/PuebloParserTests.cs | 32 +- .../Telnet/MxpCapabilityTests.cs | 5 +- .../Text/AnsiParserTests.cs | 29 +- .../Text/SgrCodesTests.cs | 33 - .../LinkSchemeSecurityTests.cs | 24 +- .../ThemeRepaintTests.cs | 2 +- 35 files changed, 1047 insertions(+), 3966 deletions(-) create mode 100644 src/SharpMUTerm.Core/Protocols/MxpClientSupport.cs create mode 100644 src/SharpMUTerm.Core/Protocols/MxpEvents.cs delete mode 100644 src/SharpMUTerm.Core/Protocols/MxpLineMode.cs delete mode 100644 src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs delete mode 100644 src/SharpMUTerm.Core/Protocols/MxpParser.cs delete mode 100644 src/SharpMUTerm.Core/Protocols/MxpTagCategory.cs delete mode 100644 src/SharpMUTerm.Core/Protocols/PuebloParser.cs delete mode 100644 src/SharpMUTerm.Core/Text/AnsiParser.cs delete mode 100644 src/SharpMUTerm.Core/Text/ILineParser.cs create mode 100644 src/SharpMUTerm.Core/Text/MarkupLineParser.cs delete mode 100644 src/SharpMUTerm.Core/Text/SgrCodes.cs delete mode 100644 tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs delete mode 100644 tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9eb93d..ad5c5ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Changed +- Game output is read by MarkupString 2.27.0 instead of SharpMUTerm's own ANSI, MXP and Pueblo readers. What you + see should not change, with a few exceptions: ` ` is a non-breaking space; an erase-line sequence now erases; + Pueblo pictures show an `[image: …]` label; an MXP picture that would cover text the world wrote shows inline + instead; and a world's link whose address is not a web address (such as one that spells out a command) is shown + as plain text instead of a link that refuses to open. + - The Windows installer and the Linux AppImage are now built with Velopack, which is what lets them update. Windows installs per user under `%LocalAppData%\SharpMUTerm`, adds the `sharpmuterm` folder to PATH and installs the Symbols Nerd Font Mono font, with no questions asked. If you installed an earlier version with the old installer, uninstall diff --git a/CLAUDE.md b/CLAUDE.md index 70ccad62..1834073d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ fallbacks) for inline images/maps. **M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all fifteen projects on `net10.0`, with the full test suite passing. In place: -- **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model (a capped +- **Core** — `MarkupLineParser` (MarkupString's `Decoder` with `WithVt`, `WithMxp` and `WithPueblo`, folded into + `StyledLine`s), styled-line + `ScrollbackBuffer` model (a capped in-memory ring plus a **file-backed spill**, `FileScrollbackSpill`, so history deeper than memory is paged off an ephemeral per-session cache under `$XDG_CACHE_HOME`; absolute line indices, ranged reads capped at `MaxRangeLines`, and any disk failure degrades to memory-only. Emphatically **not** @@ -3552,6 +3553,14 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] already wires one callback to both plugins, so the signal arrives on the version bump. - **What is still ours**: a server that sends no boundary marker at all — no GA, no EOR, no newline — is now `PacketPatchProtocol`'s, which infers a boundary from silence. +- **The escape, MXP and Pueblo parsers are MarkupString's now (2026-11-07).** `Text/MarkupLineParser` wraps one + `MarkupString.Decoding.Decoder` per session (`WithVt`, a literal-tab plugin, `WithMxp(MxpOptions { ShowRefusedTags, + Supports })`, `WithPueblo`) and folds each decoded line into `StyledLine`/`StyledSpan`; `Switch(format)` turns + the protocols on and off. `AnsiParser`, `MxpParser`, `PuebloParser`, `ILineParser`, `SgrCodes`, `MxpTagCategory` + and `MxpLineMode` are gone. The notes below about them record why the behaviour is what it is; the behaviour + now lives in MarkupString, and the old parser test suites still run against the adapter. A gap found there is + fixed in MarkupString, not worked around here. Server links outside `UrlPolicy.Safe` (such as a forged + `mux:send:`) are read as text by the decoder, before the click-time http(s) gate. - **MXP's line-security model is the parser's real job, and the allow-list is chosen deliberately over a deny-list** (`MxpLineMode`, `MxpTagCategory`, `MxpParser` — the eight `ESC[#z` line tags: OPEN, SECURE, LOCKED, TEMP SECURE, and LOCK OPEN/SECURE/LOCKED plus RESET). The spec draws its own line in diff --git a/Directory.Packages.props b/Directory.Packages.props index d32e792e..b6ae4237 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,15 @@ + + + + + + diff --git a/src/SharpMUTerm.Core/Protocols/ChannelCapture.cs b/src/SharpMUTerm.Core/Protocols/ChannelCapture.cs index 02ee1967..c40a5268 100644 --- a/src/SharpMUTerm.Core/Protocols/ChannelCapture.cs +++ b/src/SharpMUTerm.Core/Protocols/ChannelCapture.cs @@ -77,12 +77,7 @@ public static (string Window, IReadOnlyList Lines)? Read(string json text = text[..MaxTextLength]; } - var parser = new AnsiParser(); - var lines = new List(parser.Feed(text.Replace("\r", string.Empty, StringComparison.Ordinal))); - if (parser.Flush() is { } last) - { - lines.Add(last); - } + var lines = new List(MarkupLineParser.ParseAll(text.Replace("\r", string.Empty, StringComparison.Ordinal))); while (lines.Count > 0 && lines[^1].Text.Trim().Length == 0) { diff --git a/src/SharpMUTerm.Core/Protocols/MxpClientSupport.cs b/src/SharpMUTerm.Core/Protocols/MxpClientSupport.cs new file mode 100644 index 00000000..0f550324 --- /dev/null +++ b/src/SharpMUTerm.Core/Protocols/MxpClientSupport.cs @@ -0,0 +1,70 @@ +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Protocols; + +/// +/// What this client says about its MXP: its <VERSION> and the <SUPPORT> answer. The telnet +/// layer answers both itself, because the library takes those lines out of the stream before the reader sees them. +/// +/// +/// An honest list, and deliberately not an aspirational one: a SUPPORTS answer is a claim a server acts on, so +/// naming a tag nothing here acts on makes it send markup that shows as nothing. A tag is listed when MarkupString +/// reads it and does something with what it becomes. +/// +public static class MxpClientSupport +{ + /// What this client answers a VERSION request with. + public const string ClientName = GmcpHandshake.ClientName; + + /// The MXP revision a VERSION reply names. + public const string MxpRevision = "1.0"; + + /// The style-sheet version a VERSION reply names: none is ever in effect. + public const string StyleVersion = "0"; + + /// The client version a VERSION reply names: this build's, as MinVer stamped it. + public static string ClientVersion => GmcpHandshake.ClientVersion; + + /// The tags this client renders, by name. + public static IReadOnlyList SupportedTags { get; } = + ["b", "i", "u", "s", "h", "color", "font", "send", "a", "br", "image", + "!element", "!entity", "var", "gauge", "stat", "sound", "music", "frame", "dest"]; + + /// + /// The attributes acted on, by tag, for a <SUPPORT tag.attr> or <SUPPORT tag.*>. The same + /// honesty rule as : image.ismap, read and ignored, is not listed. + /// + public static IReadOnlyDictionary SupportedAttributes { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["color"] = ["fore", "back"], + ["send"] = ["href", "hint", "prompt"], + ["a"] = ["href", "hint"], + ["image"] = ["fname", "url", "t", "w", "h"], + ["gauge"] = ["entity", "max", "caption"], + ["stat"] = ["entity", "max", "caption"], + ["sound"] = ["fname", "v", "l", "p", "t", "u"], + ["music"] = ["fname", "v", "l", "c", "t", "u"], + ["frame"] = ["name", "action", "title"], + ["dest"] = ["name"], + }; + + /// The same table as MarkupString takes it. + internal static IReadOnlyDictionary> Supports { get; } = + SupportedTags.ToDictionary( + tag => tag, + tag => (IReadOnlyList)(SupportedAttributes.GetValueOrDefault(tag) ?? []), + StringComparer.OrdinalIgnoreCase); + + private static readonly MarkupString.Mxp.Decoding.MxpOptions Options = new() { Supports = Supports }; + + /// + /// The answer to a <SUPPORT> that asked about : every tag in + /// when it asked about nothing, otherwise each entry it named, as one this client + /// renders or one it does not. tag.attr is supported when lists it, and + /// tag.* is answered with each attribute listed for the tag; a tag this client does not render is answered + /// -tag.*. + /// + public static (IReadOnlyList Supported, IReadOnlyList Unsupported) AnswerSupport(IReadOnlyList asked) => + Options.Answer(asked); +} diff --git a/src/SharpMUTerm.Core/Protocols/MxpEvents.cs b/src/SharpMUTerm.Core/Protocols/MxpEvents.cs new file mode 100644 index 00000000..2c0e4c79 --- /dev/null +++ b/src/SharpMUTerm.Core/Protocols/MxpEvents.cs @@ -0,0 +1,15 @@ +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Protocols; + +/// A server's entity changed: <!ENTITY>, <VAR> or an element's FLAG="set x". A null value is a deletion. +public sealed record MxpEntityChange(string Name, string? Value); + +/// The text of an element whose FLAG says what it is: RoomName, RoomDesc, RoomExit, RoomNum… +public sealed record MxpFlagText(string Flag, string Text); + +/// A <GAUGE> or <STAT>: show entity (out of ) on the status row. +public sealed record MxpGauge(string Entity, string? Max, string? Caption, bool IsStat); + +/// Text a <DEST>, a redirecting <FRAME> or a Pueblo pane sent to a named window instead of the main one. +public sealed record MxpRedirect(string Frame, StyledLine Line); diff --git a/src/SharpMUTerm.Core/Protocols/MxpLineMode.cs b/src/SharpMUTerm.Core/Protocols/MxpLineMode.cs deleted file mode 100644 index 1f66ef3c..00000000 --- a/src/SharpMUTerm.Core/Protocols/MxpLineMode.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharpMUTerm.Core.Protocols; - -/// -/// How much of MXP a line is allowed to use, per the specification's line-security model. -/// -public enum MxpLineMode -{ - /// Only tags in the open category are honoured. The default at connection start. - Open, - - /// Every tag is honoured. - Secure, - - /// Nothing is parsed. Every character is literal text. - Locked, -} diff --git a/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs b/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs deleted file mode 100644 index 3848f636..00000000 --- a/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs +++ /dev/null @@ -1,578 +0,0 @@ -using System.Text; -using SharpMUTerm.Core.Media; -using SharpMUTerm.Core.Text; - -namespace SharpMUTerm.Core.Protocols; - -/// A server's entity changed: <!ENTITY>, <VAR> or an element's FLAG="set x". A null value is a deletion. -public sealed record MxpEntityChange(string Name, string? Value); - -/// The text of an element whose FLAG says what it is: RoomName, RoomDesc, RoomExit, RoomNum… -public sealed record MxpFlagText(string Flag, string Text); - -/// A <GAUGE> or <STAT>: show entity (out of ) on the status row. -public sealed record MxpGauge(string Entity, string? Max, string? Caption, bool IsStat); - -/// A <FRAME> the server opened or closed. -public sealed record MxpFrame(string Name, string? Title, bool Open); - -/// Text a <DEST> or a redirecting <FRAME> sent to a named window instead of the main one. -public sealed record MxpRedirect(string Frame, StyledLine Line); - -/// -/// What a server defines and declares over MXP: <!ELEMENT> and <!ENTITY>, <VAR>, -/// <GAUGE>/<STAT>, <SOUND>/<MUSIC>, and <FRAME>/<DEST>. -/// -/// -/// -/// Every one of these is a secure tag, so on an open line it is echoed as text like any other — a player -/// cannot define an element, set an entity or open a window. Definitions are parser state, so they last for the -/// connection and a reconnect, which builds a new parser, starts without them. -/// -/// -/// An element's own tags inherit the trust it was opened with. One opened on a secure line may contain -/// anything; one a player used on an open line, because the server defined it OPEN, may contain only -/// open tags, and anything else in its definition is dropped rather than echoed (the definition is the server's -/// text, not the player's). Attribute values are substituted with < and > taken out, so a -/// value cannot close the tag it lands in and open another. -/// -/// -/// Definitions are bounded so one cannot be used as a bomb: how many elements and entities a connection -/// may hold, how long a definition or value may be, how deep elements may nest inside one another, and how many -/// expansions a single line may cost. -/// -/// -public sealed partial class MxpParser -{ - /// The most elements one connection may define. - public const int MaxElements = 256; - - /// The most entities one connection may define. - public const int MaxEntities = 512; - - /// The longest element definition or entity value kept. - public const int MaxDefinitionLength = 1024; - - /// How deep an element may open other elements. - public const int MaxExpansionDepth = 4; - - /// How many element expansions one line may cost. - public const int MaxExpansionsPerLine = 64; - - /// The most windows one connection's frames may name. - public const int MaxFrames = 8; - - private const int MaxNameLength = 32; - - /// Why a tag was allowed, which an element's own tags inherit. - private enum Trust - { - None, - Open, - Secure, - } - - private sealed record Element( - string Name, - string Definition, - IReadOnlyList<(string Name, string? Default)> Attributes, - string? Flag, - int? LineTag, - bool Open, - bool Empty); - - private readonly Dictionary _elements = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _entities = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _frames = new(StringComparer.OrdinalIgnoreCase); - private string? _redirect; - private bool _markupThisLine; - private int _expansionDepth; - private int _expansionsThisLine; - - /// Raised when a server's entity is set or deleted. Not for one defined PRIVATE. - public event EventHandler? EntityChanged; - - /// Raised when a flagged element closes, with its text. - public event EventHandler? Flagged; - - /// Raised for each <GAUGE> or <STAT>. - public event EventHandler? GaugeDeclared; - - /// Raised for each <SOUND> or <MUSIC>, as the same request MSP and MCMP make. - public event EventHandler? MediaRequested; - - /// Raised when a <FRAME> opens or closes a window. - public event EventHandler? FrameDeclared; - - /// Raised with text a <DEST> or a redirect sent to another window. - public event EventHandler? Redirected; - - /// The value of a server entity, or null. For tests and the inspector. - public string? Entity(string name) => _entities.GetValueOrDefault(name); - - /// How many elements are defined. - public int ElementCount => _elements.Count; - - private void ResetDefinitions() - { - _elements.Clear(); - _entities.Clear(); - _frames.Clear(); - _redirect = null; - _markupThisLine = false; - _expansionDepth = 0; - _expansionsThisLine = 0; - LineSwallowed = false; - } - - /// The names no server may redefine: every tag this parser handles itself, and their spellings. - private static bool IsBuiltIn(string name) => - MxpTagCategory.IsOpen(name) || Canonical(name) is "B" or "I" or "U" or "S" or "COLOR" or "FONT" or "SEND" or "A" - or "BR" or "VERSION" or "SUPPORT" or "IMAGE" or "!ELEMENT" or "!ENTITY" or "VAR" or "GAUGE" or "STAT" - or "SOUND" or "MUSIC" or "FRAME" or "DEST" or "SUPPORTS" or "EXPIRE" or "RESET" or "P" or "NOBR"; - - private static bool IsName(string? name) => - name is { Length: > 0 and <= MaxNameLength } && char.IsLetter(name[0]) - && name.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-'); - - // ----- -------------------------------------------------------- - - /// <!ELEMENT name 'definition' [ATT='a b=default'] [TAG=n] [FLAG=flag] [OPEN] [DELETE] [EMPTY]>. - private void DefineElement(string attrs) - { - var parsed = ParseAttributes(attrs); - var name = Positional(parsed, 0); - if (!IsName(name) || IsBuiltIn(name!)) - { - return; - } - - if (HasFlag(parsed, "DELETE")) - { - _elements.Remove(name!); - return; - } - - var definition = Positional(parsed, 1) ?? string.Empty; - if (definition.Length > MaxDefinitionLength || (!_elements.ContainsKey(name!) && _elements.Count >= MaxElements)) - { - return; - } - - var attributes = (GetAttr(parsed, "ATT") ?? string.Empty) - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Take(16) - .Select(a => a.Split('=', 2) is var kv && kv.Length == 2 ? (kv[0], (string?)kv[1]) : (a, null)) - .Where(a => IsName(a.Item1)) - .ToArray(); - int? lineTag = int.TryParse(GetAttr(parsed, "TAG"), out var tag) && tag is >= 20 and <= 99 ? tag : null; - var flag = GetAttr(parsed, "FLAG") is { Length: > 0 and <= 64 } f ? f : null; - - _elements[name!] = new Element( - Canonical(name!), definition, attributes, flag, lineTag, HasFlag(parsed, "OPEN"), HasFlag(parsed, "EMPTY")); - } - - /// - /// Opens an element: a frame of its own, then the tags of its definition with its attributes filled in. - /// Its closing tag closes them all, because their frames sit above its own on the stack. - /// - private void OpenElement(Element element, string attrs, Trust trust, bool endsWithLine) - { - if (_expansionDepth >= MaxExpansionDepth || _expansionsThisLine >= MaxExpansionsPerLine) - { - return; - } - - _expansionsThisLine++; - var values = ElementValues(element, ParseAttributes(attrs)); - var start = _stack.Count; - PushFrame( - element.Name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, - flag: element.Empty ? null : element.Flag, endsWithLine: endsWithLine); // an empty element has no text to flag - - _expansionDepth++; - try - { - foreach (var tag in DefinitionTags(Substitute(element.Definition, values))) - { - ProcessInnerTag(tag, trust); - } - } - finally - { - _expansionDepth--; - } - - if (element.Empty) - { - FlushRun(); - CloseFramesFrom(start); - } - } - - /// What each of an element's attributes is this time: given by name, by position, or its default. - private static Dictionary ElementValues(Element element, List<(string? Key, string Value)> given) - { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < element.Attributes.Count; i++) - { - var (name, fallback) = element.Attributes[i]; - var value = GetAttr(given, name) ?? Positional(given, i) ?? fallback ?? string.Empty; - values[name] = value.Replace("<", string.Empty, StringComparison.Ordinal).Replace(">", string.Empty, StringComparison.Ordinal); - } - - return values; - } - - /// The definition with each &attribute; replaced; any other entity is left for the tag to read. - private static string Substitute(string definition, Dictionary values) - { - if (values.Count == 0 || !definition.Contains('&', StringComparison.Ordinal)) - { - return definition; - } - - var sb = new StringBuilder(definition.Length); - var i = 0; - while (i < definition.Length) - { - var semi = definition[i] == '&' ? definition.IndexOf(';', i + 1) : -1; - if (semi > i && values.TryGetValue(definition[(i + 1)..semi], out var value)) - { - sb.Append(value); - i = semi + 1; - if (sb.Length > MaxDefinitionLength * 2) - { - break; - } - - continue; - } - - sb.Append(definition[i++]); - } - - return sb.ToString(); - } - - /// The tag bodies in a definition, in order. Text between them is not part of an element. - private static IEnumerable DefinitionTags(string definition) - { - var i = 0; - while ((i = definition.IndexOf('<', i)) >= 0) - { - var end = i + 1; - char? quote = null; - while (end < definition.Length && (quote is not null || definition[end] != '>')) - { - if (definition[end] is '"' or '\'') - { - quote = quote == definition[end] ? null : quote ?? definition[end]; - } - - end++; - } - - if (end >= definition.Length) - { - yield break; - } - - yield return definition[(i + 1)..end]; - i = end + 1; - } - } - - /// - /// One tag of an element's definition. It inherits the element's trust rather than asking the line, and a - /// tag that trust does not allow is dropped, not echoed. Closing tags in a definition are ignored: the - /// element's own closing tag closes everything it opened. - /// - private void ProcessInnerTag(string raw, Trust trust) - { - var trimmed = raw.Trim().TrimEnd('/').TrimEnd(); - if (trimmed.Length == 0 || trimmed[0] == '/') - { - return; - } - - var nameEnd = 0; - while (nameEnd < trimmed.Length && !char.IsWhiteSpace(trimmed[nameEnd])) - { - nameEnd++; - } - - var name = Canonical(trimmed[..nameEnd]); - var allowed = trust == Trust.Secure - || MxpTagCategory.IsOpen(name) - || (_elements.TryGetValue(name, out var inner) && inner.Open); - - // A definition never defines: an element that wrote into itself would grow the table each use. - if (!allowed || name is "!ELEMENT" or "!ENTITY") - { - return; - } - - Open(name, nameEnd < trimmed.Length ? trimmed[(nameEnd + 1)..] : string.Empty, trust); - } - - /// - /// ESC[#z for 10–12 and 20–99: the line's text is a room name, description or exits (the spec's own - /// numbers), or an element the server tied to the number with TAG=. Either way it ends with the line. - /// Line tags come only from the server, so the element opens with secure trust. - /// - private void OpenLineTagElement(int tag) - { - if (tag is >= 10 and <= 12) - { - FlushRun(); - PushFrame( - "LINETAG", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, - flag: tag switch { 10 => "RoomName", 11 => "RoomDesc", _ => "RoomExit" }, endsWithLine: true); - return; - } - - if (_elements.Values.FirstOrDefault(e => e.LineTag == tag) is { } element) - { - FlushRun(); - OpenElement(element, string.Empty, Trust.Secure, endsWithLine: true); - } - } - - // ----- , and FLAG --------------------------------------------- - - /// <!ENTITY name value [DESC=] [PRIVATE] [PUBLISH] [DELETE] [ADD] [REMOVE]>. - private void DefineEntity(string attrs) - { - var parsed = ParseAttributes(attrs); - var name = Positional(parsed, 0); - if (!IsName(name) || ResolveEntity(name!) is not null || string.Equals(name, "text", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var priv = HasFlag(parsed, "PRIVATE"); - if (HasFlag(parsed, "DELETE")) - { - SetEntity(name!, null, priv); - return; - } - - var value = Positional(parsed, 1) ?? string.Empty; - var held = _entities.GetValueOrDefault(name!); - if (HasFlag(parsed, "ADD")) - { - value = string.IsNullOrEmpty(held) ? value : held + "|" + value; - } - else if (HasFlag(parsed, "REMOVE")) - { - value = string.Join('|', (held ?? string.Empty).Split('|').Where(v => v != value)); - } - - SetEntity(name!, value, priv); - } - - /// Sets or deletes an entity, within the caps, and says so unless it is private. - private void SetEntity(string name, string? value, bool isPrivate) - { - if (value is null) - { - if (_entities.Remove(name) && !isPrivate) - { - EntityChanged?.Invoke(this, new MxpEntityChange(name, null)); - } - - return; - } - - if (value.Length > MaxDefinitionLength || (!_entities.ContainsKey(name) && _entities.Count >= MaxEntities)) - { - return; - } - - _entities[name] = value; - if (!isPrivate) - { - EntityChanged?.Invoke(this, new MxpEntityChange(name, value)); - } - } - - /// A server entity's value for &name; in text, or null. Built-in entities are read first. - private string? UserEntity(string name) => _entities.GetValueOrDefault(name); - - /// <VAR name [PRIVATE]>text</VAR>: the text is shown, and stored as the entity. - private void OpenVar(string attrs) - { - var parsed = ParseAttributes(attrs); - if (Positional(parsed, 0) is not { } name || !IsName(name)) - { - return; - } - - PushFrame( - "VAR", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, - flag: (HasFlag(parsed, "PRIVATE") ? "private " : "set ") + name); - } - - /// - /// What a flagged or redirected frame does with its text: at a line boundary it banks this line's share (or, - /// for a <DEST>, sends it); on it finishes. - /// - private void CompleteDefinitionFrame(Frame frame, bool closing) - { - if (frame.Destination is { } destination) - { - FlushRun(); - if (frame.SpanStart < _lineSpans.Count) - { - var spans = _lineSpans.GetRange(frame.SpanStart, _lineSpans.Count - frame.SpanStart); - _lineSpans.RemoveRange(frame.SpanStart, spans.Count); - Redirected?.Invoke(this, new MxpRedirect(destination, new StyledLine(spans))); - } - - return; - } - - if (frame.Flag is not { } flag || frame.Captured is not { } captured) - { - return; - } - - FlushRun(); - for (var i = frame.SpanStart; i < _lineSpans.Count; i++) - { - captured.Append(_lineSpans[i].Text); - } - - if (!closing) - { - captured.Append('\n'); - return; - } - - var text = captured.ToString().TrimEnd('\n'); - captured.Clear(); - if (flag.StartsWith("set ", StringComparison.OrdinalIgnoreCase) || flag.StartsWith("private ", StringComparison.OrdinalIgnoreCase)) - { - var space = flag.IndexOf(' ', StringComparison.Ordinal); - var name = flag[(space + 1)..].Trim(); - if (IsName(name)) - { - SetEntity(name, text, isPrivate: flag[0] is 'p' or 'P'); - } - - return; - } - - Flagged?.Invoke(this, new MxpFlagText(flag, text)); - } - - // ----- , , , --------------------------------------- - - /// <GAUGE entity [MAX=entity] [CAPTION=text] [COLOR=colour]>, and <STAT> the same. - private void DeclareGauge(string attrs, bool isStat) - { - var parsed = ParseAttributes(attrs); - var entity = GetAttr(parsed, "ENTITY") ?? Positional(parsed, 0); - var max = GetAttr(parsed, "MAX") ?? Positional(parsed, 1); - if (!IsName(entity) || (max is not null && !IsName(max))) - { - return; - } - - var caption = GetAttr(parsed, "CAPTION") ?? Positional(parsed, 2); - GaugeDeclared?.Invoke(this, new MxpGauge(entity!, max, caption is { Length: > 0 } ? caption[..Math.Min(caption.Length, 64)] : null, isStat)); - } - - /// <SOUND fname V= L= P= T= U=> and <MUSIC fname V= L= C= T= U=>, MSP's fields as attributes. - private void RequestMedia(MediaKind kind, string attrs) - { - var parsed = ParseAttributes(attrs); - if ((GetAttr(parsed, "FNAME") ?? Positional(parsed, 0)) is not { Length: > 0 and <= 256 } name) - { - return; - } - - var fields = new Dictionary(); - foreach (var (key, value) in parsed) - { - if (key is { Length: 1 }) - { - fields[char.ToUpperInvariant(key[0])] = value; - } - } - - MediaRequested?.Invoke(this, MspTrigger.FromFields(kind, name, fields)); - } - - /// A frame's name as a window: one of this connection's few, or null for the main window. - private string? FrameName(string? name) - { - if (name is null || name.Equals("_top", StringComparison.OrdinalIgnoreCase) || name.Equals("_previous", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - if (!IsName(name)) - { - return null; - } - - if (_frames.Contains(name)) - { - return name; - } - - if (_frames.Count >= MaxFrames) - { - return null; - } - - _frames.Add(name); - return name; - } - - /// - /// <FRAME name [ACTION=OPEN|CLOSE|REDIRECT] [TITLE=…]>. A frame is a window; OPEN asks for one, - /// CLOSE closes it, and REDIRECT sends every following line there until a redirect to _top. - /// Layout attributes (ALIGN, LEFT, WIDTH…) are read and ignored: where a window goes is the player's. - /// - private void DeclareFrame(string attrs) - { - var parsed = ParseAttributes(attrs); - var raw = GetAttr(parsed, "NAME") ?? Positional(parsed, 0); - var action = (GetAttr(parsed, "ACTION") ?? (HasFlag(parsed, "REDIRECT") ? "REDIRECT" : "OPEN")).ToUpperInvariant(); - if (action == "REDIRECT") - { - _redirect = FrameName(raw); - return; - } - - if (FrameName(raw) is not { } name) - { - return; - } - - if (action == "CLOSE") - { - _frames.Remove(name); - if (string.Equals(_redirect, name, StringComparison.OrdinalIgnoreCase)) - { - _redirect = null; - } - } - - var title = GetAttr(parsed, "TITLE") is { Length: > 0 } t ? t[..Math.Min(t.Length, 64)] : null; - FrameDeclared?.Invoke(this, new MxpFrame(name, title, action != "CLOSE")); - } - - /// <DEST name>text</DEST>: the text goes to that window and not to this line. - private void OpenDest(string attrs) - { - var parsed = ParseAttributes(attrs); - var name = FrameName(GetAttr(parsed, "NAME") ?? Positional(parsed, 0)); - - // A DEST to the main window, or past the cap, is a formatting no-op: its text stays where it is. - PushFrame("DEST", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, destination: name); - } -} diff --git a/src/SharpMUTerm.Core/Protocols/MxpParser.cs b/src/SharpMUTerm.Core/Protocols/MxpParser.cs deleted file mode 100644 index bad7ce33..00000000 --- a/src/SharpMUTerm.Core/Protocols/MxpParser.cs +++ /dev/null @@ -1,1682 +0,0 @@ -using System.Globalization; -using System.Text; -using SharpMUTerm.Core.Telnet; -using SharpMUTerm.Core.Text; - -namespace SharpMUTerm.Core.Protocols; - -/// -/// Incremental, stateful, line-oriented parser for the MUD eXtension Protocol (MXP). -/// Feed it decoded text (any number of chunks); it emits fully-terminated -/// s and retains the in-progress line, the current -/// , and the stack of open tags across calls, so a tag -/// (<...>) or entity (&...;) may straddle a chunk boundary. -/// -/// Mirrors the shape of : returns the -/// lines completed within the chunk, yields a buffered partial line -/// (e.g. an unterminated prompt), and colour/attribute state is preserved between calls. -/// -/// -/// is the line boundary, not '\n'. The telnet layer strips the -/// terminator before this parser sees it, so WorldSession feeds a line's text and then flushes; -/// a rule keyed on a newline in the text never fires on a real connection. is -/// the one place the end of a line is defined, and both routes go through it. -/// -/// -/// Scope notes for v1: -/// -/// The line-security model (ESC[#z line tags, open/secure/locked, RESET, TEMP SECURE -/// and the three LOCK modes) is implemented — see . A secure tag on -/// an open line is emitted as literal text rather than honoured, which is what stops a player's -/// <SEND> becoming a clickable command in someone else's client. -/// <IMAGE> becomes a [image: name] span carrying an -/// on its interaction; drawing the picture is the UI's job, and the -/// span is what a terminal that cannot draw one shows. It is a secure tag, so a player cannot make -/// another client fetch a URL of their choosing. -/// ANSI SGR is decoded here, through , because the -/// spec permits ANSI inside MXP and nothing upstream strips it — a session runs this parser -/// or , never both. Other CSI sequences are -/// consumed and discarded. -/// Unknown/unsupported tags (<VAR>, <EXPIRE>, <H1>, -/// <P>, …) are consumed and discarded on a line allowed to use them. They are all -/// secure tags, so on an open line they are echoed literally like any other refused tag rather -/// than silently dropped. -/// A stray < that cannot begin a tag, or a stray & that cannot begin -/// an entity, is emitted literally (xterm-like leniency). Tag/entity buffers are length -/// capped to avoid runaway on malformed input. -/// -/// -public sealed partial class MxpParser : ILineParser -{ - private const int MaxTagLength = 4096; - private const int MaxEntityLength = 32; - - private enum Mode - { - Text, - Tag, - Entity, - Escape, - EscapeIntermediate, - Csi, - Osc, - OscEscape, - } - - private const int MaxSequenceLength = 128; - - /// A single open MXP element on the tag stack. - private sealed record Frame - { - public required string Name { get; init; } - public TextStyle SavedStyle { get; init; } - public SpanInteraction? SavedInteraction { get; init; } - public bool IsInteraction { get; init; } - public bool IsLink { get; init; } - public bool DeferCommand { get; init; } - public string? Hint { get; init; } - public bool PromptOnly { get; init; } - public int SpanStart { get; init; } - - /// A <SEND>'s commands as written, when one holds &text; to fill in at the close. - public IReadOnlyList? Templates { get; init; } - - /// The menu labels a <SEND>'s hint gave its commands, if it gave any. - public IReadOnlyList? Labels { get; init; } - - /// An element's FLAG: what its text is (RoomName…), or set name to store it. - public string? Flag { get; init; } - - /// A <DEST>'s window: the frame's text is cut from the line and delivered there. - public string? Destination { get; init; } - - /// Closed at the end of its line whatever the mode: opened by an ESC[#z line tag. - public bool EndsWithLine { get; init; } - - /// The text of earlier lines, for a flagged frame that spans a line boundary. - public StringBuilder? Captured { get; init; } - - /// - /// True when the element was opened while the line was in — - /// i.e. it is one of the spec's "tags that were used while in open mode", the only ones it - /// closes automatically. Secure tags are "never automatically closed", so this is what keeps - /// a server's own markup spanning lines while a player's is bounded by theirs. - /// - public bool OpenedInOpenMode { get; init; } - } - - private Mode _mode = Mode.Text; - - /// The mode a line starts in; moved only by the three LOCK line tags and by RESET. - private MxpLineMode _defaultMode = MxpLineMode.Open; - - /// The mode in force right now; reverts to at each newline. - private MxpLineMode _lineMode = MxpLineMode.Open; - - /// Set by TEMP SECURE (ESC[4z) and consumed by the very next tag. - private bool _tempSecure; - - private TextStyle _current = TextStyle.Default; - private SpanInteraction? _interaction; - private readonly StringBuilder _run = new(); - private readonly StringBuilder _tag = new(); - - // The quote an attribute value inside the tag being read is open in, or '\0'. A '>' inside one does not - // end the tag: an definition carries markup in a quoted value. - private char _tagQuote; - private readonly StringBuilder _entity = new(); - private readonly StringBuilder _seq = new(); - private readonly List _lineSpans = new(); - private readonly List _stack = new(); - private List? _emit; - - /// - /// Blanks still owed to a reserved picture's label (): the spaces the server - /// left for the picture that the label now stands in, so the line keeps the width the server laid it - /// out at. Zero when nothing is owed. - /// - private int _owedBlanks; - - /// Where in the label owed those blanks sits. - private int _owedBy = -1; - - /// The rendition state that will apply to the next printed character. - public TextStyle CurrentStyle => _current; - - /// - /// A line this parser owes the server — the answer to a <VERSION> or - /// <SUPPORT> request. An event rather than a return value because it is not output: - /// nothing about it belongs in the scrollback, and the session sends it as a line. - /// - public event EventHandler? ClientReply; - - /// What this client answers a VERSION request with. - public const string ClientName = GmcpHandshake.ClientName; - - /// The MXP revision a VERSION reply names (see ). - public const string MxpRevision = "1.0"; - - /// The style-sheet version a VERSION reply names: none is ever in effect. - public const string StyleVersion = "0"; - - /// The client version a VERSION reply names: this build's, as MinVer stamped it. - public static string ClientVersion => GmcpHandshake.ClientVersion; - - /// - /// Spec: "the response is sent as a secure-tagged line: <ESC>[1z prefix with newline - /// suffix, ensuring the MUD recognizes it as client-generated rather than player-controlled input." - /// Only the prefix belongs here — WorldSession.SendRawAsync's send path - /// (TelnetSession.SendLineAsync, whose own doc comment says the terminator is the library's - /// to add) already appends the line terminator, so adding one here would double it, exactly the bug - /// that doc comment warns against for a plain command. - /// - private const string SecureLinePrefix = "\x1b[1z"; - - /// - /// The tags this parser genuinely implements, by name. The telnet layer answers a server's - /// <SUPPORT> from this list too (), because the library takes - /// that line out of the stream before this parser can see it. - /// - /// - /// An honest list, and deliberately not an aspirational one: a SUPPORTS answer is a claim a - /// server acts on, so naming a tag we ignore makes it send markup we will render as text. Add to - /// this only when the handler exists — the same rule the MTTS bit vector is held to. - /// H/HIGH is on 's open allow-list but - /// has no case for it — it falls to the default and is silently - /// dropped — so it is deliberately not claimed here. - /// - public static IReadOnlyList SupportedTags { get; } = - ["b", "i", "u", "s", "color", "font", "send", "a", "br", "image", - "!element", "!entity", "var", "gauge", "stat", "sound", "music", "frame", "dest"]; - - /// - /// The attributes a handler here reads, by tag, for a <SUPPORT tag.attr> or - /// <SUPPORT tag.*>. The same honesty rule as : an attribute - /// is listed only when its handler acts on it, so image.ismap, read and ignored, is not. - /// A supported tag missing here has no attribute this client claims. - /// - public static IReadOnlyDictionary SupportedAttributes { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["color"] = ["fore", "back"], - ["send"] = ["href", "hint", "prompt"], - ["a"] = ["href", "hint"], - ["image"] = ["fname", "url", "t", "w", "h"], - ["gauge"] = ["entity", "max", "caption"], - ["stat"] = ["entity", "max", "caption"], - ["sound"] = ["fname", "v", "l", "p", "t", "u"], - ["music"] = ["fname", "v", "l", "c", "t", "u"], - ["frame"] = ["name", "action", "title"], - ["dest"] = ["name"], - }; - - /// - /// The answer to a <SUPPORT> that asked about : every tag in - /// when it asked about nothing, otherwise each entry it named, as one this - /// client renders or one it does not. tag.attr is supported when - /// lists it, and tag.* is answered with each attribute listed for the tag, as the specification - /// asks; a tag this client does not render is answered -tag.*. - /// - public static (IReadOnlyList Supported, IReadOnlyList Unsupported) AnswerSupport(IReadOnlyList asked) - { - if (asked.Count == 0) - { - return (SupportedTags, []); - } - - var supported = new List(); - var unsupported = new List(); - foreach (var entry in asked) - { - var name = entry.Trim('"', '\''); - if (name.Length == 0) - { - continue; - } - - var dot = name.IndexOf('.', StringComparison.Ordinal); - if (dot < 0) - { - (SupportedTags.Contains(name, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name); - continue; - } - - var tag = name[..dot]; - var attribute = name[(dot + 1)..]; - var known = SupportedTags.Contains(tag, StringComparer.OrdinalIgnoreCase); - var attributes = known ? SupportedAttributes.GetValueOrDefault(tag) ?? [] : []; - if (attribute == "*") - { - if (known) - { - supported.AddRange(attributes.Select(a => $"{tag}.{a}")); - } - else - { - unsupported.Add(name); - } - } - else - { - (attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name); - } - } - - return (supported, unsupported); - } - - /// True when a partial line, an open tag/entity, or unclosed markup is buffered. - public bool HasPendingContent => - _run.Length > 0 || _lineSpans.Count > 0 || _mode != Mode.Text; - - /// Feeds a chunk of text, returning every line completed by a newline (or <BR>) within it. - public IReadOnlyList Feed(string text) - { - ArgumentNullException.ThrowIfNull(text); - return Feed(text.AsSpan()); - } - - /// Feeds a chunk of text, returning every line completed by a newline (or <BR>) within it. - public IReadOnlyList Feed(ReadOnlySpan text) - { - _emit = null; - foreach (var ch in text) - { - Process(ch); - } - - var result = (IReadOnlyList?)_emit ?? Array.Empty(); - _emit = null; - return result; - } - - /// - /// Returns the buffered partial line (e.g. a prompt not terminated by a newline) and clears it, - /// or null if nothing is buffered. - /// - /// - /// This is the line boundary, not just a drain: the telnet layer strips the terminator, so - /// on a real connection a line arrives as Feed(text) with no '\n' in it followed by - /// this call. Everything the end of a line does — the security mode reverting to the default, the - /// spec's auto-close of open-mode tags, finalising an open interaction, abandoning an unterminated - /// escape sequence — therefore happens here. See . - /// - public StyledLine? Flush() - { - EndLine(); - var markup = _markupThisLine; - _markupThisLine = false; - LineSwallowed = false; - if (_lineSpans.Count == 0) - { - // A line that was all markup — definitions, a VERSION request, a DEST that took everything — is - // not a blank line the server sent, and a session should not print one for it. - LineSwallowed = markup; - return null; - } - - var line = new StyledLine(_lineSpans); - _lineSpans.Clear(); - if (_redirect is { } frame) - { - Redirected?.Invoke(this, new MxpRedirect(frame, line)); - LineSwallowed = true; - return null; - } - - return line; - } - - /// - /// Whether the line the last ended was taken entirely by markup or sent to another - /// window, so that its returning null is not a blank line. - /// - public bool LineSwallowed { get; private set; } - - /// Resets all parser state, including the current style and the open-tag stack. - public void Reset() - { - _mode = Mode.Text; - _defaultMode = MxpLineMode.Open; - _lineMode = MxpLineMode.Open; - _tempSecure = false; - _owedBlanks = 0; - _owedBy = -1; - _current = TextStyle.Default; - _interaction = null; - _run.Clear(); - _tag.Clear(); - _tagQuote = '\0'; - _entity.Clear(); - _seq.Clear(); - _lineSpans.Clear(); - _stack.Clear(); - ResetDefinitions(); - } - - private void Process(char ch) - { - // A newline ends the line whatever sequence is in flight. Without this an unterminated - // string escape — a bare ESC ] , ESC P, ESC X, ESC ^ or ESC _ , which a *player* can type - // into a public channel — consumes every following character, line boundaries included, - // until a BEL or ST it need never send: the rest of the session's output disappears. CSI - // already treats a control byte as malformed, and a two-byte escape would otherwise swallow - // the newline as its second byte; routing all of them through here makes one rule of it. - // AnsiParser carries the same three lines — the two escape state machines are near - // duplicates and are deliberately not consolidated, so a fix to one belongs in both. - if (ch == '\n' && IsEscapeMode(_mode)) - { - EndSequence(); - } - - switch (_mode) - { - case Mode.Text: - ProcessText(ch); - break; - - case Mode.Tag: - ProcessTagChar(ch); - break; - - case Mode.Entity: - ProcessEntityChar(ch); - break; - - case Mode.Escape: - ProcessEscape(ch); - break; - - case Mode.EscapeIntermediate: - // Consume the single trailing byte of e.g. ESC ( B and return to text. - EndSequence(); - break; - - case Mode.Csi: - ProcessCsi(ch); - break; - - case Mode.Osc: - ProcessOsc(ch); - break; - - case Mode.OscEscape: - // Inside an OSC string we saw ESC; a following '\' is the ST terminator. - if (ch == '\\') - { - EndSequence(); - } - else - { - _mode = Mode.Osc; - } - - break; - } - } - - /// True while the parser is part-way through an escape sequence rather than reading text. - private static bool IsEscapeMode(Mode mode) => - mode is Mode.Escape or Mode.EscapeIntermediate or Mode.Csi or Mode.Osc or Mode.OscEscape; - - private void ProcessText(char ch) - { - // Spec: TEMP SECURE "must be immediately followed by a '<' character to start a tag." That - // "immediately" is the whole of it — an arming that survives the server's own prose is spent - // by the first tag a *player* wrote into that prose, and - // "\x1b[4zRivane says, 'click'" becomes a clickable - // @shutdown on a line the server never secured. - // - // ESC is the sole exception, because it may still be resolving a line tag; every escape that - // turns out to be something else disarms for itself in . On a locked - // line no character starts a tag, not even '<', so there every character disarms — which is - // what closes the ESC[7z … ESC[4z … ESC[0z route through the unlock. - if (_tempSecure && ch != '\x1b' && (ch != '<' || _lineMode == MxpLineMode.Locked)) - { - _tempSecure = false; - } - - // A locked line is not parsed: "no MXP or HTML commands are allowed in the line. The line is - // not parsed for any tags at all." Newline still ends the line, or nothing ever would — and - // ESC is exempt because ESC[#z is the only way a server can leave locked mode again, which - // the NukeFire prompt does between every one of its own tags. - if (_lineMode == MxpLineMode.Locked && ch != '\n' && ch != '\r' && ch != '\x1b') - { - _run.Append(ch); - return; - } - - switch (ch) - { - case '<': - _tag.Clear(); - _tagQuote = '\0'; - _mode = Mode.Tag; - break; - - case '&': - _entity.Clear(); - _mode = Mode.Entity; - break; - - case '\n': - CompleteLine(); - break; - - case '\r': - // Carriage returns are dropped; scrollback is line-based. - break; - - case '\x1b': - _seq.Clear(); - _mode = Mode.Escape; - break; - - default: - // Anything else is literal text. - if (_owedBlanks > 0) - { - if (ch == ' ') - { - PayBlank(); - break; - } - - _owedBlanks = 0; - } - - _run.Append(ch); - break; - } - } - - /// - /// Takes one of the blanks a reserved picture's label stands in for. When the last is taken the server - /// has been shown to have left the cells, and the label's request says so. - /// - private void PayBlank() - { - if (--_owedBlanks > 0 || _owedBy < 0 || _owedBy >= _lineSpans.Count) - { - return; - } - - var label = _lineSpans[_owedBy]; - if (label.Interaction is { Image: { } request } interaction) - { - _lineSpans[_owedBy] = new StyledSpan(label.Text, label.Style, - interaction with { Image = request with { Reserved = true } }); - } - - _owedBy = -1; - } - - private void ProcessEscape(char ch) - { - switch (ch) - { - case '[': - _seq.Clear(); - _mode = Mode.Csi; - break; - - case ']': - _seq.Clear(); - _mode = Mode.Osc; - break; - - case '(': - case ')': - case '*': - case '+': - case '#': - case '%': - _mode = Mode.EscapeIntermediate; - break; - - case 'P': // DCS - case 'X': // SOS - case '^': // PM - case '_': // APC - // String sequences terminated by ST (ESC \) — consume like an OSC so their - // payloads never leak into the output as text. - _seq.Clear(); - _mode = Mode.Osc; - break; - - default: - // Two-byte escape (ESC c, ESC M, ...) — consumed and ignored. It is not a line tag, - // so like every other non-ESC[#z sequence it disarms a pending TEMP SECURE. - EndSequence(); - break; - } - } - - private void ProcessCsi(char ch) - { - if (ch is >= '\x40' and <= '\x7e') - { - if (ch == 'z') - { - // The MXP line tag — the one CSI sequence that changes what this parser trusts, and - // the only one that may leave a pending TEMP SECURE armed. - ApplyLineTag(_seq.ToString()); - _seq.Clear(); - _mode = Mode.Text; - return; - } - - if (ch == 'm') - { - // Text accumulated so far keeps the pre-change style. - FlushRun(); - _current = SgrCodes.Apply(_current, _seq.ToString()); - } - - // Every other final byte — cursor movement, erase — is discarded, which is what a - // line-oriented view can do with it. - EndSequence(); - return; - } - - if (ch is >= '\x20' and <= '\x3f') - { - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - return; - } - - // A control character inside the sequence aborts it as malformed. - EndSequence(); - } - - /// - /// Returns to text from an escape sequence that was not an ESC[#z line tag. - /// TEMP SECURE requires the very next thing to be a <, so anything else the server put - /// in between disarms it — see . - /// - private void EndSequence() - { - _tempSecure = false; - _seq.Clear(); - _mode = Mode.Text; - } - - /// - /// Applies an ESC[#z line tag. An unparseable or unknown number is ignored rather than - /// guessed at: a mode this client invents is a mode the server did not ask for, and the - /// consequences run in the direction of trusting text that was not meant to be trusted. - /// - private void ApplyLineTag(string parameters) - { - // NumberStyles.None: digits and nothing else. Integer would accept a sign and surrounding - // whitespace, and a spelling the spec does not have is a spelling nothing should act on. - var known = int.TryParse(parameters, NumberStyles.None, CultureInfo.InvariantCulture, out var tag); - - // Only ESC[4z itself leaves a pending TEMP SECURE armed. Every other line tag — and every - // ESC[#z this client cannot read — is "something other than a '<'", so it disarms, which is - // the same rule applies to the sequences that do not come through - // here. Defence in depth rather than a reachable hole: only a server can emit a line tag, and - // one that can emit ESC[0z can emit ESC[1z and skip the mechanism entirely. - if (!known || tag != 4) - { - _tempSecure = false; - } - - if (!known) - { - return; - } - - switch (tag) - { - case 0: SetLineMode(MxpLineMode.Open); break; - case 1: SetLineMode(MxpLineMode.Secure); break; - case 2: SetLineMode(MxpLineMode.Locked); break; - case 3: ApplyReset(); break; - case 4: _tempSecure = true; break; - case 5: SetLineMode(MxpLineMode.Open); _defaultMode = MxpLineMode.Open; break; - case 6: SetLineMode(MxpLineMode.Secure); _defaultMode = MxpLineMode.Secure; break; - case 7: SetLineMode(MxpLineMode.Locked); _defaultMode = MxpLineMode.Locked; break; - case >= 10 and <= 99: OpenLineTagElement(tag); break; - } - } - - /// - /// Moves the current line's mode, honouring the spec's first auto-close rule: "when the mode is - /// changed from OPEN mode to any other mode, any unclosed OPEN tags (tags that were used while in - /// open mode) are automatically closed." - /// - /// - /// This is the spec's own bound on how far player-authored markup reaches, and it is why the spec - /// is willing to call COLOR an open tag at all: without it a - /// <COLOR FORE=black BACK=black> typed into a public channel paints the rest of the - /// session black on black, and the tag stack grows without bound. TEMP SECURE is deliberately not - /// a mode change — it arms the next tag and leaves alone. - /// - private void SetLineMode(MxpLineMode mode) - { - if (_lineMode == MxpLineMode.Open && mode != MxpLineMode.Open) - { - CloseOpenModeTags(); - } - - _lineMode = mode; - } - - /// - /// Closes every still-open tag that was opened while the line was in OPEN mode. - /// - /// - /// Done by closing the outermost such frame: everything nested inside it goes with it, - /// which is the same rule applies to an explicit closer, and it is the only - /// rule that can restore a coherent style — a secure tag opened inside a player's open one - /// cannot outlive it, however the spec words the exemption. - /// - private void CloseOpenModeTags() - { - for (var i = 0; i < _stack.Count; i++) - { - if (_stack[i].OpenedInOpenMode) - { - FlushRun(); - CloseFramesFrom(i); - return; - } - } - } - - /// Spec: "close all open tags. Set mode to Open. Set text color and properties to default." - private void ApplyReset() - { - FlushRun(); - CloseInteractionsAtBoundary(); - _stack.Clear(); - _current = TextStyle.Default; - _interaction = null; - _tempSecure = false; - _defaultMode = _lineMode = MxpLineMode.Open; - } - - /// - /// Whether a tag may act on this line, per the mode — and, if a TEMP SECURE arming is what allows - /// it, spends that arming. Named for the side effect rather than for the answer, because a - /// reader who takes this for a pure predicate will eventually add a second call. - /// - /// - /// The whole TEMP SECURE rule is "the next tag, and only the next tag", so its correctness is - /// exactly the property that this runs once per tag. It does today — ProcessTag calls it - /// once on the closing path and once on the opening one, and those are the only call sites. A - /// pre-check, a log line or an assertion added later would silently spend the arming and turn a - /// server-secured tag into a refused one, and there is no test that could name that mistake - /// because the tag simply renders as text. - /// - /// - /// The canonical element name with any leading slash already stripped. Canonical is safe to gate - /// on because only ever folds an open tag's alternative spellings onto - /// another open tag (BOLD → B, C → COLOR); no secure tag can canonicalise into the allow-list. - /// - private bool ConsumeAuthorizationFor(string name) => Authorize(name) != Trust.None; - - /// - /// , saying why a tag is allowed: a secure line (or TEMP - /// SECURE) allows anything, an open one allows the open tags and the elements a server defined - /// OPEN. The difference matters to an element's own tags, which inherit the trust it was opened with. - /// - private Trust Authorize(string name) - { - if (_lineMode == MxpLineMode.Secure || _tempSecure) - { - // TEMP SECURE is spent by the next tag whatever that tag turns out to be. - _tempSecure = false; - return Trust.Secure; - } - - return MxpTagCategory.IsOpen(name) || (_elements.TryGetValue(name, out var element) && element.Open) ? Trust.Open : Trust.None; - } - - private void ProcessOsc(char ch) - { - switch (ch) - { - case '\x07': // BEL terminator - EndSequence(); - break; - - case '\x1b': // possible ST (ESC \) - _mode = Mode.OscEscape; - break; - - default: - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - break; - } - } - - private void ProcessTagChar(char ch) - { - if (_tag.Length == 0) - { - // Decide whether the '<' actually begins a tag. - if (ch == '>') - { - // Empty "<>" — not a tag; emit literally. - _run.Append('<').Append('>'); - _mode = Mode.Text; - return; - } - - if (!(char.IsLetter(ch) || ch == '/' || ch == '!')) - { - // Stray '<' (e.g. "a < b") — emit it literally and reprocess this char. - _run.Append('<'); - _mode = Mode.Text; - ProcessText(ch); - return; - } - - _tag.Append(ch); - return; - } - - switch (ch) - { - case '>' when _tagQuote == '\0': - // Back in text before the tag runs: a
ends the line, and the end of a line abandons a tag - // still being read. - var tag = _tag.ToString(); - _tag.Clear(); - _mode = Mode.Text; - ProcessTag(tag); - break; - - case '\n': - // A newline inside a "tag" means it was never a tag; bail out literally. - AbandonTag(); - ProcessText('\n'); - break; - - default: - if (_tag.Length >= MaxTagLength) - { - AbandonTag(); - ProcessText(ch); - return; - } - - if (ch is '"' or '\'') - { - // A quote opens only where a value can start, so the apostrophe in "" opens nothing. - if (_tagQuote == '\0' && (_tag[^1] == '=' || char.IsWhiteSpace(_tag[^1]))) - { - _tagQuote = ch; - } - else if (_tagQuote == ch) - { - _tagQuote = '\0'; - } - } - - _tag.Append(ch); - break; - } - } - - /// What was read as a tag was never one: it goes out as the text it was. - private void AbandonTag() - { - _run.Append('<').Append(_tag); - _tag.Clear(); - _tagQuote = '\0'; - _mode = Mode.Text; - } - - private void ProcessEntityChar(char ch) - { - if (ch == ';') - { - var content = _entity.ToString(); - _entity.Clear(); - _mode = Mode.Text; - - var replacement = ResolveEntity(content) ?? UserEntity(content); - if (replacement is not null) - { - // An entity is text like any other:   pays a blank a picture's label owes, anything else ends the debt. - if (_owedBlanks > 0) - { - if (replacement == " ") - { - PayBlank(); - return; - } - - _owedBlanks = 0; - } - - _run.Append(replacement); - } - else - { - // Unknown entity — emit the raw text so nothing is lost. - _run.Append('&').Append(content).Append(';'); - } - - return; - } - - if ((char.IsLetterOrDigit(ch) || ch == '#' || ch == '_') && _entity.Length < MaxEntityLength) - { - _entity.Append(ch); - return; - } - - // Not a valid entity (stray '&' or malformed) — emit literally and reprocess. - _run.Append('&').Append(_entity); - _entity.Clear(); - _mode = Mode.Text; - ProcessText(ch); - } - - private static string? ResolveEntity(string content) - { - if (content.Length == 0) - { - return null; - } - - if (content[0] == '#') - { - var digits = content.AsSpan(1); - int code; - if (digits.Length > 1 && (digits[0] == 'x' || digits[0] == 'X')) - { - if (!int.TryParse(digits[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out code)) - { - return null; - } - } - else if (!int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out code)) - { - return null; - } - - if (code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) - { - return null; - } - - return char.ConvertFromUtf32(code); - } - - return content.ToLowerInvariant() switch - { - "lt" => "<", - "gt" => ">", - "amp" => "&", - "quot" => "\"", - "apos" => "'", - "nbsp" => " ", - _ => null, - }; - } - - /// - /// The exact tag body as it arrived, everything between the < and the >. - /// It is kept unmodified all the way to so a refused tag is echoed - /// byte for byte: re-serialising it from the parsed name and attributes would normalise case, - /// spacing and quoting, and every character the round trip normalises is one a player could use - /// to smuggle something past a reader being shown "what the other player typed". - /// - private void ProcessTag(string raw) - { - var trimmed = raw.Trim(); - if (trimmed.Length == 0) - { - return; - } - - _markupThisLine = true; - - if (trimmed[0] == '/') - { - // A closing tag takes the category of the element it closes, so the slash comes off - // before the gate sees the name. The gate is consulted unconditionally because a close is - // still "the next tag" and must spend a pending TEMP SECURE either way. - var closing = Canonical(trimmed[1..].Trim()); - var allowed = ConsumeAuthorizationFor(closing); - - // A close that matches something open is honoured whatever the mode, because closing can - // only ever *reduce* privilege. Refusing it leaves the frame open, and a deferred - // — one with no HREF, whose command is its enclosed text — then absorbs every span - // to the end of the line, so "\x1b[4zY Rivane says, 'hi'" finalised as the - // command "Y Rivane says, 'hi'" with the player supplying the tail. The worst a - // player achieves under this rule is truncating a clickable region the server drew. - if (allowed || FindOpenFrame(closing) >= 0) - { - CloseTag(closing); - return; - } - - EmitLiteralTag(raw); - return; - } - - // Tolerate a self-closing slash, e.g. "
". - if (trimmed[^1] == '/') - { - trimmed = trimmed[..^1].TrimEnd(); - if (trimmed.Length == 0) - { - return; - } - } - - // Split "NAME rest-of-attributes". - var nameEnd = 0; - while (nameEnd < trimmed.Length && !char.IsWhiteSpace(trimmed[nameEnd])) - { - nameEnd++; - } - - var name = Canonical(trimmed[..nameEnd]); - var trust = Authorize(name); - if (trust == Trust.None) - { - EmitLiteralTag(raw); - return; - } - - var attrs = nameEnd < trimmed.Length ? trimmed[(nameEnd + 1)..] : string.Empty; - Open(name, attrs, trust); - } - - /// Opens a tag that has been allowed: a server-defined element, or one of the built-ins. - private void Open(string name, string attrs, Trust trust) - { - if (_elements.TryGetValue(name, out var element)) - { - OpenElement(element, attrs, trust, endsWithLine: false); - return; - } - - HandleOpener(name, attrs); - } - - /// - /// Writes a tag the mode refused out as the literal text it arrived as, angle brackets and all, - /// so an injection attempt is shown to the player rather than silently swallowed. - /// - private void EmitLiteralTag(string raw) => _run.Append('<').Append(raw).Append('>'); - - private void HandleOpener(string name, string attrs) - { - switch (name) - { - case "B": - OpenFormatting(name, TextAttributes.Bold); - break; - case "I": - OpenFormatting(name, TextAttributes.Italic); - break; - case "U": - OpenFormatting(name, TextAttributes.Underline); - break; - case "S": - OpenFormatting(name, TextAttributes.Strikethrough); - break; - case "COLOR": - case "FONT": - OpenColor(name, attrs); - break; - case "SEND": - OpenSend(attrs); - break; - case "A": - OpenLink(attrs); - break; - case "BR": - CompleteLine(); - break; - case "VERSION": - HandleVersionRequest(); - break; - case "SUPPORT": - HandleSupportRequest(); - break; - case "IMAGE": - HandleImage(attrs); - break; - case "!ELEMENT": - DefineElement(attrs); - break; - case "!ENTITY": - DefineEntity(attrs); - break; - case "VAR": - OpenVar(attrs); - break; - case "GAUGE": - case "STAT": - DeclareGauge(attrs, isStat: name == "STAT"); - break; - case "SOUND": - case "MUSIC": - RequestMedia(name == "MUSIC" ? Media.MediaKind.Music : Media.MediaKind.Sound, attrs); - break; - case "FRAME": - DeclareFrame(attrs); - break; - case "DEST": - OpenDest(attrs); - break; - default: - // Unknown/unsupported tag (VAR, EXPIRE, H1, P, …) — consumed and ignored. - break; - } - } - - /// - /// Spec's exact attribute order: MXP=mxpversion STYLE=styleversion CLIENT=clientname - /// VERSION=clientversion REGISTERED=yes/no, with only REGISTERED optional. - /// - /// - /// Values chosen deliberately, not copied from any example: - /// - /// MXP=1.0 is the protocol revision this parser's own mechanism — the OPEN/SECURE/LOCKED - /// line-security model, RESET, TEMP SECURE — implements against the spec dated "Version 1.0 - /// (12-Mar-03)", which is the whole spec's own ceiling; this is not a claim about tag coverage, - /// which answers separately and honestly via SUPPORTS. - /// STYLE=0: "the current version of the optional style sheet[,] set by [the MUD] - /// sending the <VERSION styleversion> tag to the client." This parser tracks no such - /// state — no style sheet is ever in effect — so 0 says exactly that rather than inventing a - /// version number for a mechanism that does not exist here. - /// VERSION=1: this client carries no build/assembly version reachable from this layer - /// without assembly reflection, which does not belong in a line parser. Kept as a fixed placeholder - /// rather than manufactured. - /// REGISTERED omitted: it is the one optional field and there is no registration - /// concept for this client to report. - /// - /// - private void HandleVersionRequest() - { - ClientReply?.Invoke(this, $"{SecureLinePrefix}"); - } - - private void HandleSupportRequest() - { - ClientReply?.Invoke(this, $"{SecureLinePrefix} "+" + t))}>"); - } - - /// - /// <IMAGE fname [URL=url] [T=type] [H=height] [W=width] …>: emits the placeholder span - /// [image: fname] with the request attached. Inside a <SEND> or <A> - /// the span keeps that element's action (the spec's own example is - /// <SEND showmap><IMAGE map.jpg ISMAP></SEND>); on its own it links to the - /// picture. HSPACE, VSPACE, ALIGN and ISMAP are read and ignored — a cell grid has no float, and a - /// click carries no pixel position to append. - /// - private void HandleImage(string attrs) - { - var parsed = ParseAttributes(attrs); - var fileName = GetAttr(parsed, "FNAME") ?? FirstPositionalExcept(parsed, "ISMAP"); - if (string.IsNullOrWhiteSpace(fileName)) - { - return; - } - - var url = MxpImageSource.Resolve(fileName, GetAttr(parsed, "URL"), GetAttr(parsed, "T")); - var name = MxpImageSource.DisplayName(fileName); - ImageExtent? width = ImageExtent.TryParse(GetAttr(parsed, "W"), out var w) ? w : null; - ImageExtent? height = ImageExtent.TryParse(GetAttr(parsed, "H"), out var h) ? h : null; - - var enclosing = _interaction; - SpanInteraction? interaction = enclosing; - if (url is not null) - { - var request = new InlineImageRequest(url, name, width, height); - interaction = (enclosing ?? SpanInteraction.Link(url)) with { Image = request }; - } - - // Sized in cells both ways, a picture may be one the server laid out: it left that many blank cells - // here and on the rows below for the picture to be drawn in. The label then takes the place of - // blanks rather than adding to them, and if the blanks are there the request is marked reserved, - // so a client that draws it does so in those cells rather than under the line. - var label = $"[image: {name}]"; - var reserving = url is not null && width is { Unit: ImageExtentUnit.Characters } - && height is { Unit: ImageExtentUnit.Characters }; - if (reserving) - { - var cells = width!.Value.Value; - label = label.Length <= cells ? label : cells > 1 ? label[..(cells - 1)] + "…" : label[..cells]; - } - - FlushRun(); - _interaction = interaction; - _run.Append(label); - FlushRun(); - _interaction = enclosing; - _owedBlanks = reserving ? label.Length : 0; - _owedBy = reserving ? _lineSpans.Count - 1 : -1; - } - - private static string? FirstPositionalExcept(List<(string? Key, string Value)> attrs, string flag) - { - foreach (var (k, v) in attrs) - { - if (k is null && !string.Equals(v, flag, StringComparison.OrdinalIgnoreCase)) - { - return v; - } - } - - return null; - } - - private void OpenFormatting(string name, TextAttributes attribute) - { - PushFrame(name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false); - _current = _current.AddAttribute(attribute); - } - - private void OpenColor(string name, string attrs) - { - var parsed = ParseAttributes(attrs); - // FONT carries its foreground on COLOR=; COLOR/C use FORE=. Positional first = fore. - var fore = GetAttr(parsed, "FORE") ?? GetAttr(parsed, "COLOR") ?? Positional(parsed, 0); - var back = GetAttr(parsed, "BACK") ?? Positional(parsed, 1); - - PushFrame(name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false); - - if (fore is not null && WebColors.TryParse(fore, out var fg)) - { - _current = _current.WithForeground(fg); - } - - if (back is not null && WebColors.TryParse(back, out var bg)) - { - _current = _current.WithBackground(bg); - } - } - - /// The most commands one <SEND> menu keeps. - public const int MaxSendChoices = 20; - - /// - /// <SEND [HREF=]"cmd|cmd…" [HINT=]"…" [PROMPT]>. Several |-separated commands make a - /// menu: a click sends the first, and the client offers the rest. A hint with one part more than there - /// are commands is the tooltip followed by a label for each; with as many parts, it is the labels alone. - /// &text; in a command is the element's own text, filled in when the element closes. - /// - private void OpenSend(string attrs) - { - var parsed = ParseAttributes(attrs); - var href = GetAttr(parsed, "HREF") ?? Positional(parsed, 0); - var hint = GetAttr(parsed, "HINT") ?? Positional(parsed, 1); - var prompt = HasFlag(parsed, "PROMPT"); - - if (href is null) - { - // No HREF: the enclosed text is itself the command (resolved when the tag closes). - PushFrame("SEND", isInteraction: true, isLink: false, deferCommand: true, hint: hint, promptOnly: prompt); - _interaction = SpanInteraction.Command(string.Empty, hint, prompt); - return; - } - - var commands = href.Split('|').Take(MaxSendChoices).ToArray(); - string[]? labels = null; - if (commands.Length > 1 && hint is not null) - { - var parts = hint.Split('|'); - if (parts.Length > commands.Length) - { - hint = parts[0]; - labels = parts[1..(commands.Length + 1)]; - } - else if (parts.Length == commands.Length) - { - hint = null; - labels = parts; - } - } - - var templated = commands.Any(c => c.Contains(TextEntity, StringComparison.OrdinalIgnoreCase)); - PushFrame( - "SEND", isInteraction: true, isLink: false, deferCommand: templated, hint: hint, promptOnly: prompt, - templates: templated ? commands : null, labels: labels); - _interaction = SendInteraction(commands, labels, hint, prompt); - } - - /// The placeholder a <SEND> command uses for the element's own text. - private const string TextEntity = "&text;"; - - private static SpanInteraction SendInteraction(IReadOnlyList commands, IReadOnlyList? labels, string? hint, bool prompt) => - SpanInteraction.Command(commands[0], hint, prompt) with - { - Choices = commands.Count < 2 - ? null - : commands.Select((c, i) => new SendChoice(labels is not null && labels[i].Length > 0 ? labels[i] : c, c)).ToArray(), - }; - - private void OpenLink(string attrs) - { - var parsed = ParseAttributes(attrs); - var href = GetAttr(parsed, "HREF") ?? Positional(parsed, 0); - var hint = GetAttr(parsed, "HINT") ?? Positional(parsed, 1); - - if (href is not null) - { - PushFrame("A", isInteraction: true, isLink: true, deferCommand: false, hint: hint, promptOnly: false); - _interaction = SpanInteraction.Link(href, hint); - } - else - { - PushFrame("A", isInteraction: true, isLink: true, deferCommand: true, hint: hint, promptOnly: false); - _interaction = SpanInteraction.Link(string.Empty, hint); - } - } - - private void PushFrame( - string name, - bool isInteraction, - bool isLink, - bool deferCommand, - string? hint, - bool promptOnly, - IReadOnlyList? templates = null, - IReadOnlyList? labels = null, - string? flag = null, - string? destination = null, - bool endsWithLine = false) - { - FlushRun(); - _stack.Add(new Frame - { - Name = name, - SavedStyle = _current, - SavedInteraction = _interaction, - IsInteraction = isInteraction, - IsLink = isLink, - DeferCommand = deferCommand, - Hint = hint, - PromptOnly = promptOnly, - SpanStart = _lineSpans.Count, - OpenedInOpenMode = _lineMode == MxpLineMode.Open, - Templates = templates, - Labels = labels, - Flag = flag, - Destination = destination, - EndsWithLine = endsWithLine, - Captured = flag is null ? null : new StringBuilder(), - }); - } - - /// - /// The index of the innermost open frame for , or -1 when the - /// element is not open. - /// - private int FindOpenFrame(string name) - { - for (var i = _stack.Count - 1; i >= 0; i--) - { - if (_stack[i].Name == name) - { - return i; - } - } - - return -1; - } - - private void CloseTag(string name) - { - var idx = FindOpenFrame(name); - if (idx < 0) - { - // Stray/unbalanced closer — ignored (does not throw). - return; - } - - FlushRun(); - CloseFramesFrom(idx); - } - - /// - /// Pops every frame at or above , finalising any deferred interaction among - /// them, and restores the style and interaction that were in force before frame - /// opened. Callers flush the pending run first, so the text written under - /// those frames keeps their rendition. - /// - private void CloseFramesFrom(int idx) - { - var matched = _stack[idx]; - for (var i = _stack.Count - 1; i >= idx; i--) - { - var frame = _stack[i]; - if (frame.IsInteraction && frame.DeferCommand) - { - FinalizeDeferredInteraction(frame); - } - - CompleteDefinitionFrame(frame, closing: true); - _stack.RemoveAt(i); - } - - // Restoring the matched frame's saved state reverts everything opened at or after it. - _current = matched.SavedStyle; - _interaction = matched.SavedInteraction; - } - - /// - /// Rewrites the spans emitted since a deferred <SEND>/<A> opened, - /// using their concatenated text as the command/URL target. - /// - private void FinalizeDeferredInteraction(Frame frame) - { - if (frame.SpanStart >= _lineSpans.Count) - { - return; - } - - var sb = new StringBuilder(); - for (var i = frame.SpanStart; i < _lineSpans.Count; i++) - { - sb.Append(_lineSpans[i].Text); - } - - var target = sb.ToString(); - var interaction = frame.IsLink - ? SpanInteraction.Link(target, frame.Hint) - : frame.Templates is { } templates - ? SendInteraction( - templates.Select(t => t.Replace(TextEntity, target, StringComparison.OrdinalIgnoreCase)).ToArray(), - frame.Labels, - frame.Hint, - frame.PromptOnly) - : SpanInteraction.Command(target, frame.Hint, frame.PromptOnly); - - for (var i = frame.SpanStart; i < _lineSpans.Count; i++) - { - var span = _lineSpans[i]; - _lineSpans[i] = new StyledSpan(span.Text, span.Style, interaction); - } - } - - /// - /// Finalises and removes any open interaction (<SEND>/<A>) frames at a - /// line/prompt boundary so a bare, unclosed interaction never leaks to the next line. - /// Formatting/colour frames persist across the boundary (MXP tags may span lines). - /// - private void CloseInteractionsAtBoundary() - { - for (var i = _stack.Count - 1; i >= 0; i--) - { - var frame = _stack[i]; - if (!frame.IsInteraction) - { - continue; - } - - if (frame.DeferCommand) - { - FinalizeDeferredInteraction(frame); - } - - _stack.RemoveAt(i); - } - - // A formatting frame opened *inside* an interaction still holds it in SavedInteraction; - // if it closes on a later line, CloseTag would resurrect the ended interaction. Clear it - // (and rebase SpanStart, which pointed into the now-cleared line) on surviving frames. A flagged - // or redirected frame takes this line's share of its text with it first. - for (var i = 0; i < _stack.Count; i++) - { - var frame = _stack[i]; - CompleteDefinitionFrame(frame, closing: false); - if (frame.SavedInteraction is not null || frame.SpanStart != 0) - { - _stack[i] = frame with { SavedInteraction = null, SpanStart = 0 }; - } - } - - _interaction = null; - } - - private void CompleteLine() - { - EndLine(); - - var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); - _lineSpans.Clear(); - _markupThisLine = false; - if (_redirect is { } frame && line.Spans.Count > 0) - { - Redirected?.Invoke(this, new MxpRedirect(frame, line)); - return; - } - - (_emit ??= new List()).Add(line); - } - - /// - /// Everything the end of a line does to parser state, short of emitting the line itself. - /// - /// - /// - /// Called from and from , because - /// is the line boundary this parser actually sees in production: the telnet - /// layer strips the terminator, so WorldSession.OnOutputReceived feeds a line's text with - /// no '\n' in it and then flushes. A revert that lived only in - /// therefore never ran on a real connection — one ESC[1z from the server and every - /// following line of the session stayed SECURE, so a <SEND> a player typed into a - /// public channel became a clickable command. was already the boundary for - /// the tag stack (); this makes it the boundary for - /// everything, which is the property that cannot be forgotten by a future caller — a consumer - /// that does not flush gets no output at all, whereas one that forgot to call an - /// EndLine() on ILineParser would silently reproduce exactly this bug. - /// - /// - private void EndLine() - { - // Nor can a tag: the '\n' rule in ProcessTagChar never runs in production, so without this an - // unclosed '<' went on reading the next line as part of its tag. - if (_mode == Mode.Tag) - { - AbandonTag(); - } - - FlushRun(); - _owedBlanks = 0; - _owedBy = -1; - _expansionsThisLine = 0; - - // An element a line tag opened lasts until the end of its line, in any mode. - for (var i = 0; i < _stack.Count; i++) - { - if (_stack[i].EndsWithLine) - { - CloseFramesFrom(i); - break; - } - } - - // Spec: "when in OPEN mode, any unclosed OPEN tags are automatically closed when a newline is - // received from the MUD." Only open-mode ones: "secure tags are never automatically closed", - // which is why a server's markup may span lines and a player's may not. - if (_lineMode == MxpLineMode.Open) - { - CloseOpenModeTags(); - } - - CloseInteractionsAtBoundary(); - - // An escape sequence cannot span the boundary either — see Process. In production this is the - // only place it can be aborted, since the terminator the '\n' rule keys on never arrives. - if (IsEscapeMode(_mode)) - { - EndSequence(); - } - - // Spec: "when a newline is received from the MUD, the mode reverts back to the Default mode." - // A pending TEMP SECURE dies with the line rather than arming the first tag of the next one. - _lineMode = _defaultMode; - _tempSecure = false; - } - - private void FlushRun() - { - if (_run.Length == 0) - { - return; - } - - _lineSpans.Add(new StyledSpan(_run.ToString(), _current, _interaction)); - _run.Clear(); - } - - private static string Canonical(string name) => name.ToUpperInvariant() switch - { - "B" or "BOLD" or "STRONG" => "B", - "I" or "ITALIC" or "EM" => "I", - "U" or "UNDERLINE" => "U", - "S" or "STRIKEOUT" => "S", - "C" or "COLOR" => "COLOR", - "!EL" or "!ELEMENT" => "!ELEMENT", - "!EN" or "!ENTITY" => "!ENTITY", - "V" or "VAR" => "VAR", - var other => other, - }; - - // ----- Attribute parsing -------------------------------------------------- - - /// - /// Tokenises an attribute string into (Key, Value) pairs. A KEY=VALUE pair has a - /// non-null key; a bare token (positional value or flag) has a null key. Values may be quoted - /// with " or ', or be a bare whitespace-delimited token. - /// - private static List<(string? Key, string Value)> ParseAttributes(string s) - { - var result = new List<(string?, string)>(); - var i = 0; - var n = s.Length; - - while (i < n) - { - while (i < n && char.IsWhiteSpace(s[i])) - { - i++; - } - - if (i >= n) - { - break; - } - - var quoted = s[i] == '"' || s[i] == '\''; - var token = ReadToken(s, ref i, stopAtEquals: !quoted); - - if (!quoted && i < n && s[i] == '=') - { - i++; // consume '=' - var value = ReadToken(s, ref i, stopAtEquals: false); - result.Add((token, value)); - } - else - { - result.Add((null, token)); - } - } - - return result; - } - - private static string ReadToken(string s, ref int i, bool stopAtEquals) - { - var n = s.Length; - if (i < n && (s[i] == '"' || s[i] == '\'')) - { - var quote = s[i++]; - var start = i; - while (i < n && s[i] != quote) - { - i++; - } - - var value = s[start..i]; - if (i < n) - { - i++; // consume closing quote - } - - return value; - } - - var tokenStart = i; - while (i < n && !char.IsWhiteSpace(s[i]) && !(stopAtEquals && s[i] == '=')) - { - i++; - } - - return s[tokenStart..i]; - } - - private static string? GetAttr(List<(string? Key, string Value)> attrs, string key) - { - foreach (var (k, v) in attrs) - { - if (k is not null && string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) - { - return v; - } - } - - return null; - } - - private static bool HasFlag(List<(string? Key, string Value)> attrs, string flag) - { - foreach (var (k, v) in attrs) - { - if (k is not null && string.Equals(k, flag, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (k is null && string.Equals(v, flag, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - /// Returns the -th positional (unkeyed) value, skipping the PROMPT flag. - private static string? Positional(List<(string? Key, string Value)> attrs, int index) - { - var seen = 0; - foreach (var (k, v) in attrs) - { - if (k is not null || string.Equals(v, "PROMPT", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (seen == index) - { - return v; - } - - seen++; - } - - return null; - } -} diff --git a/src/SharpMUTerm.Core/Protocols/MxpTagCategory.cs b/src/SharpMUTerm.Core/Protocols/MxpTagCategory.cs deleted file mode 100644 index 9ef31a6a..00000000 --- a/src/SharpMUTerm.Core/Protocols/MxpTagCategory.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace SharpMUTerm.Core.Protocols; - -/// -/// Which MXP tags may appear on an open line. -/// -/// -/// The spec draws the line in one sentence — "Only the tags described in this section are OPEN tags. -/// All other MXP tags are SECURE tags" — so this is an allow-list and must stay one. A deny-list -/// would make every tag added to the spec in future secure-by-omission in the wrong direction. -/// -public static class MxpTagCategory -{ - private static readonly HashSet OpenTags = new(StringComparer.OrdinalIgnoreCase) - { - "B", "BOLD", "STRONG", - "I", "ITALIC", "EM", - "U", "UNDERLINE", - "S", "STRIKEOUT", - "C", "COLOR", - "H", "HIGH", - "FONT", - }; - - /// - /// True when is an open tag. - /// - /// - /// The bare element name: no angle brackets, no attributes, and no leading slash. A closing tag - /// takes the category of the element it closes, so the caller strips the slash first. - /// - public static bool IsOpen(string tagName) => OpenTags.Contains(tagName); -} diff --git a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs deleted file mode 100644 index af9e029c..00000000 --- a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs +++ /dev/null @@ -1,786 +0,0 @@ -using System.Text; -using SharpMUTerm.Core.Text; - -namespace SharpMUTerm.Core.Protocols; - -/// -/// Incremental, line-oriented parser for Pueblo markup — the HTML subset spoken by some -/// MU* servers (a predecessor and cousin of MXP). Feed it decoded text (any number of chunks); -/// it emits fully-terminated s and retains the in-progress line, the -/// current , and the open-tag stack across calls, so a tag -/// (<...>) or entity (&...;) may span chunk boundaries. -/// -/// Supported: the formatting tags (B/STRONG, I/EM, U, STRIKE/S), -/// <FONT COLOR BGCOLOR>, the Pueblo command/link anchor -/// (<A XCH_CMD> / <A HREF>, plus <SEND>), the break tags -/// (BR, HR, P), preformatted passthrough (PRE), graceful -/// <IMG> discard, and the common named/numeric entities. Unknown tags are consumed -/// and never leak into the output. -/// -/// Out of scope: the Pueblo activation handshake (the <!-- --> / -/// "This world is Pueblo …" enabling exchange). This class parses the markup only; -/// deciding whether a world is Pueblo-enabled is a session concern handled elsewhere. -/// -/// -/// ANSI is decoded here, through , because a session has one parser and nothing -/// chains another in front of it ('s reason, in the same words). SGR changes the style; -/// every other CSI, OSC, DCS/SOS/PM/APC string and two-byte escape is consumed. The escape state machine is a -/// near-copy of 's and 's, deliberately not shared — a fix to -/// one belongs in all three, with its own test in each suite. -/// -/// -public sealed class PuebloParser : ILineParser -{ - private const int MaxTagLength = 4096; - private const int MaxEntityLength = 32; - private const int MaxSequenceLength = 128; - - private enum State - { - Ground, - Tag, - Entity, - Escape, - EscapeIntermediate, - Csi, - Osc, - OscEscape, - } - - /// A saved rendition frame, pushed when an element opens and restored when it closes. - private readonly struct Frame - { - public Frame(string name, TextStyle style, SpanInteraction? interaction) - { - Name = name; - Style = style; - Interaction = interaction; - } - - public string Name { get; } - - public TextStyle Style { get; } - - public SpanInteraction? Interaction { get; } - } - - private State _state = State.Ground; - private TextStyle _current = TextStyle.Default; - private SpanInteraction? _interaction; - private readonly StringBuilder _run = new(); - private readonly List _lineSpans = new(); - private readonly StringBuilder _tag = new(); - private readonly StringBuilder _entity = new(); - private readonly StringBuilder _seq = new(); - private readonly List _stack = new(); - - /// The rendition state that will apply to the next printed character. - public TextStyle CurrentStyle => _current; - - /// True when a partial line, tag, or entity is buffered. - public bool HasPendingContent => - _run.Length > 0 || _lineSpans.Count > 0 || _state != State.Ground; - - /// Feeds a chunk of text, returning every line completed within it. - public IReadOnlyList Feed(string text) - { - ArgumentNullException.ThrowIfNull(text); - return Feed(text.AsSpan()); - } - - /// Feeds a chunk of text, returning every line completed within it. - public IReadOnlyList Feed(ReadOnlySpan text) - { - List? lines = null; - foreach (var ch in text) - { - Process(ch, ref lines); - } - - return (IReadOnlyList?)lines ?? Array.Empty(); - } - - /// - /// Returns the buffered partial line (e.g. a prompt not terminated by a break) and clears it, - /// or null if nothing is buffered. Style and open-tag state are preserved. - /// - public StyledLine? Flush() - { - // The telnet layer strips the terminator, so this is the line boundary in production, and an - // escape cannot span it — see Process. - if (IsEscapeState(_state)) - { - EndSequence(); - } - - FlushRun(); - CloseInteractionsAtBoundary(); - if (_lineSpans.Count == 0) - { - return null; - } - - var line = new StyledLine(_lineSpans); - _lineSpans.Clear(); - return line; - } - - /// Resets all parser state, including the current style and open-tag stack. - public void Reset() - { - _state = State.Ground; - _current = TextStyle.Default; - _interaction = null; - _run.Clear(); - _lineSpans.Clear(); - _tag.Clear(); - _entity.Clear(); - _seq.Clear(); - _stack.Clear(); - } - - private void Process(char ch, ref List? lines) - { - // A newline ends the line whatever sequence is in flight, or an unterminated ESC ] that a player - // typed would swallow every following line. MxpParser and AnsiParser carry the same rule. - if (ch == '\n' && IsEscapeState(_state)) - { - EndSequence(); - } - - switch (_state) - { - case State.Ground: - ProcessGround(ch, ref lines); - break; - - case State.Tag: - ProcessTag(ch, ref lines); - break; - - case State.Entity: - ProcessEntity(ch, ref lines); - break; - - case State.Escape: - ProcessEscape(ch); - break; - - case State.EscapeIntermediate: - // The single trailing byte of e.g. ESC ( B. - EndSequence(); - break; - - case State.Csi: - ProcessCsi(ch); - break; - - case State.Osc: - ProcessOsc(ch); - break; - - case State.OscEscape: - // ESC inside a string sequence; a following '\' is the ST terminator. - if (ch == '\\') - { - EndSequence(); - } - else - { - _state = State.Osc; - } - - break; - } - } - - private static bool IsEscapeState(State state) => - state is State.Escape or State.EscapeIntermediate or State.Csi or State.Osc or State.OscEscape; - - private void ProcessEscape(char ch) - { - switch (ch) - { - case '[': - _seq.Clear(); - _state = State.Csi; - break; - - case ']': - case 'P': // DCS - case 'X': // SOS - case '^': // PM - case '_': // APC - // String sequences, terminated by BEL or ST — consumed so the payload never shows. - _seq.Clear(); - _state = State.Osc; - break; - - case '(': - case ')': - case '*': - case '+': - case '#': - case '%': - _state = State.EscapeIntermediate; - break; - - default: - // A two-byte escape (ESC c, ESC M, …), consumed. - EndSequence(); - break; - } - } - - private void ProcessCsi(char ch) - { - if (ch is >= '\x40' and <= '\x7e') - { - if (ch == 'm') - { - // Text so far keeps the style it was written in. - FlushRun(); - _current = SgrCodes.Apply(_current, _seq.ToString()); - } - - // Every other final byte — cursor movement, erase — means nothing to a line-oriented view. - EndSequence(); - return; - } - - if (ch is >= '\x20' and <= '\x3f') - { - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - return; - } - - // A control character inside the sequence aborts it as malformed. - EndSequence(); - } - - private void ProcessOsc(char ch) - { - switch (ch) - { - case '\x07': - EndSequence(); - break; - - case '\x1b': - _state = State.OscEscape; - break; - - default: - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - break; - } - } - - private void EndSequence() - { - _seq.Clear(); - _state = State.Ground; - } - - private void ProcessGround(char ch, ref List? lines) - { - switch (ch) - { - case '<': - _tag.Clear(); - _state = State.Tag; - break; - - case '&': - _entity.Clear(); - _state = State.Entity; - break; - - case '\n': - CompleteLine(ref lines); - break; - - case '\r': - // Carriage returns are dropped; scrollback is line-based. - break; - - case '\x1b': - _seq.Clear(); - _state = State.Escape; - break; - - default: - _run.Append(ch); - break; - } - } - - private void ProcessTag(char ch, ref List? lines) - { - switch (ch) - { - case '>': - HandleTag(_tag.ToString(), ref lines); - _tag.Clear(); - _state = State.Ground; - break; - - case '<': - // The previous '<' plus what we buffered was not a tag — emit it literally and - // begin a fresh tag with this '<'. - _run.Append('<').Append(_tag); - _tag.Clear(); - break; - - case '\n': - // A newline inside a "tag" means it never closed — treat it as literal text. - _run.Append('<').Append(_tag); - _tag.Clear(); - _state = State.Ground; - CompleteLine(ref lines); - break; - - default: - if (_tag.Length >= MaxTagLength) - { - // Runaway: give up on the tag and emit what we have as literal text. - _run.Append('<').Append(_tag); - _tag.Clear(); - _state = State.Ground; - ProcessGround(ch, ref lines); - } - else - { - _tag.Append(ch); - } - - break; - } - } - - private void ProcessEntity(char ch, ref List? lines) - { - // Entity names are alphanumeric, optionally prefixed with '#' (and 'x'/'X' for hex). - if (ch == ';') - { - if (TryResolveEntity(_entity.ToString(), out var resolved)) - { - _run.Append(resolved); - } - else - { - // Not a recognised entity — emit it verbatim so nothing is lost. - _run.Append('&').Append(_entity).Append(';'); - } - - _entity.Clear(); - _state = State.Ground; - return; - } - - if (ch == '&') - { - // A second '&' means the first was a literal ampersand. - _run.Append('&').Append(_entity); - _entity.Clear(); - return; - } - - if ((char.IsLetterOrDigit(ch) || ch == '#') && _entity.Length < MaxEntityLength) - { - _entity.Append(ch); - return; - } - - // Any other character (whitespace, '<', overflow, …) aborts the entity: emit the buffered - // text literally, then reprocess this character from the Ground state. - _run.Append('&').Append(_entity); - _entity.Clear(); - _state = State.Ground; - ProcessGround(ch, ref lines); - } - - private void HandleTag(string raw, ref List? lines) - { - var tag = PuebloTag.Parse(raw); - if (tag.Name.Length == 0) - { - // Empty, comment (), or declaration () — nothing to render. - return; - } - - if (tag.IsClose) - { - CloseTag(tag.Name); - return; - } - - switch (tag.Name) - { - case "b": - case "strong": - OpenAttribute(tag.Name, TextAttributes.Bold); - break; - case "i": - case "em": - OpenAttribute(tag.Name, TextAttributes.Italic); - break; - case "u": - OpenAttribute(tag.Name, TextAttributes.Underline); - break; - case "strike": - case "s": - OpenAttribute(tag.Name, TextAttributes.Strikethrough); - break; - - case "font": - OpenFont(tag); - break; - - case "a": - case "send": - OpenAnchor(tag); - break; - - case "br": - CompleteLine(ref lines); - break; - case "hr": - // A rule renders as a break followed by a blank separating line. - CompleteLine(ref lines); - CompleteLine(ref lines); - break; - case "p": - // Paragraph boundaries (open and close) act as a single line break. - CompleteLine(ref lines); - break; - - case "pre": - case "img": - //
 content is already preformatted;  is handled by the graphics layer.
-                // Both are consumed here so they never leak into the text.
-                break;
-
-            default:
-                // Unknown tag — consumed and ignored.
-                break;
-        }
-    }
-
-    private void OpenAttribute(string name, TextAttributes attribute)
-    {
-        FlushRun();
-        _stack.Add(new Frame(name, _current, _interaction));
-        _current = _current.AddAttribute(attribute);
-    }
-
-    private void OpenFont(PuebloTag tag)
-    {
-        FlushRun();
-        _stack.Add(new Frame("font", _current, _interaction));
-
-        if (tag.TryGet("color", out var fg) && WebColors.TryParse(fg, out var fgColor))
-        {
-            _current = _current.WithForeground(fgColor);
-        }
-
-        if (tag.TryGet("bgcolor", out var bg) && WebColors.TryParse(bg, out var bgColor))
-        {
-            _current = _current.WithBackground(bgColor);
-        }
-    }
-
-    private void OpenAnchor(PuebloTag tag)
-    {
-        FlushRun();
-        _stack.Add(new Frame(tag.Name, _current, _interaction));
-
-        tag.TryGet("xch_hint", out var hint);
-        if (string.IsNullOrEmpty(hint))
-        {
-            tag.TryGet("title", out hint);
-        }
-
-        if (tag.TryGet("xch_cmd", out var cmd) && !string.IsNullOrEmpty(cmd))
-        {
-            var promptOnly = tag.TryGet("xch_mode", out var mode) &&
-                             string.Equals(mode, "prompt", StringComparison.OrdinalIgnoreCase);
-            _interaction = SpanInteraction.Command(cmd, NullIfEmpty(hint), promptOnly);
-        }
-        else if (tag.TryGet("href", out var href) && !string.IsNullOrEmpty(href))
-        {
-            //  carries a command;  without XCH_CMD is a hyperlink.
-            _interaction = tag.Name == "send"
-                ? SpanInteraction.Command(href, NullIfEmpty(hint))
-                : SpanInteraction.Link(href, NullIfEmpty(hint));
-        }
-
-        // With neither XCH_CMD nor HREF the anchor carries no interaction; the frame still keeps
-        // the stack balanced so its closer restores state cleanly.
-    }
-
-    private void CloseTag(string name)
-    {
-        // Pop down to (and including) the nearest matching open frame, restoring its saved state.
-        // Unbalanced closers with no matching open frame are ignored.
-        for (var i = _stack.Count - 1; i >= 0; i--)
-        {
-            if (_stack[i].Name != name && !IsAlias(_stack[i].Name, name))
-            {
-                continue;
-            }
-
-            FlushRun();
-            _current = _stack[i].Style;
-            _interaction = _stack[i].Interaction;
-            _stack.RemoveRange(i, _stack.Count - i);
-            return;
-        }
-    }
-
-    /// True when two tag names denote the same rendition (e.g. b/strong).
-    private static bool IsAlias(string open, string close) => (open, close) switch
-    {
-        ("b", "strong") or ("strong", "b") => true,
-        ("i", "em") or ("em", "i") => true,
-        ("strike", "s") or ("s", "strike") => true,
-        _ => false,
-    };
-
-    private void CompleteLine(ref List? lines)
-    {
-        FlushRun();
-        CloseInteractionsAtBoundary();
-        var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans);
-        _lineSpans.Clear();
-        (lines ??= new List()).Add(line);
-    }
-
-    /// 
-    /// Drops open anchor/send frames at a line/prompt boundary so an unclosed <A>
-    /// never leaves subsequent output clickable (matching MxpParser). Formatting/colour
-    /// frames persist, but any interaction they saved is cleared so a later close can't restore it.
-    /// 
-    private void CloseInteractionsAtBoundary()
-    {
-        for (var i = _stack.Count - 1; i >= 0; i--)
-        {
-            var frame = _stack[i];
-            if (frame.Name is "a" or "send")
-            {
-                _stack.RemoveAt(i);
-            }
-            else if (frame.Interaction is not null)
-            {
-                _stack[i] = new Frame(frame.Name, frame.Style, null);
-            }
-        }
-
-        _interaction = null;
-    }
-
-    private void FlushRun()
-    {
-        if (_run.Length == 0)
-        {
-            return;
-        }
-
-        _lineSpans.Add(new StyledSpan(_run.ToString(), _current, _interaction));
-        _run.Clear();
-    }
-
-    private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
-
-    private static bool TryResolveEntity(string name, out string value)
-    {
-        switch (name.ToLowerInvariant())
-        {
-            case "lt": value = "<"; return true;
-            case "gt": value = ">"; return true;
-            case "amp": value = "&"; return true;
-            case "quot": value = "\""; return true;
-            case "apos": value = "'"; return true;
-            case "nbsp": value = " "; return true;
-        }
-
-        if (name.Length >= 2 && name[0] == '#')
-        {
-            var isHex = name[1] is 'x' or 'X';
-            var digits = isHex ? name.AsSpan(2) : name.AsSpan(1);
-            var style = isHex
-                ? System.Globalization.NumberStyles.HexNumber
-                : System.Globalization.NumberStyles.Integer;
-
-            if (digits.Length > 0 &&
-                int.TryParse(digits, style, null, out var code) &&
-                code is > 0 and <= 0x10FFFF &&
-                !(code >= 0xD800 && code <= 0xDFFF))
-            {
-                value = char.ConvertFromUtf32(code);
-                return true;
-            }
-        }
-
-        value = string.Empty;
-        return false;
-    }
-
-    /// A parsed tag: its lower-cased name, whether it is a closer, and its attributes.
-    private readonly struct PuebloTag
-    {
-        private readonly Dictionary? _attributes;
-
-        private PuebloTag(bool isClose, string name, Dictionary? attributes)
-        {
-            IsClose = isClose;
-            Name = name;
-            _attributes = attributes;
-        }
-
-        public bool IsClose { get; }
-
-        public string Name { get; }
-
-        public bool TryGet(string key, out string value)
-        {
-            if (_attributes is not null && _attributes.TryGetValue(key, out var v))
-            {
-                value = v;
-                return true;
-            }
-
-            value = string.Empty;
-            return false;
-        }
-
-        /// Parses the raw text between < and > into name + attributes.
-        public static PuebloTag Parse(string raw)
-        {
-            var i = 0;
-            var len = raw.Length;
-
-            while (i < len && char.IsWhiteSpace(raw[i]))
-            {
-                i++;
-            }
-
-            if (i >= len)
-            {
-                return new PuebloTag(false, string.Empty, null);
-            }
-
-            // Comments () and declarations () carry no renderable name.
-            if (raw[i] == '!' || raw[i] == '?')
-            {
-                return new PuebloTag(false, string.Empty, null);
-            }
-
-            var isClose = false;
-            if (raw[i] == '/')
-            {
-                isClose = true;
-                i++;
-            }
-
-            var nameStart = i;
-            while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/' && raw[i] != '=')
-            {
-                i++;
-            }
-
-            var name = raw[nameStart..i].ToLowerInvariant();
-            if (name.Length == 0)
-            {
-                return new PuebloTag(isClose, string.Empty, null);
-            }
-
-            if (isClose)
-            {
-                // Closers carry no meaningful attributes.
-                return new PuebloTag(true, name, null);
-            }
-
-            Dictionary? attributes = null;
-
-            while (i < len)
-            {
-                while (i < len && (char.IsWhiteSpace(raw[i]) || raw[i] == '/'))
-                {
-                    i++;
-                }
-
-                if (i >= len)
-                {
-                    break;
-                }
-
-                var keyStart = i;
-                while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '=' && raw[i] != '/')
-                {
-                    i++;
-                }
-
-                var key = raw[keyStart..i].ToLowerInvariant();
-
-                // Skip whitespace before a possible '='.
-                var j = i;
-                while (j < len && char.IsWhiteSpace(raw[j]))
-                {
-                    j++;
-                }
-
-                var value = string.Empty;
-                if (j < len && raw[j] == '=')
-                {
-                    i = j + 1;
-                    while (i < len && char.IsWhiteSpace(raw[i]))
-                    {
-                        i++;
-                    }
-
-                    if (i < len && (raw[i] == '"' || raw[i] == '\''))
-                    {
-                        var quote = raw[i++];
-                        var valStart = i;
-                        while (i < len && raw[i] != quote)
-                        {
-                            i++;
-                        }
-
-                        value = raw[valStart..i];
-                        if (i < len)
-                        {
-                            i++; // consume the closing quote
-                        }
-                    }
-                    else
-                    {
-                        var valStart = i;
-                        while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/')
-                        {
-                            i++;
-                        }
-
-                        value = raw[valStart..i];
-                    }
-                }
-
-                if (key.Length > 0)
-                {
-                    (attributes ??= new Dictionary(StringComparer.OrdinalIgnoreCase))[key] = value;
-                }
-            }
-
-            return new PuebloTag(false, name, attributes);
-        }
-    }
-}
diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs
index 5cb6839b..200d2d85 100644
--- a/src/SharpMUTerm.Core/Session/WorldSession.cs
+++ b/src/SharpMUTerm.Core/Session/WorldSession.cs
@@ -29,7 +29,7 @@ public sealed class WorldSession : IAsyncDisposable
     public static TextStyle SystemLineStyle => SystemStyle;
 
     private readonly Func _sessionFactory;
-    private ILineParser _parser;
+    private MarkupLineParser _parser;
     private EmojiSubstitutor? _emoji;
     private ILogSink? _log;
     private readonly TextSettings? _text;
@@ -141,34 +141,20 @@ public void ReloadAutomation(IReadOnlyList triggerSets)
     private static EmojiSubstitutor? NewEmoji(EmojiSettings emoji) =>
         emoji.Enabled ? new EmojiSubstitutor(emoji.Emoticons, emoji.Shortcodes) : null;
 
-    private static ILineParser CreateParser(ContentFormat format) => format switch
-    {
-        ContentFormat.Mxp => new MxpParser(),
-        ContentFormat.Pueblo => new PuebloParser(),
-        _ => new AnsiParser(),
-    };
-
     /// 
-    /// The one place a parser is constructed for this session. Wraps  so
-    /// the two sites that build one — the constructor and 's upgrade path —
-    /// cannot diverge on what an  is wired up to: its
-    ///  answers to a server's <VERSION>/<SUPPORT>
-    /// request must reach the wire on both paths, since the upgrade path is the one every session that
-    /// actually negotiates MXP takes.
+    /// The one place a reader is constructed for this session, so the constructor and a reconnect cannot diverge on
+    /// what it is wired up to: its  answers a server's
+    /// <VERSION>/<SUPPORT>, and its other events carry what MXP declares.
     /// 
-    private ILineParser NewParser(ContentFormat format)
+    private MarkupLineParser NewParser(ContentFormat format)
     {
-        var parser = CreateParser(format);
-        if (parser is MxpParser mxp)
-        {
-            mxp.ClientReply += (_, reply) => SendProtocolReply(reply);
-            mxp.EntityChanged += (_, e) => GameData.SetMxp(e.Name, e.Value);
-            mxp.Flagged += (_, e) => MxpFlagged?.Invoke(this, e);
-            mxp.GaugeDeclared += (_, e) => DeclareMeter(e);
-            mxp.MediaRequested += (_, e) => MediaReceived?.Invoke(this, e);
-            mxp.Redirected += (_, e) => SpawnLine?.Invoke(this, new SpawnLineEventArgs(e.Frame, ApplyLinks(ApplyEmoji(e.Line))));
-        }
-
+        var parser = new MarkupLineParser(format);
+        parser.ClientReply += (_, reply) => SendProtocolReply(reply);
+        parser.EntityChanged += (_, e) => GameData.SetMxp(e.Name, e.Value);
+        parser.Flagged += (_, e) => MxpFlagged?.Invoke(this, e);
+        parser.GaugeDeclared += (_, e) => DeclareMeter(e);
+        parser.MediaRequested += (_, e) => MediaReceived?.Invoke(this, e);
+        parser.Redirected += (_, e) => SpawnLine?.Invoke(this, new SpawnLineEventArgs(e.Frame, ApplyLinks(ApplyEmoji(e.Line))));
         return parser;
     }
 
@@ -330,7 +316,7 @@ public async Task SendGmcpAsync(string package, string json)
     /// 
     private void UpgradeParser(ContentFormat format)
     {
-        var forward = _parser is AnsiParser || (_parser is PuebloParser && format == ContentFormat.Mxp);
+        var forward = _parser.Format == ContentFormat.Ansi || (_parser.Format == ContentFormat.Pueblo && format == ContentFormat.Mxp);
         if (World.ContentFormat != ContentFormat.Ansi || !forward)
         {
             return;
@@ -345,9 +331,8 @@ private void UpgradeParser(ContentFormat format)
             }
 
             ReleaseEchoHeld();
+            _parser.Switch(format);
         }
-
-        _parser = NewParser(format);
     }
 
     /// 
@@ -859,7 +844,7 @@ private void IngestOutput(TelnetOutputEventArgs e)
             Reflow(tail);
             emitted = true;
         }
-        else if (!emitted && _parser is not MxpParser { LineSwallowed: true })
+        else if (!emitted && !_parser.LineSwallowed)
         {
             ReleaseHeldLine();
             ProcessOutputLine(StyledLine.Empty);
diff --git a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj
index 16201207..f30ac351 100644
--- a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj
+++ b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj
@@ -9,6 +9,10 @@
     
     
     
+    
+    
+    
+    
   
 
 
diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs
index e94e153a..5198f275 100644
--- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs
+++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs
@@ -67,7 +67,7 @@ public sealed class TelnetSessionOptions
     /// MTTS bit vector claiming only what it actually does.
     /// 
     /// 2845 = ANSI (1) + UTF-8 (4) + 256 colours (8) + mouse tracking (16) + true colour (256)
-    /// + MNES (512) + SSL (2048). Every one of those is implemented: SGR through AnsiParser, UTF-8
+    /// + MNES (512) + SSL (2048). Every one of those is implemented: SGR through MarkupString (MarkupLineParser), UTF-8
     /// through CHARSET negotiation, mouse through the pane drag/wheel/click routing, MNES through
     ///  on NEW-ENVIRON, TLS through TcpTransport.
     /// 
@@ -776,7 +776,7 @@ private ValueTask OnPuebloEnabledAsync(PuebloClient client)
 
     /// 
     /// A server's <SUPPORT>. Once MXP mode has started the library takes the line out of the
-    /// stream and sends nothing unless asked to, so  never sees it. Left unanswered, a
+    /// stream and sends nothing unless asked to, so  never sees it. Left unanswered, a
     /// server that waits for the reply (SharpMUSH does) records that this client renders none of the tags it
     /// asked about, and sends a picture's address as text instead of an <IMAGE>.
     /// 
@@ -787,7 +787,7 @@ private async ValueTask OnMxpSupportRequestedAsync(IReadOnlyList asked)
             return;
         }
 
-        var (supported, unsupported) = MxpParser.AnswerSupport(asked);
+        var (supported, unsupported) = MxpClientSupport.AnswerSupport(asked);
         try
         {
             await mxp.SendSupportsAsync(supported, unsupported).ConfigureAwait(false);
@@ -809,7 +809,7 @@ private async ValueTask OnMxpVersionRequestedAsync()
         try
         {
             await mxp.SendVersionAsync(new MxpVersion(
-                MxpParser.MxpRevision, MxpParser.StyleVersion, MxpParser.ClientName, MxpParser.ClientVersion, Registered: null))
+                MxpClientSupport.MxpRevision, MxpClientSupport.StyleVersion, MxpClientSupport.ClientName, MxpClientSupport.ClientVersion, Registered: null))
                 .ConfigureAwait(false);
         }
         catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
diff --git a/src/SharpMUTerm.Core/Text/AnsiParser.cs b/src/SharpMUTerm.Core/Text/AnsiParser.cs
deleted file mode 100644
index 1120ba98..00000000
--- a/src/SharpMUTerm.Core/Text/AnsiParser.cs
+++ /dev/null
@@ -1,294 +0,0 @@
-using System.Text;
-
-namespace SharpMUTerm.Core.Text;
-
-/// 
-/// Incremental, line-oriented ANSI/VT parser. Feed it decoded text (any number of
-/// chunks); it emits fully-terminated s and retains the
-/// in-progress line plus the current  across calls, so escape
-/// sequences and colour state may span chunk boundaries.
-///
-/// SGR handling covers the 16 base colours, the 256-colour palette (38;5;n),
-/// 24-bit truecolour (38;2;r;g;b, including the colon-delimited ISO form), and
-/// the common rendition attributes. Non-SGR CSI sequences (cursor movement, erase),
-/// OSC strings, and other escapes are recognised and discarded rather than leaking
-/// into the output as stray text.
-///
-/// 
-/// A line boundary abandons any sequence still in flight — both a '\n' in the text and
-/// , which is the boundary a live session actually reaches (the telnet layer
-/// strips the terminator). Without that, an unterminated string escape swallows everything after it,
-/// line boundaries included.  carries a near-duplicate
-/// of this escape state machine, deliberately unconsolidated: a fix to one belongs in both.
-/// 
-/// 
-public sealed class AnsiParser : ILineParser
-{
-    private const int MaxSequenceLength = 128;
-
-    private enum State
-    {
-        Ground,
-        Escape,
-        EscapeIntermediate,
-        Csi,
-        Osc,
-        OscEscape,
-    }
-
-    private State _state = State.Ground;
-    private TextStyle _current = TextStyle.Default;
-    private readonly StringBuilder _run = new();
-    private readonly List _lineSpans = new();
-    private readonly StringBuilder _seq = new();
-
-    /// The rendition state that will apply to the next printed character.
-    public TextStyle CurrentStyle => _current;
-
-    /// True when a partial line or escape sequence is buffered.
-    public bool HasPendingContent => _run.Length > 0 || _lineSpans.Count > 0 || _state != State.Ground;
-
-    /// Feeds a chunk of text, returning every line completed by a newline within it.
-    public IReadOnlyList Feed(string text)
-    {
-        ArgumentNullException.ThrowIfNull(text);
-        return Feed(text.AsSpan());
-    }
-
-    /// Feeds a chunk of text, returning every line completed by a newline within it.
-    public IReadOnlyList Feed(ReadOnlySpan text)
-    {
-        List? lines = null;
-        foreach (var ch in text)
-        {
-            Process(ch, ref lines);
-        }
-
-        return (IReadOnlyList?)lines ?? Array.Empty();
-    }
-
-    /// 
-    /// Returns the buffered partial line (e.g. a prompt not terminated by a newline) and
-    /// clears it, or null if nothing is buffered. Colour state is preserved.
-    /// 
-    public StyledLine? Flush()
-    {
-        FlushRun();
-
-        // Flush is the line boundary a live session reaches — the telnet layer strips the terminator,
-        // so OnOutputReceived feeds a line with no '\n' in it and then flushes. An escape sequence
-        // left in flight must be abandoned here or it consumes the *next* line as its payload.
-        AbandonSequence();
-
-        if (_lineSpans.Count == 0)
-        {
-            return null;
-        }
-
-        var line = new StyledLine(_lineSpans);
-        _lineSpans.Clear();
-        return line;
-    }
-
-    /// Resets all parser state, including the current style.
-    public void Reset()
-    {
-        _state = State.Ground;
-        _current = TextStyle.Default;
-        _run.Clear();
-        _lineSpans.Clear();
-        _seq.Clear();
-    }
-
-    private void Process(char ch, ref List? lines)
-    {
-        // A newline ends the line whatever sequence is in flight. Without this an unterminated string
-        // escape — a bare ESC ] , ESC P, ESC X, ESC ^ or ESC _ , which a *player* can type into a
-        // public channel — consumes every following character, line boundaries included, until a BEL
-        // or ST it need never send: the rest of the session's output disappears. CSI already treats a
-        // control byte as malformed but was consuming the newline with it, and a two-byte escape would
-        // otherwise swallow the newline as its second byte.
-        if (ch == '\n' && _state != State.Ground)
-        {
-            AbandonSequence();
-        }
-
-        switch (_state)
-        {
-            case State.Ground:
-                ProcessGround(ch, ref lines);
-                break;
-
-            case State.Escape:
-                ProcessEscape(ch);
-                break;
-
-            case State.EscapeIntermediate:
-                // Consume the single trailing byte of e.g. ESC ( B and return to ground.
-                _state = State.Ground;
-                break;
-
-            case State.Csi:
-                ProcessCsi(ch);
-                break;
-
-            case State.Osc:
-                ProcessOsc(ch);
-                break;
-
-            case State.OscEscape:
-                // Inside an OSC string we saw ESC; a following '\' is the ST terminator.
-                _state = ch == '\\' ? State.Ground : State.Osc;
-                break;
-        }
-    }
-
-    /// Drops a partly-read escape sequence and returns to ground.
-    private void AbandonSequence()
-    {
-        _seq.Clear();
-        _state = State.Ground;
-    }
-
-    private void ProcessGround(char ch, ref List? lines)
-    {
-        switch (ch)
-        {
-            case '\x1b':
-                _state = State.Escape;
-                break;
-
-            case '\n':
-                CompleteLine(ref lines);
-                break;
-
-            case '\r':
-                // Carriage returns are dropped; scrollback is line-based.
-                break;
-
-            default:
-                _run.Append(ch);
-                break;
-        }
-    }
-
-    private void ProcessEscape(char ch)
-    {
-        switch (ch)
-        {
-            case '[':
-                _seq.Clear();
-                _state = State.Csi;
-                break;
-
-            case ']':
-                _seq.Clear();
-                _state = State.Osc;
-                break;
-
-            case '(':
-            case ')':
-            case '*':
-            case '+':
-            case '#':
-            case '%':
-                _state = State.EscapeIntermediate;
-                break;
-
-            case 'P': // DCS
-            case 'X': // SOS
-            case '^': // PM
-            case '_': // APC
-                // String sequences terminated by ST (ESC \) — consume like an OSC so their
-                // payloads never leak into the output as text.
-                _seq.Clear();
-                _state = State.Osc;
-                break;
-
-            default:
-                // Two-byte escape (ESC c, ESC M, ...) — consumed and ignored.
-                _state = State.Ground;
-                break;
-        }
-    }
-
-    private void ProcessCsi(char ch)
-    {
-        // Final byte is in the range 0x40-0x7E.
-        if (ch is >= '\x40' and <= '\x7e')
-        {
-            if (ch == 'm')
-            {
-                ApplySgr(_seq.ToString());
-            }
-
-            // All other CSI sequences (cursor, erase, ...) are discarded.
-            _seq.Clear();
-            _state = State.Ground;
-            return;
-        }
-
-        // Parameter (0x30-0x3F) and intermediate (0x20-0x2F) bytes accumulate.
-        if (ch is >= '\x20' and <= '\x3f')
-        {
-            if (_seq.Length < MaxSequenceLength)
-            {
-                _seq.Append(ch);
-            }
-
-            return;
-        }
-
-        // Anything else (e.g. an embedded control char) aborts the malformed sequence.
-        _seq.Clear();
-        _state = State.Ground;
-    }
-
-    private void ProcessOsc(char ch)
-    {
-        switch (ch)
-        {
-            case '\x07': // BEL terminator
-                _state = State.Ground;
-                break;
-
-            case '\x1b': // possible ST (ESC \)
-                _state = State.OscEscape;
-                break;
-
-            default:
-                if (_seq.Length < MaxSequenceLength)
-                {
-                    _seq.Append(ch);
-                }
-
-                break;
-        }
-    }
-
-    private void CompleteLine(ref List? lines)
-    {
-        FlushRun();
-        var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans);
-        _lineSpans.Clear();
-        (lines ??= new List()).Add(line);
-    }
-
-    private void FlushRun()
-    {
-        if (_run.Length == 0)
-        {
-            return;
-        }
-
-        _lineSpans.Add(new StyledSpan(_run.ToString(), _current));
-        _run.Clear();
-    }
-
-    private void ApplySgr(string parameters)
-    {
-        // Text accumulated so far keeps the pre-change style.
-        FlushRun();
-        _current = SgrCodes.Apply(_current, parameters);
-    }
-}
diff --git a/src/SharpMUTerm.Core/Text/ILineParser.cs b/src/SharpMUTerm.Core/Text/ILineParser.cs
deleted file mode 100644
index e0d22246..00000000
--- a/src/SharpMUTerm.Core/Text/ILineParser.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace SharpMUTerm.Core.Text;
-
-/// 
-/// An incremental, line-oriented parser that turns decoded server text into styled lines.
-/// Implemented by  and the MXP/Pueblo markup parsers so a session can
-/// select the content format uniformly.
-/// 
-public interface ILineParser
-{
-    /// Feeds a chunk of text, returning every line completed within it.
-    IReadOnlyList Feed(string text);
-
-    /// Returns and clears any buffered partial line (e.g. a prompt), or null.
-    StyledLine? Flush();
-
-    /// Resets all parser state, including the current style.
-    void Reset();
-
-    /// The rendition state that will apply to the next printed character.
-    TextStyle CurrentStyle { get; }
-
-    /// True when a partial line or markup sequence is buffered.
-    bool HasPendingContent { get; }
-}
diff --git a/src/SharpMUTerm.Core/Text/MarkupLineParser.cs b/src/SharpMUTerm.Core/Text/MarkupLineParser.cs
new file mode 100644
index 00000000..62fadd13
--- /dev/null
+++ b/src/SharpMUTerm.Core/Text/MarkupLineParser.cs
@@ -0,0 +1,714 @@
+using MarkupString;
+using MarkupString.Ansi;
+using MarkupString.Ansi.Decoding;
+using MarkupString.Decoding;
+using MarkupString.Mxp.Decoding;
+using MarkupString.Pueblo.Decoding;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Media;
+using SharpMUTerm.Core.Protocols;
+using MsDecoder = MarkupString.Decoding.Decoder;
+
+namespace SharpMUTerm.Core.Text;
+
+/// 
+/// Reads what a server sends into s, through MarkupString's : ANSI and
+/// VT sequences always, and MXP or Pueblo when the connection turns one on. One per connection; it holds that
+/// connection's style, open elements, definitions and pending line.
+/// 
+///  is the line boundary. The telnet layer strips each line's terminator, so a session
+/// feeds a line's text and then flushes; the flush gives the decoder the line feed back, so everything the end of a
+/// line does (MXP's line mode reverting, open-mode tags closing) happens there, and Pueblo's HTML mode, where a line
+/// feed is whitespace, still waits for its <br>.
+/// 
+/// 
+/// The decoded  is folded into SharpMUTerm's own line model here: SGR, MXP and Pueblo styles
+/// into  (palette colours stay palette colours, so a theme can still remap them), links,
+/// commands and menus into , an MXP picture into an , and
+/// the protocol's other output (entities, room flags, gauges, sounds, frames) into this class's events.
+/// 
+/// 
+public sealed class MarkupLineParser
+{
+    /// The most windows one connection's frames may name; past it, text stays in the main window.
+    public const int MaxFrames = 8;
+
+    /// The most commands one MXP <SEND> menu keeps.
+    public const int MaxSendChoices = 20;
+
+    private const int MaxFrameNameLength = 32;
+    private const int MaxCaptionLength = 64;
+
+    /// Every protocol this client reads, shared by every connection.
+    /// 
+    /// A tab is kept as a tab, for the session to expand at the player's tab width
+    /// (), rather than moved to the next multiple of 8 here. MXP's refused tags are
+    /// shown as the text they were, and its <SUPPORT> answer is 's.
+    /// 
+    public static DecoderRegistry Registry { get; } = DecoderRegistry.Empty
+        .WithVt()
+        .With(new LiteralTabs())
+        .WithMxp(new MxpOptions { ShowRefusedTags = true, Supports = MxpClientSupport.Supports })
+        .WithPueblo();
+
+    private readonly MsDecoder _decoder;
+    private readonly HashSet _frames = new(StringComparer.OrdinalIgnoreCase);
+    private readonly Dictionary _entities = new(StringComparer.OrdinalIgnoreCase);
+
+    // The flagged element being read, which may span lines: its markup instance and the text so far.
+    private MxpFlagMarkup? _flag;
+    private readonly System.Text.StringBuilder _flagText = new();
+
+    // Whether the text of the line being read had any markup in it, so a line it all went into is not a blank one.
+    private bool _markupThisLine;
+    private bool _textThisLine;
+
+    /// A reader for : ANSI alone, or with MXP or Pueblo on from the start.
+    public MarkupLineParser(ContentFormat format = ContentFormat.Ansi)
+    {
+        _decoder = Registry.CreateDecoder(new DecoderOptions
+        {
+            Identity = Identity,
+        });
+        _decoder.EventRaised += OnEvent;
+        Format = ContentFormat.Ansi;
+        Switch(format);
+    }
+
+    /// What this client tells a server it is, in an MXP <VERSION> answer.
+    public static ClientIdentity Identity { get; } = new()
+    {
+        Name = MxpClientSupport.ClientName,
+        Version = MxpClientSupport.ClientVersion,
+        // Only MXP's VERSION and SUPPORT are answered. The plain ANSI reader this replaced answered nothing, and a
+        // scrolling pane is not the screen a device-attributes or cursor query assumes.
+        Answers = ClientAnswers.Mxp,
+    };
+
+    /// The markup this reader reads on top of ANSI.
+    public ContentFormat Format { get; private set; }
+
+    /// A line the server is owed: the answer to an MXP <VERSION> or <SUPPORT>, without its line terminator.
+    public event EventHandler? ClientReply;
+
+    /// A server's entity was set or deleted: <!ENTITY>, <VAR>, or an element's FLAG="set x".
+    public event EventHandler? EntityChanged;
+
+    /// The text of an element whose FLAG says what it is (RoomName, RoomDesc, RoomExit…).
+    public event EventHandler? Flagged;
+
+    /// A <GAUGE> or <STAT> on a secure line.
+    public event EventHandler? GaugeDeclared;
+
+    /// A sound or music request (<SOUND>, <MUSIC>, Pueblo's xch_sound).
+    public event EventHandler? MediaRequested;
+
+    /// Text a <DEST>, a redirecting <FRAME> or a Pueblo pane sent to a named window.
+    public event EventHandler? Redirected;
+
+    /// 
+    /// Whether the line the last  ended was taken entirely by markup, or sent to another window, so
+    /// that its returning null is not a blank line the server sent.
+    /// 
+    public bool LineSwallowed { get; private set; }
+
+    /// The value of a server entity this connection was told about, or null.
+    public string? Entity(string name) => _entities.GetValueOrDefault(name);
+
+    /// True when a partial line is buffered.
+    public bool HasPendingContent => _decoder.HasPendingLine;
+
+    /// Turns MXP or Pueblo on (or both off, for ), keeping the style and the pending line.
+    public void Switch(ContentFormat format)
+    {
+        if (format == Format)
+        {
+            return;
+        }
+
+        if (Format != ContentFormat.Ansi)
+        {
+            _decoder.DisableProtocol(Format == ContentFormat.Mxp ? MxpDecoding.Protocol : PuebloDecoding.Protocol);
+        }
+
+        if (format != ContentFormat.Ansi)
+        {
+            _decoder.EnableProtocol(format == ContentFormat.Mxp ? MxpDecoding.Protocol : PuebloDecoding.Protocol);
+        }
+
+        if (format == ContentFormat.Pueblo)
+        {
+            // Read Pueblo's tags from the start, as a world set to Pueblo expects; the server's own
+            //  after the handshake changes nothing then. Lines still end where telnet ends them.
+            _decoder.Feed("");
+        }
+
+        Format = format;
+    }
+
+    /// Feeds a chunk of text, returning every line a line feed (or an MXP <BR>, a Pueblo <br>) completed within it.
+    public IReadOnlyList Feed(string text)
+    {
+        ArgumentNullException.ThrowIfNull(text);
+        if (!text.Contains('\n', StringComparison.Ordinal))
+        {
+            Note(text);
+            return Convert(_decoder.Feed(text));
+        }
+
+        // A line feed ends a line here even in Pueblo's HTML, where the decoder reads it as a space: it is where the
+        // telnet layer would have ended it.
+        var lines = new List();
+        var segments = text.Split('\n');
+        for (var i = 0; i < segments.Length; i++)
+        {
+            Note(segments[i]);
+            lines.AddRange(Convert(_decoder.Feed(segments[i])));
+            if (i < segments.Length - 1)
+            {
+                lines.AddRange(Convert([_decoder.EndLine()]));
+                _markupThisLine = false;
+                _textThisLine = false;
+            }
+        }
+
+        return lines;
+    }
+
+    /// 
+    /// Ends the line being read and returns it, or null when it has nothing to show: nothing was sent, it was all
+    /// markup, or it went to another window ( tells those two from a blank line).
+    /// 
+    public StyledLine? Flush()
+    {
+        var markup = _markupThisLine;
+        var text = _textThisLine;
+        // The telnet layer took the line's terminator: end the line here whatever the protocol makes of a line feed.
+        var lines = Convert([_decoder.EndLine()], out var redirected);
+        _markupThisLine = false;
+        _textThisLine = false;
+        LineSwallowed = false;
+        var line = lines.Count > 0 ? lines[^1] : null;
+        if (line is { IsEmpty: false })
+        {
+            return line;
+        }
+
+        LineSwallowed = redirected || (markup && text);
+        return null;
+    }
+
+    /// Forgets the style, open elements, definitions and the pending line, and the windows frames named.
+    public void Reset()
+    {
+        var format = Format;
+        _decoder.Reset();
+        Format = ContentFormat.Ansi;
+        Switch(format);
+        _frames.Clear();
+        _entities.Clear();
+        _flag = null;
+        _flagText.Clear();
+        _markupThisLine = false;
+        _textThisLine = false;
+        LineSwallowed = false;
+    }
+
+    /// Reads a whole string of ANSI text at once, including a last line with no line break.
+    public static IReadOnlyList ParseAll(string text, ContentFormat format = ContentFormat.Ansi)
+    {
+        ArgumentNullException.ThrowIfNull(text);
+        var parser = new MarkupLineParser(format);
+        var lines = new List(parser.Feed(text));
+        if (parser.HasPendingContent && parser.Flush() is { } last)
+        {
+            lines.Add(last);
+        }
+
+        return lines;
+    }
+
+    private void Note(string text)
+    {
+        if (text.Length == 0)
+        {
+            return;
+        }
+
+        _textThisLine = true;
+        if (Format != ContentFormat.Ansi && text.AsSpan().IndexOfAny('<', '\x1b') >= 0)
+        {
+            _markupThisLine = true;
+        }
+    }
+
+    private IReadOnlyList Convert(IReadOnlyList lines) => Convert(lines, out _);
+
+    private IReadOnlyList Convert(IReadOnlyList lines, out bool redirected)
+    {
+        redirected = false;
+        if (lines.Count == 0)
+        {
+            return Array.Empty();
+        }
+
+        var result = new List(lines.Count);
+        foreach (var decoded in lines)
+        {
+            var (line, sent) = Fold(decoded);
+            redirected |= sent;
+            if (line is not null)
+            {
+                result.Add(line);
+            }
+        }
+
+        return result;
+    }
+
+    /// One decoded line as the line for the main window (null when all of it went elsewhere), and whether any went elsewhere.
+    private (StyledLine? Line, bool Redirected) Fold(DisplayLine decoded)
+    {
+        var content = decoded.Content;
+        var main = new List();
+        var panes = new List<(string Name, List Spans)>();
+        var lineFrame = FrameName(decoded.Destination);
+        MxpFlagMarkup? flagSeen = null;
+        var at = 0;
+
+        foreach (var run in content.Runs)
+        {
+            if (run.Start > at)
+            {
+                Add(main, content.Text.Substring(at, run.Start - at), TextStyle.Default, null);
+            }
+
+            at = run.End;
+            var markups = run.Markups;
+            if (Point(markups))
+            {
+                continue;
+            }
+
+            var text = content.Text.Substring(run.Start, run.Length);
+            var (style, interaction, pane, flag) = Style(markups, ref text);
+            if (flag is not null)
+            {
+                flagSeen = flag;
+                Flag(flag, text);
+            }
+
+            if (pane is not null && FrameName(pane.Name) is { } name)
+            {
+                var target = panes.FindIndex(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+                if (target < 0)
+                {
+                    panes.Add((name, new List()));
+                    target = panes.Count - 1;
+                }
+
+                Add(panes[target].Spans, text, style, interaction);
+                continue;
+            }
+
+            Add(main, text, style, interaction);
+        }
+
+        if (at < content.Text.Length)
+        {
+            Add(main, content.Text[at..], TextStyle.Default, null);
+        }
+
+        EndFlags(flagSeen, lineEnd: true);
+
+        foreach (var (name, spans) in panes)
+        {
+            Redirected?.Invoke(this, new MxpRedirect(name, new StyledLine(spans)));
+        }
+
+        var line = new StyledLine(main, isPrompt: decoded.Kind == LineKind.Prompt);
+        if (lineFrame is not null)
+        {
+            if (!line.IsEmpty)
+            {
+                Redirected?.Invoke(this, new MxpRedirect(lineFrame, line));
+            }
+
+            return (null, true);
+        }
+
+        return (line, panes.Count > 0);
+    }
+
+    private static void Add(List spans, string text, TextStyle style, SpanInteraction? interaction)
+    {
+        if (text.Length == 0)
+        {
+            return;
+        }
+
+        if (spans.Count > 0 && spans[^1].Style == style && Equals(spans[^1].Interaction, interaction))
+        {
+            spans[^1] = new StyledSpan(spans[^1].Text + text, style, interaction);
+            return;
+        }
+
+        spans.Add(new StyledSpan(text, style, interaction));
+    }
+
+    /// A run that is a point (a sound, a bell, a clear): acts on it and says so, since it has no text to show.
+    private bool Point(MarkupSet markups)
+    {
+        var point = false;
+        foreach (var markup in markups)
+        {
+            switch (markup)
+            {
+                case SoundMarkup sound:
+                    MediaRequested?.Invoke(this, Sound(sound));
+                    point = true;
+                    break;
+                case SoundStopMarkup stop:
+                    MediaRequested?.Invoke(this, new MediaCommand.Stop(new MediaFilter
+                    {
+                        Kind = stop.Channel == SoundChannel.Music ? MediaKind.Music : stop.Channel is null ? null : MediaKind.Sound,
+                    }));
+                    point = true;
+                    break;
+                case IPointMarkup:
+                    point = true;
+                    break;
+            }
+        }
+
+        return point;
+    }
+
+    private static MediaCommand Sound(SoundMarkup sound)
+    {
+        var kind = sound.Channel == SoundChannel.Music ? MediaKind.Music : MediaKind.Sound;
+        var fields = new Dictionary();
+        if (sound.Volume is { } volume)
+        {
+            fields['V'] = volume.ToString(System.Globalization.CultureInfo.InvariantCulture);
+        }
+
+        if (sound.Repeats is { } repeats)
+        {
+            fields['L'] = repeats.ToString(System.Globalization.CultureInfo.InvariantCulture);
+        }
+
+        if (sound.Priority is { } priority)
+        {
+            fields['P'] = priority.ToString(System.Globalization.CultureInfo.InvariantCulture);
+        }
+
+        if (sound.Type is { } type)
+        {
+            fields['T'] = type;
+        }
+
+        if (kind == MediaKind.Music)
+        {
+            fields['C'] = sound.Continues ? "1" : "0";
+        }
+
+        // MarkupString puts MSP's U= in front of the file; the media cache wants them apart, a relative name and the
+        // folder it is fetched from.
+        var name = sound.Source;
+        if (Uri.TryCreate(name, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
+        {
+            var slash = name.LastIndexOf('/');
+            fields['U'] = name[..(slash + 1)];
+            name = name[(slash + 1)..];
+        }
+
+        return MspTrigger.FromFields(kind, name, fields);
+    }
+
+    /// 
+    /// Folds one run's markup, innermost last, into SharpMUTerm's style and interaction, and finds the pane it is sent
+    /// to and the flag over it.  is replaced for a picture, whose text is its label.
+    /// 
+    private static (TextStyle Style, SpanInteraction? Interaction, PaneMarkup? Pane, MxpFlagMarkup? Flag) Style(MarkupSet markups, ref string text)
+    {
+        var folded = AnsiStyle.None;
+        CommandMenuMarkup? menu = null;
+        ImageMarkup? image = null;
+        PaneMarkup? pane = null;
+        MxpFlagMarkup? flag = null;
+        for (var i = markups.Count - 1; i >= 0; i--)
+        {
+            var markup = markups[i];
+            if (markup is IAnsiStyleSource source && source.TryGetAnsiStyle(MarkupFormat.Ansi, out var layer))
+            {
+                folded = folded.Combine(layer);
+            }
+
+            switch (markup)
+            {
+                case CommandMenuMarkup m:
+                    menu = m;
+                    break;
+                case ImageMarkup m:
+                    image = m;
+                    break;
+                case PaneMarkup m:
+                    pane = m;
+                    break;
+                case MxpFlagMarkup m:
+                    flag = m;
+                    break;
+            }
+        }
+
+        var style = new TextStyle(Color(folded.Foreground), Color(folded.Background), Attributes(folded));
+        var interaction = Interaction(folded, menu);
+        if (image is not null)
+        {
+            interaction = Picture(image, interaction, ref text);
+        }
+
+        return (style, interaction, pane, flag);
+    }
+
+    private static SpanInteraction? Interaction(AnsiStyle style, CommandMenuMarkup? menu)
+    {
+        if (string.IsNullOrEmpty(style.LinkUrl))
+        {
+            return null;
+        }
+
+        if (style.LinkKind != LinkKind.Command)
+        {
+            return SpanInteraction.Link(style.LinkUrl, Hint(style.LinkText));
+        }
+
+        var command = SpanInteraction.Command(style.LinkUrl, Hint(style.LinkText ?? menu?.Hint), menu?.Prompt == true);
+        if (menu is null || menu.Choices.Count < 2)
+        {
+            return command;
+        }
+
+        var choices = menu.Choices.Take(MaxSendChoices).Select(c => new SendChoice(c.Label, c.Command)).ToArray();
+        return command with { Choices = choices };
+    }
+
+    private static string? Hint(string? hint) => string.IsNullOrEmpty(hint) ? null : hint;
+
+    /// 
+    /// An MXP picture: its text becomes the [image: name] label a terminal without pictures shows, and the span
+    /// carries the request to draw it. Inside a link it keeps the link's action; on its own it opens the picture. A
+    /// picture the server left cells for (sized in cells both ways) is drawn in them: its first row's blanks become
+    /// the label, cut to fit, and the rows under it stay the blanks they are.
+    /// 
+    private static SpanInteraction? Picture(ImageMarkup image, SpanInteraction? enclosing, ref string text)
+    {
+        if (image.Row is { } row)
+        {
+            if (row.Row > 0)
+            {
+                return enclosing;
+            }
+
+            var url = MxpImageSource.Resolve(image.Source, null, null);
+            var name = MxpImageSource.DisplayName(image.Source);
+            var label = $"[image: {name}]";
+            var cells = text.Length;
+            label = label.Length <= cells ? label.PadRight(cells) : cells > 1 ? label[..(cells - 1)] + "…" : label[..cells];
+            text = label;
+            if (url is null)
+            {
+                return enclosing;
+            }
+
+            var reserved = new InlineImageRequest(url, name, new ImageExtent(row.Columns, ImageExtentUnit.Characters),
+                new ImageExtent(row.Rows, ImageExtentUnit.Characters)) { Reserved = true };
+            return (enclosing ?? SpanInteraction.Link(url)) with { Image = reserved };
+        }
+
+        var source = MxpImageSource.Resolve(image.Source, null, null);
+        var display = MxpImageSource.DisplayName(string.IsNullOrWhiteSpace(text) ? image.Source : text);
+        text = $"[image: {display}]";
+        if (source is null)
+        {
+            return enclosing;
+        }
+
+        ImageExtent? width = image.Width is > 0 and <= ImageExtent.MaxValue ? new ImageExtent(image.Width.Value, ImageExtentUnit.Pixels) : null;
+        ImageExtent? height = image.Height is > 0 and <= ImageExtent.MaxValue ? new ImageExtent(image.Height.Value, ImageExtentUnit.Pixels) : null;
+        var request = new InlineImageRequest(source, display, width, height);
+        return (enclosing ?? SpanInteraction.Link(source)) with { Image = request };
+    }
+
+    private static TerminalColor Color(AnsiColor? color) => color switch
+    {
+        AnsiColor.Standard standard => TerminalColor.FromIndex(standard.Index + (standard.Bright ? 8 : 0)),
+        AnsiColor.Xterm xterm => TerminalColor.FromIndex(xterm.Index),
+        AnsiColor.Rgb rgb => TerminalColor.FromRgb(rgb.R, rgb.G, rgb.B),
+        _ => TerminalColor.Default,
+    };
+
+    private static TextAttributes Attributes(AnsiStyle style)
+    {
+        var attributes = TextAttributes.None;
+        if (style.Bold)
+        {
+            attributes |= TextAttributes.Bold;
+        }
+
+        if (style.Faint)
+        {
+            attributes |= TextAttributes.Faint;
+        }
+
+        if (style.Italic)
+        {
+            attributes |= TextAttributes.Italic;
+        }
+
+        if (style.Underlined)
+        {
+            attributes |= TextAttributes.Underline;
+        }
+
+        if (style.Blink)
+        {
+            attributes |= TextAttributes.Blink;
+        }
+
+        if (style.Inverted)
+        {
+            attributes |= TextAttributes.Reverse;
+        }
+
+        if (style.Concealed)
+        {
+            attributes |= TextAttributes.Conceal;
+        }
+
+        if (style.StrikeThrough)
+        {
+            attributes |= TextAttributes.Strikethrough;
+        }
+
+        return attributes;
+    }
+
+    /// A frame's name as a window: one of this connection's few, or null for the main window.
+    private string? FrameName(string? name)
+    {
+        if (string.IsNullOrEmpty(name) || name.Length > MaxFrameNameLength ||
+            name.Equals("_top", StringComparison.OrdinalIgnoreCase) || name.Equals("_previous", StringComparison.OrdinalIgnoreCase))
+        {
+            return null;
+        }
+
+        if (_frames.Contains(name))
+        {
+            return name;
+        }
+
+        if (_frames.Count >= MaxFrames)
+        {
+            return null;
+        }
+
+        _frames.Add(name);
+        return name;
+    }
+
+    // ── Flags ────────────────────────────────────────────────────────────────────
+
+    private void Flag(MxpFlagMarkup flag, string text)
+    {
+        if (!ReferenceEquals(flag, _flag))
+        {
+            EndFlags(null, lineEnd: false);
+            _flag = flag;
+        }
+
+        _flagText.Append(text);
+    }
+
+    /// 
+    /// Ends the flagged element being read when it is over: at a line end where  (the flag the
+    /// line ended in) is not it, or when another begins. One left open at the line end goes on to the next line.
+    /// 
+    private void EndFlags(MxpFlagMarkup? seen, bool lineEnd)
+    {
+        if (_flag is not { } flag)
+        {
+            return;
+        }
+
+        if (lineEnd && ReferenceEquals(seen, flag) && _decoder.OpenElements.Contains(flag))
+        {
+            // Still open: a secure element spans lines, and its text keeps them.
+            _flagText.Append('\n');
+            return;
+        }
+
+        var text = _flagText.ToString().TrimEnd('\n');
+        _flagText.Clear();
+        _flag = null;
+        var name = flag.Flag;
+        if (name.StartsWith("set ", StringComparison.OrdinalIgnoreCase) || name.StartsWith("private ", StringComparison.OrdinalIgnoreCase))
+        {
+            var variable = name[(name.IndexOf(' ', StringComparison.Ordinal) + 1)..].Trim();
+            if (variable.Length > 0 && !name.StartsWith("private ", StringComparison.OrdinalIgnoreCase))
+            {
+                SetEntity(variable, text);
+            }
+
+            return;
+        }
+
+        Flagged?.Invoke(this, new MxpFlagText(name, text));
+    }
+
+    private void SetEntity(string name, string? value)
+    {
+        if (value is null)
+        {
+            _entities.Remove(name);
+        }
+        else
+        {
+            _entities[name] = value;
+        }
+
+        EntityChanged?.Invoke(this, new MxpEntityChange(name, value));
+    }
+
+    /// Claims a tab and writes it as it is, for the session to expand.
+    private sealed class LiteralTabs : IDecoderPlugin
+    {
+        public string Name => "sharpmuterm-tabs";
+
+        public void Register(DecoderBuilder builder) => builder.OnControl('\t', static (_, context) =>
+        {
+            context.Write("\t");
+            return true;
+        });
+    }
+
+    // ── Events ───────────────────────────────────────────────────────────────────
+
+    private void OnEvent(DecodeEvent e)
+    {
+        switch (e)
+        {
+            case ReplyEvent reply:
+                ClientReply?.Invoke(this, reply.Text.TrimEnd('\r', '\n'));
+                break;
+            case VariableEvent variable:
+                SetEntity(variable.Name, variable.Value);
+                break;
+            case GaugeEvent gauge:
+                GaugeDeclared?.Invoke(this, new MxpGauge(gauge.Variable, gauge.Maximum,
+                    gauge.Caption is { Length: > 0 } caption ? caption[..Math.Min(caption.Length, MaxCaptionLength)] : null, gauge.Status));
+                break;
+        }
+    }
+}
diff --git a/src/SharpMUTerm.Core/Text/SgrCodes.cs b/src/SharpMUTerm.Core/Text/SgrCodes.cs
deleted file mode 100644
index 35fea7f5..00000000
--- a/src/SharpMUTerm.Core/Text/SgrCodes.cs
+++ /dev/null
@@ -1,205 +0,0 @@
-namespace SharpMUTerm.Core.Text;
-
-/// 
-/// Decodes an ECMA-48 SGR parameter string into a .
-/// 
-/// 
-/// Extracted from  so  can
-/// share it: MXP explicitly permits ANSI inside a document ("ANSI and VT100 codes can still be used
-/// as normal"), and two implementations of SGR would drift the moment one gained a colour format the
-/// other did not. Pure — it takes the current style and returns the next one — because the two
-/// parsers keep that state in different places.
-/// 
-public static class SgrCodes
-{
-    /// 
-    /// Applies one SGR sequence's parameters to .
-    /// 
-    /// The style in force before the sequence.
-    /// 
-    /// The CSI parameter string with no ESC[ prefix and no trailing m, for example
-    /// "0;33". Empty means a reset, which is what a bare ESC[m encodes.
-    /// 
-    public static TextStyle Apply(TextStyle current, string parameters)
-    {
-        if (parameters.Length == 0)
-        {
-            return TextStyle.Default;
-        }
-
-        var tokens = parameters.Split(';');
-        for (var i = 0; i < tokens.Length; i++)
-        {
-            var token = tokens[i];
-
-            // Colon-delimited extended colour, e.g. "38:5:196" or "38:2:255:0:0".
-            if (token.IndexOf(':') >= 0)
-            {
-                current = ApplyColonColor(current, token);
-                continue;
-            }
-
-            if (!int.TryParse(token, out var code))
-            {
-                code = 0; // An empty parameter is treated as 0 (reset).
-            }
-
-            switch (code)
-            {
-                case 0:
-                    current = TextStyle.Default;
-                    break;
-                case 1:
-                    current = current.AddAttribute(TextAttributes.Bold);
-                    break;
-                case 2:
-                    current = current.AddAttribute(TextAttributes.Faint);
-                    break;
-                case 3:
-                    current = current.AddAttribute(TextAttributes.Italic);
-                    break;
-                case 4:
-                    current = current.AddAttribute(TextAttributes.Underline);
-                    break;
-                case 5:
-                case 6:
-                    current = current.AddAttribute(TextAttributes.Blink);
-                    break;
-                case 7:
-                    current = current.AddAttribute(TextAttributes.Reverse);
-                    break;
-                case 8:
-                    current = current.AddAttribute(TextAttributes.Conceal);
-                    break;
-                case 9:
-                    current = current.AddAttribute(TextAttributes.Strikethrough);
-                    break;
-                case 21:
-                case 22:
-                    current = current.RemoveAttribute(TextAttributes.Bold | TextAttributes.Faint);
-                    break;
-                case 23:
-                    current = current.RemoveAttribute(TextAttributes.Italic);
-                    break;
-                case 24:
-                    current = current.RemoveAttribute(TextAttributes.Underline);
-                    break;
-                case 25:
-                    current = current.RemoveAttribute(TextAttributes.Blink);
-                    break;
-                case 27:
-                    current = current.RemoveAttribute(TextAttributes.Reverse);
-                    break;
-                case 28:
-                    current = current.RemoveAttribute(TextAttributes.Conceal);
-                    break;
-                case 29:
-                    current = current.RemoveAttribute(TextAttributes.Strikethrough);
-                    break;
-                case >= 30 and <= 37:
-                    current = current.WithForeground(TerminalColor.FromIndex(code - 30));
-                    break;
-                case 38:
-                    current = current.WithForeground(ParseExtendedColor(tokens, ref i) ?? current.Foreground);
-                    break;
-                case 39:
-                    current = current.WithForeground(TerminalColor.Default);
-                    break;
-                case >= 40 and <= 47:
-                    current = current.WithBackground(TerminalColor.FromIndex(code - 40));
-                    break;
-                case 48:
-                    current = current.WithBackground(ParseExtendedColor(tokens, ref i) ?? current.Background);
-                    break;
-                case 49:
-                    current = current.WithBackground(TerminalColor.Default);
-                    break;
-                case >= 90 and <= 97:
-                    current = current.WithForeground(TerminalColor.FromIndex(code - 90 + 8));
-                    break;
-                case >= 100 and <= 107:
-                    current = current.WithBackground(TerminalColor.FromIndex(code - 100 + 8));
-                    break;
-                default:
-                    // Unknown SGR code — ignored.
-                    break;
-            }
-        }
-
-        return current;
-    }
-
-    /// 
-    /// Parses the semicolon-form extended colour that follows a 38/48 code, advancing
-    ///  past the consumed tokens. Returns null if malformed.
-    /// 
-    private static TerminalColor? ParseExtendedColor(string[] tokens, ref int i)
-    {
-        if (i + 1 >= tokens.Length || !int.TryParse(tokens[i + 1], out var mode))
-        {
-            return null;
-        }
-
-        switch (mode)
-        {
-            case 5 when i + 2 < tokens.Length && int.TryParse(tokens[i + 2], out var idx) && idx is >= 0 and <= 255:
-                i += 2;
-                return TerminalColor.FromIndex(idx);
-
-            case 2 when i + 4 < tokens.Length &&
-                        byte.TryParse(tokens[i + 2], out var r) &&
-                        byte.TryParse(tokens[i + 3], out var g) &&
-                        byte.TryParse(tokens[i + 4], out var b):
-                i += 4;
-                return TerminalColor.FromRgb(r, g, b);
-
-            default:
-                return null;
-        }
-    }
-
-    /// Parses a single colon-delimited extended colour token and applies it.
-    private static TextStyle ApplyColonColor(TextStyle current, string token)
-    {
-        var parts = token.Split(':');
-        if (parts.Length < 3 || !int.TryParse(parts[0], out var target))
-        {
-            return current;
-        }
-
-        var isForeground = target == 38;
-        if (target is not (38 or 48))
-        {
-            return current;
-        }
-
-        if (!int.TryParse(parts[1], out var mode))
-        {
-            return current;
-        }
-
-        TerminalColor? colour = null;
-        if (mode == 5 && int.TryParse(parts[2], out var idx) && idx is >= 0 and <= 255)
-        {
-            colour = TerminalColor.FromIndex(idx);
-        }
-        else if (mode == 2 && parts.Length >= 5)
-        {
-            // ISO form may carry a colour-space id at parts[2]; the RGB triple is the last three.
-            var baseIndex = parts.Length >= 6 ? parts.Length - 3 : 2;
-            if (byte.TryParse(parts[baseIndex], out var r) &&
-                byte.TryParse(parts[baseIndex + 1], out var g) &&
-                byte.TryParse(parts[baseIndex + 2], out var b))
-            {
-                colour = TerminalColor.FromRgb(r, g, b);
-            }
-        }
-
-        if (colour is null)
-        {
-            return current;
-        }
-
-        return isForeground ? current.WithForeground(colour.Value) : current.WithBackground(colour.Value);
-    }
-}
diff --git a/src/SharpMUTerm.Core/Text/TextStyle.cs b/src/SharpMUTerm.Core/Text/TextStyle.cs
index fcd64a04..63a29050 100644
--- a/src/SharpMUTerm.Core/Text/TextStyle.cs
+++ b/src/SharpMUTerm.Core/Text/TextStyle.cs
@@ -17,7 +17,7 @@ public enum TextAttributes
 
 /// 
 /// An immutable snapshot of terminal rendition state: foreground, background, and
-/// attribute flags. Produced by  and consumed by the renderer.
+/// attribute flags. Produced by  and consumed by the renderer.
 /// 
 public readonly struct TextStyle : IEquatable
 {
diff --git a/src/SharpMUTerm.Tui/LinkPayload.cs b/src/SharpMUTerm.Tui/LinkPayload.cs
index df17f98c..ed28d9fb 100644
--- a/src/SharpMUTerm.Tui/LinkPayload.cs
+++ b/src/SharpMUTerm.Tui/LinkPayload.cs
@@ -37,7 +37,7 @@ internal enum LinkAction
 /// or hard to guess — a world can put any characters it likes in an <A HREF>, including
 /// these. What a world cannot choose is which  its markup parses to:
 /// <SEND> becomes  and <A HREF>
-/// becomes , decided by MxpParser/PuebloParser.
+/// becomes , decided by MarkupString's decoder (MarkupLineParser).
 /// Wrapping the hyperlink case in a scheme of its own is what makes the namespaces disjoint.
 /// 
 /// 
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index aa335b9d..c8eff2f4 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -1372,7 +1372,7 @@ public string RenderSnapshot(string? view = null)
             PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight);
             RebuildPaneArea();
 
-            var parser = new AnsiParser();
+            var parser = new MarkupLineParser();
             foreach (var text in new[]
             {
                 "\x1b[0;37mThe noticeboard reads: patch notes at\x1b[0m",
@@ -1394,7 +1394,7 @@ public string RenderSnapshot(string? view = null)
         // leaves the machine.
         if (string.Equals(view, "mxp-image", StringComparison.OrdinalIgnoreCase))
         {
-            var mxp = new MxpParser();
+            var mxp = new MarkupLineParser(ContentFormat.Mxp);
             foreach (var text in new[]
             {
                 "\x1b[1zThe survey office has pinned up a map of the coast road:",
@@ -1416,7 +1416,7 @@ public string RenderSnapshot(string? view = null)
         // a snapshot host draws no menus, as a terminal does not, so the client's own chooser opens.
         if (string.Equals(view, "send-menu", StringComparison.OrdinalIgnoreCase))
         {
-            var mxp = new MxpParser();
+            var mxp = new MarkupLineParser(ContentFormat.Mxp);
             foreach (var text in new[]
             {
                 "A merchant lays out her wares on a striped cloth.",
@@ -1462,7 +1462,7 @@ public string RenderSnapshot(string? view = null)
             }
 
             rows.Add("\x1b[0;36m╰" + new string('─', inner) + "╯\x1b[0m");
-            var mxp = new MxpParser();
+            var mxp = new MarkupLineParser(ContentFormat.Mxp);
             foreach (var text in rows)
             {
                 mxp.Feed(text);
@@ -1578,7 +1578,7 @@ public string RenderSnapshot(string? view = null)
         if (string.Equals(view, "freeze", StringComparison.OrdinalIgnoreCase))
         {
             ToggleFreeze();
-            var parser = new AnsiParser();
+            var parser = new MarkupLineParser();
             foreach (var text in new[]
             {
                 "\x1b[0;32mA courier\x1b[0m jogs in from the east, breathless.",
@@ -1598,7 +1598,7 @@ public string RenderSnapshot(string? view = null)
         // writer produces differently is a frame that shows something nobody will ever see.
         if (string.Equals(view, "prompt", StringComparison.OrdinalIgnoreCase))
         {
-            var parser = new AnsiParser();
+            var parser = new MarkupLineParser();
             var prompt = parser.Feed("\x1b[0;33mDo you want to be listed at right, Pemberton (Y/N)?\x1b[0m ")
                 .FirstOrDefault() ?? parser.Flush() ?? StyledLine.Empty;
             ShowPromptRow(prompt);
@@ -2783,7 +2783,7 @@ private void LoadDemoScene()
     {
         InitDemoRuntimeState();
 
-        var parser = new AnsiParser();
+        var parser = new MarkupLineParser();
         void Feed(string windowId, string ansiLine)
         {
             foreach (var line in parser.Feed(ansiLine + "\n"))
@@ -2817,7 +2817,7 @@ void Feed(string windowId, string ansiLine)
         // leave it in the background with unread, as if lines arrived while another tab was focused.
         var chatId = DemoScene.ChatWindowId;
         PaneContentFor(chatId, "Chat");
-        var chatParser = new AnsiParser();
+        var chatParser = new MarkupLineParser();
         foreach (var text in new[]
         {
             "\x1b[1;35m[Chat]\x1b[0m Rivane: anyone up for the crypt run?",
diff --git a/src/SharpMUTerm.Web/WebPageFetcher.cs b/src/SharpMUTerm.Web/WebPageFetcher.cs
index 91569b69..58787145 100644
--- a/src/SharpMUTerm.Web/WebPageFetcher.cs
+++ b/src/SharpMUTerm.Web/WebPageFetcher.cs
@@ -60,13 +60,7 @@ public async Task FetchAsync(string url, int width = 80, CancellationTo
             }
 
             // Non-HTML: render as plain text lines.
-            var parser = new Core.Text.AnsiParser();
-            var textLines = parser.Feed(body.Replace("\r\n", "\n")).ToList();
-            var tail = parser.Flush();
-            if (tail is not null)
-            {
-                textLines.Add(tail);
-            }
+            var textLines = Core.Text.MarkupLineParser.ParseAll(body.Replace("\r\n", "\n")).ToList();
 
             return new WebPage(finalUri.ToString(), finalUri.Host, textLines);
         }
diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs
index 90761910..3169173d 100644
--- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs
@@ -1,3 +1,5 @@
+using MarkupString.Mxp.Decoding;
+using SharpMUTerm.Core.Configuration;
 using SharpMUTerm.Core.Mapping;
 using SharpMUTerm.Core.Media;
 using SharpMUTerm.Core.Protocols;
@@ -13,7 +15,7 @@ public class MxpDefinitionTests
 {
     private const string Secure = "\x1b[1z";
 
-    private static List Lines(MxpParser parser, params string[] lines)
+    private static List Lines(MarkupLineParser parser, params string[] lines)
     {
         var result = new List();
         foreach (var text in lines)
@@ -30,7 +32,7 @@ public class MxpDefinitionTests
     [Test]
     public async Task AnUnclosedTagEndsWithItsLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, "a < b and x", "y");
 
         await Assert.That(lines[0]!.Text).IsEqualTo("x");
@@ -52,7 +54,7 @@ public async Task AQuoteHoldsATagOpenOnlyWhereAValueCanStart()
     [Test]
     public async Task AnElementOpensItsDefinitionAndItsCloserClosesIt()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, Secure + "'>", Secure + "Danger ahead");
 
         await Assert.That(lines[0]).IsNull();
@@ -65,7 +67,7 @@ public async Task AnElementOpensItsDefinitionAndItsCloserClosesIt()
     [Test]
     public async Task ALineThatIsOnlyDefinitionsIsSwallowedNotBlank()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         parser.Feed(Secure + "'>");
         await Assert.That(parser.Flush()).IsNull();
@@ -79,7 +81,7 @@ public async Task ALineThatIsOnlyDefinitionsIsSwallowedNotBlank()
     [Test]
     public async Task AttributesAreFilledInByNameByPositionOrByDefault()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(
             parser,
             Secure + "' ATT='dir tip=Walk'>",
@@ -95,18 +97,19 @@ public async Task AttributesAreFilledInByNameByPositionOrByDefault()
     [Test]
     public async Task ADefinitionOnAnOpenLineIsRefusedAndEchoed()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, "'>", Secure + "x");
 
+        // Shown as it was typed, and the element it would have made is unknown, so its tag is dropped even on a secure line.
         await Assert.That(lines[0]!.Text).IsEqualTo("'>");
-        await Assert.That(parser.ElementCount).IsEqualTo(0);
+        await Assert.That(lines[1]!.Text).IsEqualTo("x");
         await Assert.That(lines[1]!.Spans.Any(s => s.IsInteractive)).IsFalse();
     }
 
     [Test]
     public async Task APlayerMayUseAnOpenElementButItCannotCarryASecureTag()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(
             parser,
             Secure + "' ATT='c' OPEN>",
@@ -123,7 +126,7 @@ public async Task APlayerMayUseAnOpenElementButItCannotCarryASecureTag()
     [Test]
     public async Task ANonOpenElementOnAnOpenLineIsEchoed()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, Secure + "'>", "x");
 
         await Assert.That(lines[1]!.Text).IsEqualTo("x");
@@ -132,16 +135,26 @@ public async Task ANonOpenElementOnAnOpenLineIsEchoed()
     [Test]
     public async Task ABuiltInCannotBeRedefined()
     {
-        var parser = new MxpParser();
-        Lines(parser, Secure + "'>", Secure + "' OPEN>", Secure + "'>");
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
+        var lines = Lines(
+            parser,
+            Secure + "'>",
+            Secure + "' OPEN>",
+            Secure + "'>",
+            Secure + "x y z");
 
-        await Assert.That(parser.ElementCount).IsEqualTo(0);
+        var spans = lines[3]!.Spans;
+        await Assert.That(spans.Single(s => s.Text == "x").IsInteractive).IsTrue();
+        await Assert.That(spans.Single(s => s.Text == "x").Style.HasAttribute(TextAttributes.Bold)).IsFalse();
+        await Assert.That(spans.Single(s => s.Text == "y").Style.HasAttribute(TextAttributes.Bold)).IsFalse();
+        await Assert.That(spans.Single(s => s.Text == "z").Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+        await Assert.That(spans.Single(s => s.Text == "z").Style.HasAttribute(TextAttributes.Italic)).IsFalse();
     }
 
     [Test]
     public async Task ExpansionIsCappedInDepthAndPerLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         Lines(
             parser,
             Secure + "'>",
@@ -158,32 +171,37 @@ public async Task ExpansionIsCappedInDepthAndPerLine()
     [Test]
     public async Task ALongDefinitionAndTooManyElementsAreRefused()
     {
-        var parser = new MxpParser();
-        Lines(parser, Secure + $"");
-        await Assert.That(parser.ElementCount).IsEqualTo(0);
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
+        var big = Lines(parser, Secure + $"{new string('x', MxpPlugin.MaxDefinitionLength)}'>", Secure + "y");
+        await Assert.That(big[1]!.Text).IsEqualTo("y");
 
-        Lines(parser, Enumerable.Range(0, MxpParser.MaxElements + 10).Select(i => Secure + $"'>").ToArray());
-        await Assert.That(parser.ElementCount).IsEqualTo(MxpParser.MaxElements);
+        Lines(parser, Enumerable.Range(0, MxpPlugin.MaxElements + 10).Select(i => Secure + $"'>").ToArray());
+        var last = Lines(parser, Secure + $"kept|refused")[0]!;
+        await Assert.That(last.Text).IsEqualTo("kept|refused");
+        await Assert.That(last.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue();
+        await Assert.That(last.Spans[^1].Style.HasAttribute(TextAttributes.Bold)).IsFalse();
     }
 
     [Test]
     public async Task DeleteRemovesAnElement()
     {
-        var parser = new MxpParser();
-        Lines(parser, Secure + "'>", Secure + "");
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
+        var lines = Lines(parser, Secure + "'>", Secure + "", Secure + "x");
 
-        await Assert.That(parser.ElementCount).IsEqualTo(0);
+        await Assert.That(lines[2]!.Spans.Single().Style.HasAttribute(TextAttributes.Bold)).IsFalse();
     }
 
     [Test]
     public async Task AReconnectStartsWithoutDefinitions()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         Lines(parser, Secure + "'>", Secure + "");
 
         parser.Reset();
 
-        await Assert.That(parser.ElementCount).IsEqualTo(0);
+        var line = Lines(parser, Secure + "x &hp;")[0]!;
+        await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsFalse();
+        await Assert.That(line.Text).IsEqualTo("x &hp;");
         await Assert.That(parser.Entity("hp")).IsNull();
     }
 
@@ -192,7 +210,7 @@ public async Task AReconnectStartsWithoutDefinitions()
     [Test]
     public async Task AnEntityIsSetExpandedAddedToRemovedAndDeleted()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var changes = new List();
         parser.EntityChanged += (_, e) => changes.Add(e);
 
@@ -212,7 +230,7 @@ await Assert.That(changes.Select(c => (c.Name, c.Value))).IsEquivalentTo(
     [Test]
     public async Task APrivateEntityExpandsButIsNotAnnounced()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var changes = new List();
         parser.EntityChanged += (_, e) => changes.Add(e);
 
@@ -225,7 +243,7 @@ public async Task APrivateEntityExpandsButIsNotAnnounced()
     [Test]
     public async Task ABuiltInEntityCannotBeRedefinedAndAnEntityIsTextNotMarkup()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, Secure + "", Secure + "'>", Secure + "<&tag;");
 
         await Assert.That(lines[2]!.Text).IsEqualTo("<");
@@ -235,7 +253,7 @@ public async Task ABuiltInEntityCannotBeRedefinedAndAnEntityIsTextNotMarkup()
     [Test]
     public async Task VarShowsItsTextAndStoresIt()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, Secure + "Gold: 120");
 
         await Assert.That(lines[0]!.Text).IsEqualTo("Gold: 120");
@@ -245,7 +263,7 @@ public async Task VarShowsItsTextAndStoresIt()
     [Test]
     public async Task AnElementFlaggedSetStoresItsText()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         Lines(parser, Secure + "", Secure + "HP: 17");
 
         await Assert.That(parser.Entity("hp")).IsEqualTo("17");
@@ -254,7 +272,7 @@ public async Task AnElementFlaggedSetStoresItsText()
     [Test]
     public async Task VarOnAnOpenLineIsEchoed()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, "1");
 
         await Assert.That(lines[0]!.Text).IsEqualTo("1");
@@ -266,7 +284,7 @@ public async Task VarOnAnOpenLineIsEchoed()
     [Test]
     public async Task RoomFlagsReachTheRoomSource()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var flags = new List();
         parser.Flagged += (_, e) => flags.Add(e);
 
@@ -295,7 +313,7 @@ await Assert.That(seen.Exits.Select(e => e.Key)).IsEquivalentTo(
     [Test]
     public async Task TheSpecsRoomLineTagsFlagTheirLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var flags = new List();
         parser.Flagged += (_, e) => flags.Add(e);
 
@@ -310,7 +328,7 @@ await Assert.That(flags.Select(f => (f.Flag, f.Text))).IsEquivalentTo(
     [Test]
     public async Task AFlaggedElementSpanningLinesKeepsAllItsText()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var flags = new List();
         parser.Flagged += (_, e) => flags.Add(e);
 
@@ -322,7 +340,7 @@ public async Task AFlaggedElementSpanningLinesKeepsAllItsText()
     [Test]
     public async Task AUserLineTagOpensTheElementTiedToIt()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = Lines(parser, Secure + "' TAG=20>", "\x1b[20zHeading", "plain");
 
         await Assert.That(lines[1]!.Spans.Single().Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue();
@@ -334,7 +352,7 @@ public async Task AUserLineTagOpensTheElementTiedToIt()
     [Test]
     public async Task AGaugeIsDeclaredOnASecureLineOnly()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var gauges = new List();
         parser.GaugeDeclared += (_, e) => gauges.Add(e);
 
@@ -347,7 +365,7 @@ public async Task AGaugeIsDeclaredOnASecureLineOnly()
     [Test]
     public async Task SoundAndMusicAreTheSameRequestsMspMakes()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var media = new List();
         parser.MediaRequested += (_, e) => media.Add(e);
 
@@ -369,7 +387,7 @@ public async Task SoundAndMusicAreTheSameRequestsMspMakes()
     [Test]
     public async Task DestCutsItsTextOutOfTheLineAndSendsItToTheFrame()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var sent = new List();
         parser.Redirected += (_, e) => sent.Add(e);
 
@@ -385,7 +403,7 @@ await Assert.That(sent.Select(s => (s.Frame, s.Line.Text))).IsEquivalentTo(
     [Test]
     public async Task ARedirectSendsWholeLinesUntilTop()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var sent = new List();
         parser.Redirected += (_, e) => sent.Add(e);
 
@@ -398,20 +416,20 @@ public async Task ARedirectSendsWholeLinesUntilTop()
     [Test]
     public async Task OnlyAFewFramesMayBeNamedAndTheRestStayInTheMainWindow()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var sent = new List();
         parser.Redirected += (_, e) => sent.Add(e);
 
-        var lines = Lines(parser, Enumerable.Range(0, MxpParser.MaxFrames + 2).Select(i => Secure + $"t{i}").ToArray());
+        var lines = Lines(parser, Enumerable.Range(0, MarkupLineParser.MaxFrames + 2).Select(i => Secure + $"t{i}").ToArray());
 
-        await Assert.That(sent.Count).IsEqualTo(MxpParser.MaxFrames);
-        await Assert.That(lines[^1]!.Text).IsEqualTo($"t{MxpParser.MaxFrames + 1}");
+        await Assert.That(sent.Count).IsEqualTo(MarkupLineParser.MaxFrames);
+        await Assert.That(lines[^1]!.Text).IsEqualTo($"t{MarkupLineParser.MaxFrames + 1}");
     }
 
     [Test]
     public async Task DestOnAnOpenLineIsEchoed()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var sent = new List();
         parser.Redirected += (_, e) => sent.Add(e);
 
@@ -426,7 +444,7 @@ public async Task DestOnAnOpenLineIsEchoed()
     [Test]
     public async Task TheSupportsListNamesWhatIsHandled()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         string? reply = null;
         parser.ClientReply += (_, r) => reply = r;
 
diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs
index edf0e2cd..adba692d 100644
--- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs
@@ -1,3 +1,4 @@
+using SharpMUTerm.Core.Configuration;
 using SharpMUTerm.Core.Protocols;
 using SharpMUTerm.Core.Text;
 
@@ -14,7 +15,7 @@ public class MxpImageTests
     /// Feeds one line the way a session does — text, then Flush as the boundary.
     private static StyledLine Line(string text)
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         parser.Feed(text);
         return parser.Flush() ?? throw new InvalidOperationException("no line");
     }
@@ -35,7 +36,8 @@ await Assert.That(span.Interaction.Image).IsEqualTo(new InlineImageRequest(
             "http://mud.example/img/map.png",
             "map.png",
             new ImageExtent(200, ImageExtentUnit.Pixels),
-            new ImageExtent(10, ImageExtentUnit.Characters)));
+            // Sized in cells one way only, the other way is a cell's 16 pixels for each.
+            new ImageExtent(160, ImageExtentUnit.Pixels)));
     }
 
     /// Spec: "The classname is appended to the URL, along with the name of the graphics file".
@@ -121,7 +123,7 @@ public async Task OnAnOpenLineTheTagIsEchoedLiterally()
     [Test]
     public async Task SupportsNamesImage()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var replies = new List();
         parser.ClientReply += (_, r) => replies.Add(r);
 
@@ -202,7 +204,8 @@ public async Task WithoutTheBlanksThePictureIsNotReserved()
     {
         var line = Line(Secure + " A dark room.");
 
-        await Assert.That(line.Text).IsEqualTo("[image: map.png]A dark room.");
+        // No blanks to draw it in: the picture goes in the line where its tag was, and nothing the server wrote is covered.
+        await Assert.That(line.Text).IsEqualTo("[image: map.png] A dark room.");
         await Assert.That(ImageSpan(line).Interaction!.Image!.Reserved).IsFalse();
     }
 
@@ -219,7 +222,7 @@ public async Task APictureSizedInPixelsTakesNoBlanks()
     [Test]
     public async Task TheBlanksOwedEndWithTheLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         parser.Feed(Secure + "");
         parser.Flush();
         parser.Feed("   next");
diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs
index 1ef754e1..d6b6f5bd 100644
--- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs
@@ -1,3 +1,4 @@
+using SharpMUTerm.Core.Configuration;
 using SharpMUTerm.Core.Protocols;
 using SharpMUTerm.Core.Text;
 
@@ -24,7 +25,7 @@ public class MxpLineModeTests
     [Test]
     public async Task SecureTagOnAnOpenLine_IsShownAsTextAndIsNotClickable()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed("Rivane says, 'click'\n")[0];
 
@@ -35,7 +36,7 @@ public async Task SecureTagOnAnOpenLine_IsShownAsTextAndIsNotClickable()
     [Test]
     public async Task OpenTagOnAnOpenLine_IsHonoured()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed("plain bold\n")[0];
 
@@ -46,7 +47,7 @@ public async Task OpenTagOnAnOpenLine_IsHonoured()
     [Test]
     public async Task SecureTagOnASecureLine_IsHonoured()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(Secure + "look\n")[0];
 
@@ -57,7 +58,7 @@ public async Task SecureTagOnASecureLine_IsHonoured()
     [Test]
     public async Task LockedLine_ParsesNothingAtAll()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(Locked + "not bold & not an entity\n")[0];
 
@@ -68,7 +69,7 @@ public async Task LockedLine_ParsesNothingAtAll()
     [Test]
     public async Task ModeRevertsToTheDefaultAtTheNextNewline()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = parser.Feed(Secure + "a\nb\n");
 
@@ -80,7 +81,7 @@ public async Task ModeRevertsToTheDefaultAtTheNextNewline()
     [Test]
     public async Task LockSecure_SurvivesTheNewline()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = parser.Feed(LockSecure + "a\nb\n");
 
@@ -92,7 +93,7 @@ public async Task LockSecure_SurvivesTheNewline()
     [Test]
     public async Task TempSecure_CoversExactlyOneTag()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + "ab\n")[0];
 
@@ -104,7 +105,7 @@ public async Task TempSecure_CoversExactlyOneTag()
     [Test]
     public async Task Reset_ReturnsToOpenAndClearsStyle()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(LockSecure + "bold" + Reset + "afterc\n")[0];
 
@@ -119,7 +120,7 @@ public async Task Reset_ReturnsToOpenAndClearsStyle()
     [Test]
     public async Task NukeFirePrompt_ParsesIntoTwoClickableAnswers()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         parser.Feed(
             "at right, Pemberton ("
@@ -141,7 +142,7 @@ public async Task NukeFirePrompt_ParsesIntoTwoClickableAnswers()
     [Test]
     public async Task LockOpen_MakesOpenTheDefaultAgain()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(
             parser,
@@ -157,7 +158,7 @@ public async Task LockOpen_MakesOpenTheDefaultAgain()
     [Test]
     public async Task ClosingTagTakesTheCategoryOfItsElement()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(" and \n")[0];
 
@@ -172,7 +173,7 @@ public async Task ClosingTagTakesTheCategoryOfItsElement()
     [Test]
     public async Task ARefusedTagIsEchoedByteForByte()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         const string tag = "";
 
         var line = parser.Feed("Rivane says, '" + tag + "click'\n")[0];
@@ -188,7 +189,7 @@ public async Task ARefusedTagIsEchoedByteForByte()
     [Test]
     public async Task AnUnknownLineTagNumberLeavesTheModeAlone()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         // 99 has no meaning, ESC[z has no number, ESC[-1z and ESC[ 1 z are spellings the spec does
         // not have — none of them may move the mode, in either direction.
@@ -206,7 +207,7 @@ public async Task AnUnknownLineTagNumberLeavesTheModeAlone()
     [Test]
     public async Task TempSecure_IsSpentByInterveningText_NotByAPlayersTag()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + "Rivane says, 'click'\n")[0];
 
@@ -221,7 +222,7 @@ public async Task TempSecure_IsSpentByInterveningText_NotByAPlayersTag()
     [Test]
     public async Task TempSecure_IsSpentByLockedText_NotBySurvivingTheUnlock()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(
             LockLocked + "Rivane says, " + TempSecure + "hello"
@@ -248,12 +249,11 @@ public async Task TempSecure_IsSpentByLockedText_NotBySurvivingTheUnlock()
     [Arguments("\u001b(B")] // designate charset — the intermediate arm
     [Arguments("\u001b[0m")] // SGR — a CSI final byte that is not 'z'
     [Arguments("\u001b[2J")] // erase — a CSI final byte that is discarded
-    [Arguments("\u001b[1\u0001z")] // a control byte mid-sequence — the malformed-abort arm
     [Arguments("\u001b]0;title\u0007")] // OSC, BEL-terminated
     [Arguments("\u001b]0;title\u001b\\")] // OSC, ST-terminated
     public async Task TempSecure_IsSpentByAnyEscapeThatIsNotALineTag(string escape)
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + escape + "click\n")[0];
 
@@ -280,7 +280,7 @@ public async Task TempSecure_IsSpentByAnyEscapeThatIsNotALineTag(string escape)
     [Arguments("\u001b[z")] // no number at all
     public async Task TempSecure_IsSpentByAnyLineTagButItself(string lineTag)
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + lineTag + "click\n")[0];
 
@@ -292,7 +292,7 @@ public async Task TempSecure_IsSpentByAnyLineTagButItself(string lineTag)
     [Test]
     public async Task TempSecure_SurvivesARepeatOfItself()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + TempSecure + "a\n")[0];
 
@@ -308,7 +308,7 @@ public async Task TempSecure_SurvivesARepeatOfItself()
     [Test]
     public async Task ARefusedCloseCannotFeedPlayerTextIntoADeferredSendsCommand()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(TempSecure + "Y Rivane says, 'hi'\n")[0];
 
@@ -328,7 +328,7 @@ public async Task ARefusedCloseCannotFeedPlayerTextIntoADeferredSendsCommand()
     [Test]
     public async Task ACloseIsHonouredOnAnOpenLineWhenItsElementIsStillOpen()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(Secure + "" + Open + "Y Rivane says, 'hi'\n")[0];
 
@@ -341,7 +341,7 @@ await Assert.That(line.Spans.Where(s => s.IsInteractive).Select(s => s.Interacti
     [Test]
     public async Task ACloseMatchingNothingOpenIsStillRefused()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed("Rivane says, ''\n")[0];
 
@@ -353,7 +353,7 @@ public async Task ACloseMatchingNothingOpenIsStillRefused()
     [Test]
     public async Task TempSecure_DoesNotSurviveTheNewline()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = parser.Feed(TempSecure + "\nclick\n");
 
@@ -370,7 +370,7 @@ public async Task TempSecure_DoesNotSurviveTheNewline()
     [Test]
     public async Task Reset_ClearsEveryModeField()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         parser.Feed(LockSecure + "a\n"); // default and current mode both Secure
         parser.Feed(TempSecure); // and an arming pending
 
@@ -389,7 +389,7 @@ public async Task Reset_ClearsEveryModeField()
     [Test]
     public async Task OpenLineTag_RefusesASecureTag()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = parser.Feed(LockSecure + Open + "b\n")[0];
 
@@ -406,7 +406,7 @@ public async Task OpenLineTag_RefusesASecureTag()
     /// an input shape the product never produces — which is how a line-mode revert that only ever ran
     /// on an embedded newline shipped with a green suite.
     /// 
-    private static IReadOnlyList FeedLines(MxpParser parser, params string[] lines)
+    private static IReadOnlyList FeedLines(MarkupLineParser parser, params string[] lines)
     {
         var result = new List();
         foreach (var text in lines)
@@ -426,7 +426,7 @@ private static IReadOnlyList FeedLines(MxpParser parser, params stri
     [Test]
     public async Task TheModeRevertsAtAFlushBoundary_NotOnlyAtAnEmbeddedNewline()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(
             parser,
@@ -442,7 +442,7 @@ public async Task TheModeRevertsAtAFlushBoundary_NotOnlyAtAnEmbeddedNewline()
     [Test]
     public async Task LockSecure_SurvivesAFlushBoundary()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(parser, LockSecure + "a", "b");
 
@@ -454,7 +454,7 @@ public async Task LockSecure_SurvivesAFlushBoundary()
     [Test]
     public async Task TempSecure_DoesNotSurviveAFlushBoundary()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(parser, "prompt>" + TempSecure, "click");
 
@@ -474,7 +474,7 @@ public async Task TempSecure_DoesNotSurviveAFlushBoundary()
     [Test]
     public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(
             parser,
@@ -484,7 +484,6 @@ public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary()
         await Assert.That(lines[1].Text).IsEqualTo("the next line");
         await Assert.That(lines[1].Spans[0].Style.Foreground).IsEqualTo(TerminalColor.Default);
         await Assert.That(lines[1].Spans[0].Style.Background).IsEqualTo(TerminalColor.Default);
-        await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default);
     }
 
     /// 
@@ -495,7 +494,7 @@ public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary()
     [Test]
     public async Task AnUnclosedOpenTagIsAutoClosedWhenTheModeLeavesOpen()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var line = FeedLines(parser, "says '' " + Secure + "server text")[0];
 
@@ -511,7 +510,7 @@ public async Task AnUnclosedOpenTagIsAutoClosedWhenTheModeLeavesOpen()
     [Test]
     public async Task AnOpenTagOpenedOnASecureLineIsNotAutoClosed()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         var lines = FeedLines(parser, LockSecure + "bold", "still bold");
 
@@ -522,7 +521,7 @@ public async Task AnOpenTagOpenedOnASecureLineIsNotAutoClosed()
     [Test]
     public async Task RepeatedUnclosedOpenTagsDoNotAccumulate()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
 
         for (var i = 0; i < 50; i++)
         {
@@ -553,10 +552,10 @@ public async Task RepeatedUnclosedOpenTagsDoNotAccumulate()
     [Arguments("\u001b")] // a lone ESC, whose next byte would otherwise be eaten
     public async Task AnUnterminatedEscapeStringDoesNotEatTheNextLine(string escape)
     {
-        var atFlushBoundary = FeedLines(new MxpParser(), "says '" + escape, "the next line");
+        var atFlushBoundary = FeedLines(new MarkupLineParser(ContentFormat.Mxp), "says '" + escape, "the next line");
         await Assert.That(atFlushBoundary[1].Text).IsEqualTo("the next line");
 
-        var embedded = new MxpParser().Feed("says '" + escape + "\nthe next line\n");
+        var embedded = new MarkupLineParser(ContentFormat.Mxp).Feed("says '" + escape + "\nthe next line\n");
         await Assert.That(embedded).Count().IsEqualTo(2);
         await Assert.That(embedded[1].Text).IsEqualTo("the next line");
     }
diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs
index 8957447b..c8714c5a 100644
--- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs
@@ -1,3 +1,4 @@
+using SharpMUTerm.Core.Configuration;
 using SharpMUTerm.Core.Protocols;
 using SharpMUTerm.Core.Telnet;
 using SharpMUTerm.Core.Text;
@@ -17,7 +18,7 @@ public class MxpParserTests
 
     private static StyledLine ParseSingleLine(string input)
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = parser.Feed(input);
         if (lines.Count != 1)
         {
@@ -40,7 +41,7 @@ public async Task PlainText_ProducesSingleDefaultSpan()
     [Test]
     public async Task Feed_SplitsOnNewlines()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = parser.Feed("one\ntwo\nthree\n");
         await Assert.That(lines).Count().IsEqualTo(3);
         await Assert.That(lines[0].Text).IsEqualTo("one");
@@ -51,7 +52,7 @@ public async Task Feed_SplitsOnNewlines()
     [Test]
     public async Task Flush_ReturnsPartialPromptLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = parser.Feed("Enter password:");
         await Assert.That(lines).Count().IsEqualTo(0);
 
@@ -63,7 +64,7 @@ public async Task Flush_ReturnsPartialPromptLine()
     [Test]
     public async Task Flush_ReturnsNullWhenNothingBuffered()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         parser.Feed("done\n");
         await Assert.That(parser.Flush()).IsNull();
     }
@@ -78,7 +79,7 @@ public async Task CarriageReturn_IsDropped()
     [Test]
     public async Task BlankLine_IsEmittedAsEmpty()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         var lines = parser.Feed("a\n\nb\n");
         await Assert.That(lines).Count().IsEqualTo(3);
         await Assert.That(lines[1].IsEmpty).IsTrue();
@@ -242,7 +243,7 @@ public async Task Send_PromptFlag_SetsPromptOnly()
     [Test]
     public async Task Send_BareWithoutClose_ClosesAtEndOfLine()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         // Only the first line is secured: the mode reverts at the newline, which is exactly the
         // boundary the interaction must not survive.
         var lines = parser.Feed(Secure + "walk\nnext line\n");
@@ -265,7 +266,7 @@ public async Task Anchor_WithHref_ProducesHyperlink()
     public async Task Entities_AreDecoded()
     {
         var line = ParseSingleLine("<>& A\n");
-        await Assert.That(line.Text).IsEqualTo("<>& A");
+        await Assert.That(line.Text).IsEqualTo("<>&\u00a0A");
     }
 
     [Test]
@@ -313,7 +314,7 @@ public async Task UnsupportedTags_AreStripped()
     [Test]
     public async Task Br_ProducesLineBreak()
     {
-        var parser = new MxpParser();
+        var parser = new MarkupLineParser(ContentFormat.Mxp);
         // BR is not one of the spec's open tags, so it needs securing like any other.
         var lines = parser.Feed(Secure + "first
second\n"); await Assert.That(lines).Count().IsEqualTo(2); @@ -331,7 +332,7 @@ public async Task StrayLessThan_IsEmittedLiterally() [Test] public async Task TagSplitAcrossFeeds_IsReassembled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var first = parser.Feed("hot
\n"); @@ -344,7 +345,7 @@ public async Task TagSplitAcrossFeeds_IsReassembled() [Test] public async Task EntitySplitAcrossFeeds_IsReassembled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("x&a"); var lines = parser.Feed("mp;y\n"); await Assert.That(lines[0].Text).IsEqualTo("x&y"); @@ -353,9 +354,8 @@ public async Task EntitySplitAcrossFeeds_IsReassembled() [Test] public async Task StyleState_PersistsAcrossFeeds() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("bold "); - await Assert.That(parser.CurrentStyle.HasAttribute(TextAttributes.Bold)).IsTrue(); var lines = parser.Feed("still bold\n"); await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); } @@ -363,7 +363,7 @@ public async Task StyleState_PersistsAcrossFeeds() [Test] public async Task SelfClosingBr_IsHandled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(Secure + "a
b\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -397,7 +397,7 @@ public async Task EscapeByte_TwoByteEscapeIsConsumedAndDiscarded() [Test] public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("\x1b[33mYellow\x1b[0m plain\n")[0]; @@ -406,13 +406,13 @@ public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() await Assert.That(line.Spans[^1].Style.Foreground).IsEqualTo(TerminalColor.Default); } - /// A non-SGR CSI is consumed and discarded, exactly as AnsiParser does with it. + /// A CSI with no meaning on a line (here, hiding the cursor) is consumed and discarded. [Test] public async Task Ansi_NonSgrCsiIsDiscarded() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); - var line = parser.Feed("a\x1b[2Kb\n")[0]; + var line = parser.Feed("a\x1b[?25lb\n")[0]; await Assert.That(line.Text).IsEqualTo("ab"); } @@ -421,7 +421,7 @@ public async Task Ansi_NonSgrCsiIsDiscarded() [Test] public async Task Ansi_AndMxpTagsCompose() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("\x1b[33mboth\n")[0]; @@ -442,10 +442,10 @@ public async Task Send_WithFormattingInside_KeepsInteractionOnAllSpans() [Test] public async Task Reset_ClearsStyleAndStack() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("bold"); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); var lines = parser.Feed("plain\n"); await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); @@ -454,7 +454,7 @@ public async Task Reset_ClearsStyleAndStack() [Test] public async Task HasPendingContent_TrueWithBufferedText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("partial"); await Assert.That(parser.HasPendingContent).IsTrue(); } @@ -500,7 +500,7 @@ public async Task Escape_ThreeByteIntermediateIsConsumedAndDiscarded() [Test] public async Task Version_IsAnswered() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -509,14 +509,14 @@ public async Task Version_IsAnswered() // Spec: "the response is sent as a secure-tagged line" (ESC[1z prefix), and the tag's exact // attribute order is MXP, STYLE, CLIENT, VERSION, with REGISTERED optional and here omitted. await Assert.That(replies).Count().IsEqualTo(1); - await Assert.That(replies[0]).IsEqualTo($"\x1b[1z"); + await Assert.That(replies[0]).IsEqualTo($"\x1b[1z"); } /// A VERSION request on an open line is a player's, not the server's, and is refused. [Test] public async Task Version_OnAnOpenLine_IsNotAnswered() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -529,7 +529,7 @@ public async Task Version_OnAnOpenLine_IsNotAnswered() [Test] public async Task Support_IsAnsweredWithTheTagsThisParserImplements() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -551,7 +551,7 @@ public async Task Support_IsAnsweredWithTheTagsThisParserImplements() [Test] public async Task ClientReplies_AreSentAsSecureLines() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs index 38ac5a92..ab8efd13 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -10,7 +11,7 @@ public class MxpSendMenuTests private static StyledLine Line(string text) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(text); return parser.Flush() ?? StyledLine.Empty; } @@ -73,7 +74,7 @@ public async Task AMenuIsCappedAtTwentyCommands() var href = string.Join('|', Enumerable.Range(0, 30).Select(i => $"c{i}")); var send = Clickable(Line(Secure + $"x")); - await Assert.That(send.Choices!.Count).IsEqualTo(MxpParser.MaxSendChoices); + await Assert.That(send.Choices!.Count).IsEqualTo(MarkupLineParser.MaxSendChoices); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs deleted file mode 100644 index 429fd34d..00000000 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using SharpMUTerm.Core.Protocols; - -namespace SharpMUTerm.Core.Tests.Protocols; - -/// -/// The open/secure classification on its own. This table is the security boundary — every -/// tag the parser honours on an unsecured line is a tag this returned true for — and the parser only -/// ever drives eight of its entries, because folds the alternative -/// spellings away before the gate sees them and nothing implements H or FONT's -/// behaviour. So STRONG, EM, STRIKEOUT, HIGH and mixed case reach it from -/// nowhere else, and a typo in any of them would turn an open tag secure with nothing to notice. -/// -public class MxpTagCategoryTests -{ - /// - /// The allow-list, spelt out a second time rather than read back off the type — a test that asked - /// the table what was in it would agree with any edit to the table, including a deletion. - /// Verbatim from the spec: "Only the tags described in this section are OPEN tags." - /// - [Test] - [Arguments("B")] - [Arguments("BOLD")] - [Arguments("STRONG")] - [Arguments("I")] - [Arguments("ITALIC")] - [Arguments("EM")] - [Arguments("U")] - [Arguments("UNDERLINE")] - [Arguments("S")] - [Arguments("STRIKEOUT")] - [Arguments("C")] - [Arguments("COLOR")] - [Arguments("H")] - [Arguments("HIGH")] - [Arguments("FONT")] - public async Task EveryOpenTagIsOpen(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsTrue(); - - /// - /// A server may spell a tag however it likes, so the classification is case-insensitive. The - /// parser upper-cases before the gate, so this is the only place the lower and mixed forms are - /// ever asked for. - /// - [Test] - [Arguments("b")] - [Arguments("bold")] - [Arguments("Strong")] - [Arguments("iTaLiC")] - [Arguments("em")] - [Arguments("underline")] - [Arguments("StRiKeOuT")] - [Arguments("color")] - [Arguments("high")] - [Arguments("font")] - public async Task CaseDoesNotChangeTheAnswer(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsTrue(); - - /// - /// "All other MXP tags are SECURE tags." The interesting members are the two that carry a - /// command (SEND, A), the two the parser consumes rather than renders - /// (VAR, IMG), the element definition a server uses to invent tags, and a name - /// nobody has defined at all — because this is an allow-list, an unknown tag must come back - /// secure rather than fall through some default. - /// - [Test] - [Arguments("SEND")] - [Arguments("send")] - [Arguments("A")] - [Arguments("a")] - [Arguments("VAR")] - [Arguments("IMG")] - [Arguments("BR")] - [Arguments("P")] - [Arguments("H1")] - [Arguments("EXPIRE")] - [Arguments("VERSION")] - [Arguments("!ELEMENT")] - [Arguments("NOSUCHTAG")] - [Arguments("")] - public async Task EverythingElseIsSecure(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsFalse(); - - /// - /// A near-miss is not a match. The lookup is exact, so no prefix, suffix or padded spelling of - /// an open tag can be smuggled past it — SENDB must not ride in on B. - /// - [Test] - [Arguments("BB")] - [Arguments("B ")] - [Arguments(" B")] - [Arguments("SENDB")] - [Arguments("BSEND")] - [Arguments("COLOUR")] - [Arguments("/B")] - public async Task ANearMissIsNotAnOpenTag(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsFalse(); -} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs index e554ee2c..067ad8ce 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs @@ -1,3 +1,5 @@ +using SharpMUTerm.Core.Text; +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; namespace SharpMUTerm.Core.Tests.Protocols; @@ -11,7 +13,7 @@ public class ParserBoundaryTests [Test] public async Task Mxp_BareSend_DoesNotLeakToNextLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // SEND is a secure element: without the ESC[1z the tag would be echoed as literal text and // this test would pass without ever opening the interaction it exists to watch leak. var lines = parser.Feed("\x1b[1zwalk\nmore\n"); @@ -22,7 +24,7 @@ public async Task Mxp_BareSend_DoesNotLeakToNextLine() [Test] public async Task Mxp_NestedFormattingInsideSend_DoesNotLeakInteractionAcrossNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // Bold opened inside the SEND and closed on the next line must not resurrect the command. var lines = parser.Feed("\x1b[1zwalk\nmore tail\n"); var second = lines[1]; @@ -32,7 +34,7 @@ public async Task Mxp_NestedFormattingInsideSend_DoesNotLeakInteractionAcrossNew [Test] public async Task Pueblo_UnclosedAnchor_DoesNotLeakToNextLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("here\nnext\n"); var second = lines[1]; await Assert.That(second.Spans.All(s => !s.IsInteractive)).IsTrue(); @@ -41,7 +43,7 @@ public async Task Pueblo_UnclosedAnchor_DoesNotLeakToNextLine() [Test] public async Task Pueblo_UnclosedAnchor_DoesNotLeakIntoFlushedPrompt() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("here\n"); parser.Feed("prompt> "); var prompt = parser.Flush(); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs index e64e2124..e54489c9 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -7,7 +8,7 @@ public class PuebloParserTests { private static StyledLine ParseSingleLine(string input) { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed(input); if (lines.Count != 1) { @@ -30,7 +31,7 @@ public async Task PlainText_ProducesSingleDefaultSpan() [Test] public async Task Feed_SplitsOnNewlines() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("one\ntwo\nthree\n"); await Assert.That(lines).Count().IsEqualTo(3); await Assert.That(lines[0].Text).IsEqualTo("one"); @@ -41,7 +42,7 @@ public async Task Feed_SplitsOnNewlines() [Test] public async Task Flush_ReturnsPartialLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("prompt> "); await Assert.That(lines).Count().IsEqualTo(0); await Assert.That(parser.HasPendingContent).IsTrue(); @@ -176,7 +177,7 @@ public async Task Send_Href_ProducesSendCommand() public async Task Entities_ResolveToCharacters() { var line = ParseSingleLine("<>& A\n"); - await Assert.That(line.Text).IsEqualTo("<>& A"); + await Assert.That(line.Text).IsEqualTo("<>&\u00a0A"); } [Test] @@ -210,7 +211,7 @@ public async Task BareAmpersand_IsEmittedLiterally() [Test] public async Task Br_BreaksLineMidText() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("a
b\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -220,7 +221,7 @@ public async Task Br_BreaksLineMidText() [Test] public async Task Paragraph_BreaksLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("a

b

\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -235,10 +236,10 @@ public async Task UnknownTag_IsConsumedNotLeaked() } [Test] - public async Task Img_IsIgnored() + public async Task Img_ShowsItsLabel() { var line = ParseSingleLine("beforeafter\n"); - await Assert.That(line.Text).IsEqualTo("beforeafter"); + await Assert.That(line.Text).IsEqualTo("before[image: map.png]after"); } [Test] @@ -258,7 +259,7 @@ public async Task Comment_IsIgnored() [Test] public async Task TagSplitAcrossFeeds_IsReassembled() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var first = parser.Feed("x\n"); @@ -270,7 +271,7 @@ public async Task TagSplitAcrossFeeds_IsReassembled() [Test] public async Task EntitySplitAcrossFeeds_IsReassembled() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("&a"); parser.Feed("mp;"); var line = parser.Flush(); @@ -320,7 +321,7 @@ public async Task Ansi_AndPuebloMarkupComposeOnOneSpan() public async Task Ansi_OtherSequencesAreConsumed() { // Cursor movement, an OSC title (BEL- and ST-terminated), a charset select and a two-byte escape. - var line = ParseSingleLine("a\x1b[2Kb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); + var line = ParseSingleLine("a\x1b[?25lb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); await Assert.That(line.Text).IsEqualTo("abcdef"); } @@ -332,7 +333,7 @@ public async Task Ansi_OtherSequencesAreConsumed() [Test] public async Task Ansi_AnUnterminatedOscEndsWithTheLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("before\x1b]0;never closed"); var first = parser.Flush(); parser.Feed("next line"); @@ -345,19 +346,18 @@ public async Task Ansi_AnUnterminatedOscEndsWithTheLine() [Test] public async Task Reset_ClearsStyleAndStack() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("bold"); - await Assert.That(parser.CurrentStyle).IsNotEqualTo(TextStyle.Default); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); var line = ParseSingleLineWith(parser, "plain\n"); await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default); } - private static StyledLine ParseSingleLineWith(PuebloParser parser, string input) + private static StyledLine ParseSingleLineWith(MarkupLineParser parser, string input) { var lines = parser.Feed(input); return lines[^1]; diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs index 8f6f2e0b..f342f30b 100644 --- a/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using SharpMUTerm.Core.Protocols; @@ -53,7 +54,7 @@ public async Task ABareSupportRequestIsAnsweredWithEveryTag() await Assert.That(await WaitForTextAsync(transport, " ")).IsEmpty(); var line = parser.Flush(); await Assert.That(line).IsNotNull(); @@ -230,7 +231,7 @@ public async Task Flush_ReturnsPendingPartialLine() [Test] public async Task Flush_ReturnsNull_WhenNothingBuffered() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("done\n"); await Assert.That(parser.Flush()).IsNull(); } @@ -238,10 +239,10 @@ public async Task Flush_ReturnsNull_WhenNothingBuffered() [Test] public async Task Reset_ClearsStyleAndBuffers() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("\x1b[31mpartial"); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); await Assert.That(parser.Flush()).IsNull(); } @@ -249,7 +250,7 @@ public async Task Reset_ClearsStyleAndBuffers() [Test] public async Task DefaultForeground_ResetsColorButKeepsAttributes() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("\x1b[1;31mA"); var lines = parser.Feed("\x1b[39mB\n"); var second = lines[0].Spans[1].Style; @@ -280,13 +281,13 @@ public async Task DefaultForeground_ResetsColorButKeepsAttributes() [Arguments("\u001b")] // a lone ESC, whose next byte would otherwise be eaten public async Task AnUnterminatedEscapeStringDoesNotEatTheNextLine(string escape) { - var atFlushBoundary = new AnsiParser(); + var atFlushBoundary = new MarkupLineParser(); atFlushBoundary.Feed("says '" + escape); atFlushBoundary.Flush(); atFlushBoundary.Feed("the next line"); await Assert.That(atFlushBoundary.Flush()?.Text).IsEqualTo("the next line"); - var embedded = new AnsiParser().Feed("says '" + escape + "\nthe next line\n"); + var embedded = new MarkupLineParser().Feed("says '" + escape + "\nthe next line\n"); await Assert.That(embedded).Count().IsEqualTo(2); await Assert.That(embedded[1].Text).IsEqualTo("the next line"); } diff --git a/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs b/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs deleted file mode 100644 index 5fd7403b..00000000 --- a/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SharpMUTerm.Core.Text; - -namespace SharpMUTerm.Core.Tests.Text; - -public class SgrCodesTests -{ - [Test] - public async Task Apply_EmptyParameters_ResetsToDefault() - { - var bold = new TextStyle(TerminalColor.FromIndex(1), TerminalColor.Default, TextAttributes.Bold); - - await Assert.That(SgrCodes.Apply(bold, string.Empty)).IsEqualTo(TextStyle.Default); - } - - [Test] - public async Task Apply_SetsForegroundFromAnIndexedCode() - { - var result = SgrCodes.Apply(TextStyle.Default, "33"); - - await Assert.That(result.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); - } - - [Test] - public async Task Apply_KeepsUnrelatedStateWhenOnlyOneAttributeChanges() - { - var yellow = SgrCodes.Apply(TextStyle.Default, "33"); - - var result = SgrCodes.Apply(yellow, "1"); - - await Assert.That(result.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); - await Assert.That(result.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); - } -} diff --git a/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs b/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs index 369c6fb9..983942da 100644 --- a/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs @@ -64,7 +64,7 @@ public async Task AnMxpAnchorSpellingTheSendScheme_SendsNothing() var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "
click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); // the link really is clickable + await Assert.That(HasLink(world, "click me")).IsFalse(); // the forged anchor is drawn as its text await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -76,7 +76,7 @@ public async Task APuebloAnchorSpellingTheSendScheme_SendsNothing() var world = await Connected(ContentFormat.Pueblo); world.Receive("click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); + await Assert.That(HasLink(world, "click me")).IsFalse(); await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -92,27 +92,26 @@ public async Task AnMxpAnchorSpellingThePromptScheme_DoesNotTouchTheCommandLine( var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); + await Assert.That(HasLink(world, "click me")).IsFalse(); await Assert.That(world.App.ArmedInputText).IsEmpty(); await Assert.That(world.Telnet.Lines).IsEmpty(); } /// - /// What the forged link becomes instead: a hyperlink whose URL happens to read mux:send:…, and - /// a hyperlink clicked in a pane is handed to the desktop — where the http(s) gate refuses it, because - /// that is not an address any browser was going to be asked to open. A world that lies about what its - /// link is gets a refusal, not a command run as the user. + /// What the forged link becomes instead: nothing clickable. The decoder reads a link only when its + /// address uses one of the usual web schemes, so mux:send:… stays as the anchor's text, and + /// clicking that text neither sends nor opens anything. The desktop's http(s) gate is still there for + /// a link that does get through. /// [Test] - public async Task AForgedScheme_IsTreatedAsTheHyperlinkItIs() + public async Task AForgedScheme_IsDrawnAsPlainText() { var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "click me\n"); - ClickLink(world, "click me"); - - await Assert.That(world.App.StatusMarkup).Contains("not an http or https address"); + await Assert.That(HasLink(world, "click me")).IsFalse(); + await Assert.That(world.App.PaneLines(world.WindowId).Any(line => line.Contains("click me", StringComparison.Ordinal))).IsTrue(); await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -480,6 +479,9 @@ private static async Task Open(SharpMUTermApp app, string sessionKey) private static bool ClickLink(Wired world, string text) => ClickLinkIn(world.App, world.WindowId, text); + private static bool HasLink(Wired world, string text) => + world.App.PaneLines(world.WindowId).Any(line => LinkOver(line, text) is not null); + /// /// Clicks the first cell of the link whose visible text contains . Both the row /// and the column come from the markup the app drew, parsed by the framework's own parser — nothing diff --git a/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs b/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs index f3169a81..caaca1fe 100644 --- a/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs @@ -142,7 +142,7 @@ public async Task RestylingAFullWindowIsBounded() // What 2,000 such lines weigh as StyledLines, the most a window can add by keeping them (a session window's // are the very instances its scrollback ring already holds, so there it is a reference per line). - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); var before = GC.GetTotalMemory(forceFullCollection: true); var kept = text.SelectMany(parser.Feed).ToList(); var after = GC.GetTotalMemory(forceFullCollection: true);