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