From 75207a9ceb8434c78b10773f47b73cf902194afb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:01:34 +0000 Subject: [PATCH 1/3] Put MXP images and sounds in their T= directory, and keep a short extended colour's components MXP IMAGE and SOUND appended the file to URL= (U=) but dropped T=, which the specification puts between them. SGR 38;2;40 left the 40 to be read as a black background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TnKiMidjBirFcUqtL --- CHANGELOG.md | 9 +++++++++ MarkupString.Ansi/Decoding/VtPlugins.cs | 3 +++ MarkupString.Mxp/Decoding/MxpDecoding.cs | 16 ++++++++++++---- MarkupString.Tests/Decoding/MxpDecodingTests.cs | 16 ++++++++++++++++ MarkupString.Tests/Decoding/VtDecodingTests.cs | 8 ++++++++ docs/decoding.md | 7 ++++--- 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7d12a..2ebfcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- MXP `IMAGE` and `SOUND` put the file in the `T=` type's directory under the base address, as the MXP + specification says: `` is `https://h/img/maps/map.png`. +- An SGR extended colour with too few components (`38;2;40`) no longer reads what is left as codes of their own, + so it does not set a black background. + ## 2.26.0 — 2026-11-06 ### Added diff --git a/MarkupString.Ansi/Decoding/VtPlugins.cs b/MarkupString.Ansi/Decoding/VtPlugins.cs index 63196a4..d86188e 100644 --- a/MarkupString.Ansi/Decoding/VtPlugins.cs +++ b/MarkupString.Ansi/Decoding/VtPlugins.cs @@ -155,6 +155,9 @@ public static AnsiStyle Apply(AnsiStyle style, in CsiSequence sequence) } else if (first + 2 >= sequence.Count) { + // Too few components: they are still the colour's, not codes of their own (38;2;40 does not + // set a black background). + i = sequence.Count - 1; return null; } diff --git a/MarkupString.Mxp/Decoding/MxpDecoding.cs b/MarkupString.Mxp/Decoding/MxpDecoding.cs index 04dc49b..6c50fe2 100644 --- a/MarkupString.Mxp/Decoding/MxpDecoding.cs +++ b/MarkupString.Mxp/Decoding/MxpDecoding.cs @@ -699,7 +699,7 @@ private void Image(ParsedTag tag) { var file = tag.Get("FNAME", 0); if (string.IsNullOrEmpty(file)) return; - var source = Address(tag.Get("URL"), file); + var source = Address(tag.Get("URL"), tag.Get("T"), file); var (width, widthCells) = Size(tag.Get("W")); var (height, heightCells) = Size(tag.Get("H")); var align = Enum.TryParse(tag.Get("ALIGN"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null; @@ -758,13 +758,21 @@ private void Sound(ParsedTag tag) ? l < 0 ? SoundMarkup.Forever : l : null; var continues = music && tag.Get("C") == "1"; - Context.Point(new SoundMarkup(Address(tag.Get("U"), file), channel, volume, repeats, continues)); + Context.Point(new SoundMarkup(Address(tag.Get("U"), tag.Get("T"), file), channel, volume, repeats, continues)); } - private static string Address(string? directory, string file) + /// + /// Where a picture or sound is: the file under the base address, in the type's directory when there is one + /// ("the classname is appended to the URL, along with the name of the file itself"), or the file as it is + /// when there is no base or the file is already absolute. + /// + private static string Address(string? directory, string? type, string file) { if (string.IsNullOrEmpty(directory) || Uri.TryCreate(file, UriKind.Absolute, out _)) return file; - return directory.EndsWith('/') ? directory + file : directory + "/" + file; + var address = directory.EndsWith('/') ? directory : directory + "/"; + var kind = type?.Trim().Trim('/'); + if (!string.IsNullOrEmpty(kind)) address += kind + "/"; + return address + file; } // ── Helpers ───────────────────────────────────────────────────────────────── diff --git a/MarkupString.Tests/Decoding/MxpDecodingTests.cs b/MarkupString.Tests/Decoding/MxpDecodingTests.cs index 734a819..ee9e790 100644 --- a/MarkupString.Tests/Decoding/MxpDecodingTests.cs +++ b/MarkupString.Tests/Decoding/MxpDecodingTests.cs @@ -224,6 +224,22 @@ public async Task WhatThisPackageWrites_ReadsBack() await Assert.That(spans[^1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Hint = "Look" }); } + [Test] + public async Task Image_TypeIsADirectoryUnderTheUrl() + { + var (lines, _, _) = Decode("\e[1z\n\e[1z\n\e[1z\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Source).IsEqualTo("https://example.test/img/maps/map.png"); + await Assert.That(Markups(lines[1]).OfType().Single().Source).IsEqualTo("https://example.test/img/maps/map.png"); + await Assert.That(Markups(lines[2]).OfType().Single().Source).IsEqualTo("https://example.test/doors/door.wav"); + } + + [Test] + public async Task Image_TypeWithoutUrlLeavesTheName() + { + var (lines, _, _) = Decode("\e[1z\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Source).IsEqualTo("https://example.test/x.png"); + } + [Test] public async Task Image_InCells_IsDrawnOverTheLines() { diff --git a/MarkupString.Tests/Decoding/VtDecodingTests.cs b/MarkupString.Tests/Decoding/VtDecodingTests.cs index e5cb968..b6f8ba7 100644 --- a/MarkupString.Tests/Decoding/VtDecodingTests.cs +++ b/MarkupString.Tests/Decoding/VtDecodingTests.cs @@ -90,6 +90,14 @@ public async Task Sgr_StandardColour() await Assert.That(span.Style.Has(TextAttributes.Bold)).IsTrue(); } + [Test] + public async Task Sgr_TruncatedTrueColour_SetsNothing() + { + var span = Only("\e[31;38;2;40mx\n"); + await Assert.That(span.Style.Foreground).IsEqualTo(new RgbColor(170, 0, 0)); + await Assert.That(span.Style.Background).IsNull(); + } + [Test] public async Task Sgr_TrueColour_SemicolonsAndColons() { diff --git a/docs/decoding.md b/docs/decoding.md index d7b08a1..c34d130 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -134,7 +134,8 @@ The decoder acts on none of these. Refuse a clipboard write unless the player al | `53`, `55` | Overline on, off | | `58` | Underline colour: its parameters are read and dropped | -Any other code is ignored. An extended colour out of range leaves the colour as it was. The style +Any other code is ignored. An extended colour out of range leaves the colour as it was, and so does one +with too few components, whose components are not read as codes of their own. The style is written under the `VtPens.Style` pen as an `AnsiMarkup`. ### `ControlsPlugin` @@ -584,8 +585,8 @@ What the tags become: | `B`, `I`, `U`, `S`, `H`, `COLOR`, `FONT` | An `AnsiMarkup`. SGR inside an MXP colour wins, as in an MXP client. | | `A href hint` | A URL link, when `DecoderOptions.Links` allows the address | | `SEND href hint prompt` | A command link. With several commands (`a\|b`) or `PROMPT`, a `CommandMenuMarkup` as well. With no `href`, or `&text;` in it, the command is the tag's text. | -| `IMAGE fname url w h align` | An `ImageMarkup`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. | -| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points | +| `IMAGE fname url t w h align` | An `ImageMarkup` at `url`, then the `t` directory, then `fname`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. | +| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points, at `u`, then `t`, then the file | | `FRAME`, `DEST` | A `PaneMarkup` over the text in `DEST`, titled from its `FRAME` | | `VAR name` | A `VariableMarkup` over its text, and a `VariableEvent` | | `EXPIRE`, `RELOCATE`, `USER`, `PASSWORD` | Their points | From 9ee95ac1a9fc78f58b1b0abb2be92d192e7b6734 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:27:15 +0000 Subject: [PATCH 2/3] Fill MXP and Pueblo decoding gaps found by moving SharpMUTerm onto the decoder SharpMUTerm's own MXP and Pueblo parser tests were run against WithMxp and WithPueblo. These are the differences that were MarkupString's to fix: - Bound element expansion (an element using itself overflowed the stack), definition and entity counts and lengths; built-ins cannot be redefined. - An element defined on an open line plays back as open text. - Temp secure is spent by text, entities and escape sequences; ESC[z and undefined line tags change nothing; leaving open mode closes open elements. - Links end with their line; tabs inside tags stay in the tag; EMPTY is not a definition; SEND hints match their commands. - Cell-sized images go inline when the server wrote over their cells. - New: MxpOptions (ShowRefusedTags, Supports), GaugeEvent, FRAME redirect, sound priority and type, IMAGE T, VERSION style, Decoder.OpenElements, DecodeContext.PendingLine and Insert, ISequenceObserver. - Pueblo: , xch_mode=prompt, close aliases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7WA8C5XG14TAWB6b9V --- CHANGELOG.md | 29 ++ MarkupString.Mxp/Decoding/MxpDecoding.cs | 465 +++++++++++++++--- MarkupString.Mxp/PublicAPI.Unshipped.txt | 23 + .../Decoding/PuebloDecoding.cs | 38 +- .../Decoding/MxpDecodingTests.cs | 222 ++++++++- MarkupString/Decoding/DecodeContext.cs | 24 + MarkupString/Decoding/DecodeModel.cs | 11 + MarkupString/Decoding/Decoder.cs | 11 + MarkupString/Decoding/DecoderRegistry.cs | 10 + MarkupString/Elements/SoundMarkup.cs | 9 + MarkupString/PublicAPI.Unshipped.txt | 30 ++ docs/decoding.md | 59 ++- 12 files changed, 857 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7d12a..e36b042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.27.0 — Unreleased + +### Added + +- `Decoder.OpenElements` and `DecodeContext.PendingLine`: the elements a text protocol has open, and the line read + so far. `DecodeContext.Insert(column, text)` puts text into the line at a display column. +- `ISequenceObserver`: a text protocol is told when an escape sequence arrives. +- `GaugeEvent` for MXP `GAUGE` and `STAT`. +- `MxpOptions` for `WithMxp(options)`: `ShowRefusedTags` shows a refused tag as the text it was, and `Supports` + sets the tags and attributes `` is answered with. `MxpOptions.Answer` gives that answer to a host. +- MXP `FRAME` redirects (`ACTION=REDIRECT`, `CLOSE`) through `DisplayLine.Destination`; `SOUND` priority and + type (`SoundMarkup.Priority`, `SoundMarkup.Type`); the `IMAGE` `T` folder; `` sets the reported style. +- Pueblo ``, ``, and `strong`/`em`/`strike`/`del` closing their canonical tags. + +### Fixed + +- An MXP element that used itself (`x">`) overflowed the stack. Expansion is now bounded + in depth and per line, and definitions, entities and their lengths are capped (`MxpPlugin.MaxElements` and the + rest). Built-in tags and entities cannot be redefined. +- An element defined on an open line played back as secure, so a player could send a secure tag through it. +- `ESC [ 4 z` (temp secure) stayed armed past text and escape sequences; `ESC [ z` with no number, and an + undefined line tag in 10–99, changed the line mode. +- Leaving open mode mid-line left the open part's elements open. +- `A` and `SEND` links, and Pueblo `a`, ran on past their line. +- A tab inside an MXP tag moved the cursor instead of being part of the tag. +- `` took `EMPTY` as its definition and printed it. +- `SEND` hints with several commands were matched to the wrong labels. +- A cell-sized `IMAGE` could cover text the server wrote over its cells; it now goes inline at the tag. + ## 2.26.0 — 2026-11-06 ### Added diff --git a/MarkupString.Mxp/Decoding/MxpDecoding.cs b/MarkupString.Mxp/Decoding/MxpDecoding.cs index 04dc49b..92dfd4a 100644 --- a/MarkupString.Mxp/Decoding/MxpDecoding.cs +++ b/MarkupString.Mxp/Decoding/MxpDecoding.cs @@ -25,6 +25,96 @@ public sealed record MxpFlagMarkup(string Flag) : IDataMarkup public const string Welcome = "Welcome"; } +/// How a client reads MXP, for . +public sealed record MxpOptions +{ + /// The defaults: refused tags are dropped, and <SUPPORT> is answered with . + public static MxpOptions Default { get; } = new(); + + /// + /// Show a tag a line may not use as the text it was, character for character, instead of dropping it. A player + /// who types <SEND> into a channel then shows as having typed it, and nothing they wrote goes + /// missing. A tag refused because it was secure on an open line, a definition there, and the close of a secure + /// tag that nothing opened are all shown. + /// + public bool ShowRefusedTags { get; init; } + + /// + /// What <SUPPORT> is answered with: each tag the client acts on, lower case, with the attributes it + /// acts on (none for a tag whose attributes it does not list). Null answers with , + /// every tag this package reads. A client that reads a tag and does nothing with it should leave it out, since a + /// server takes the answer as a promise. + /// + public IReadOnlyDictionary>? Supports { get; init; } + + /// + /// The answer to a <SUPPORT> that asked about : every supported tag when it + /// asked about nothing; otherwise each entry it named, as supported or not. tag.attr is supported when the + /// tag's attributes list it, and tag.* is answered with each attribute listed for the tag, or as unsupported + /// for a tag that is not. + /// + public (IReadOnlyList Supported, IReadOnlyList Unsupported) Answer(IReadOnlyList asked) + { + var entries = Entries(asked); + return (entries.Where(e => e.Supported).Select(e => e.Name).ToList(), entries.Where(e => !e.Supported).Select(e => e.Name).ToList()); + } + + /// The answer entry by entry, in the order asked. With no , an attribute is answered as its tag is. + internal List<(string Name, bool Supported)> Entries(IReadOnlyList asked) + { + ArgumentNullException.ThrowIfNull(asked); + var tags = Supports ?? MxpPlugin.Supported.ToDictionary(name => name.ToLowerInvariant(), _ => (IReadOnlyList)[], StringComparer.OrdinalIgnoreCase); + var entries = new List<(string, bool)>(); + if (asked.Count == 0) + { + foreach (var name in tags.Keys) entries.Add((name, true)); + return entries; + } + + foreach (var entry in asked) + { + var name = entry.Trim('"', '\''); + if (name.Length == 0) continue; + var dot = name.IndexOf('.', StringComparison.Ordinal); + var tag = dot < 0 ? name : name[..dot]; + var known = Lookup(tags, tag, out var attributes); + if (dot < 0 || Supports is null) + { + entries.Add((name, known)); + continue; + } + + var attribute = name[(dot + 1)..]; + if (attribute == "*") + { + if (known) entries.AddRange(attributes.Select(a => ($"{tag}.{a}", true))); + else entries.Add((name, false)); + } + else + { + entries.Add((name, attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase))); + } + } + + return entries; + } + + private static bool Lookup(IReadOnlyDictionary> tags, string tag, out IReadOnlyList attributes) + { + foreach (var (name, list) in tags) + { + if (name.Equals(tag, StringComparison.OrdinalIgnoreCase)) + { + attributes = list; + return true; + } + } + + attributes = []; + return false; + } +} + /// Registers MXP decoding on a . public static class MxpDecoding { @@ -38,6 +128,14 @@ public static DecoderRegistry WithMxp(this DecoderRegistry registry) return registry.With(MxpPlugin.Instance); } + /// The registry with an that reads as say. + public static DecoderRegistry WithMxp(this DecoderRegistry registry, MxpOptions options) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + return registry.With(new MxpPlugin(options)); + } + /// The codec that stores , for a registry that serialises decoded lines. public static IMarkupCodec FlagCodec { get; } = new MxpFlagCodec(); @@ -73,21 +171,42 @@ public IMarkup Read(JsonElement element) => /// public sealed class MxpPlugin : IDecoderPlugin { - /// The one instance. - public static MxpPlugin Instance { get; } = new(); + /// The plugin with . + public static MxpPlugin Instance { get; } = new(MxpOptions.Default); /// The tags this decoder reads, as <SUPPORTS> answers. public static IReadOnlyList Supported { get; } = [ "B", "BOLD", "STRONG", "I", "ITALIC", "EM", "U", "UNDERLINE", "S", "STRIKEOUT", "H", "HIGH", "C", "COLOR", "FONT", "BR", "SBR", "P", "NOBR", "A", "SEND", "EXPIRE", "VERSION", "SUPPORT", "IMAGE", "SOUND", "MUSIC", "FRAME", "DEST", - "RELOCATE", "USER", "PASSWORD", "VAR", "ELEMENT", "ENTITY", "ATTLIST", "TAG", + "RELOCATE", "USER", "PASSWORD", "VAR", "GAUGE", "STAT", "ELEMENT", "ENTITY", "ATTLIST", "TAG", ]; - private MxpPlugin() + /// The most elements one connection may define with <!ELEMENT>. + public const int MaxElements = 256; + + /// The most entities one connection may define with <!ENTITY>. + public const int MaxEntities = 512; + + /// The longest element definition or entity value kept; a longer one is refused. + public const int MaxDefinitionLength = 1024; + + /// How deep an element's definition may open other elements. + public const int MaxExpansionDepth = 4; + + /// How many element expansions one line may cost. + public const int MaxExpansionsPerLine = 64; + + /// A plugin that reads as say. + public MxpPlugin(MxpOptions options) { + ArgumentNullException.ThrowIfNull(options); + Options = options; } + /// How this plugin reads. + public MxpOptions Options { get; } + /// public string Name => "mxp"; @@ -96,11 +215,19 @@ public void Register(DecoderBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder - .TextProtocol(MxpDecoding.Protocol, static () => new MxpReader()) + .TextProtocol(MxpDecoding.Protocol, () => new MxpReader(Options)) .OnCsi('z', static (in CsiSequence sequence, DecodeContext context) => { // A line mode means nothing until MXP is on, and is dropped. - if (context.IsActive(MxpDecoding.Protocol)) MxpReader.LineMode(sequence.Get(0), context); + // ESC [ z with no number is not a line mode: it moves nothing. + if (context.IsActive(MxpDecoding.Protocol)) MxpReader.LineMode(sequence.Get(0, -1), context); + return true; + }) + .OnControl('\t', static (_, context) => + { + // A tab inside a tag is part of the tag, not a move of the cursor. + if (!context.IsActive(MxpDecoding.Protocol) || context.State().Reader is not { InMarkup: true } reader) return false; + reader.Feed("\t", context); return true; }); } @@ -154,10 +281,20 @@ internal sealed class MxpState public int CaptureDepth { get; set; } - public List<(ImageMarkup Image, int Rows, int Columns, int Column, int Next)> Overlays { get; } = []; + public List<(ImageMarkup Image, int Rows, int Columns, int Column, int Next, MarkupText Inline)> Overlays { get; } = []; + + public int ExpansionDepth { get; set; } + + public MxpReader? Reader { get; set; } + + public bool Replaying { get; set; } + + public int ExpansionsThisLine { get; set; } + + public string? StyleVersion { get; set; } } -internal sealed class MxpReader : ITextProtocol, ITagSink +internal sealed class MxpReader(MxpOptions options) : ITextProtocol, ITagSink, ISequenceObserver { private const int MaxCapture = 4096; @@ -192,9 +329,12 @@ private bool Secure } } + public bool InMarkup => _scanner.InMarkup; + public void Feed(ReadOnlySpan text, DecodeContext context) { _context = context; + State.Reader = this; _scanner.Feed(text, this); } @@ -212,6 +352,7 @@ public void EndLine(DecodeContext context) if (index >= 0) CloseFrom(index); state.Line = state.Default; state.TemporarySecure = false; + state.ExpansionsThisLine = 0; } public void Reset(DecodeContext context) @@ -232,8 +373,8 @@ internal static void LineMode(int mode, DecodeContext context) switch (mode) { case 0: state.Line = MxpMode.Open; break; - case 1: state.Line = MxpMode.Secure; break; - case 2: state.Line = MxpMode.Locked; break; + case 1: LeaveOpen(context); state.Line = MxpMode.Secure; break; + case 2: LeaveOpen(context); state.Line = MxpMode.Locked; break; case 3: state.Line = state.Default = MxpMode.Open; var first = state.Open.FirstOrDefault(element => element.First is not null)?.First; @@ -242,31 +383,66 @@ internal static void LineMode(int mode, DecodeContext context) break; case 4: state.TemporarySecure = true; break; case 5: state.Line = state.Default = MxpMode.Open; break; - case 6: state.Line = state.Default = MxpMode.Secure; break; - case 7: state.Line = state.Default = MxpMode.Locked; break; + case 6: LeaveOpen(context); state.Line = state.Default = MxpMode.Secure; break; + case 7: LeaveOpen(context); state.Line = state.Default = MxpMode.Locked; break; case >= 10 and <= 99: - state.Line = MxpMode.Secure; if (state.LineTags.TryGetValue(mode, out var element) && state.Elements.TryGetValue(element, out var definition)) { // The line is the element; the reader opens it and the line's end closes it. - var reader = new MxpReader { _context = context }; - reader.OpenCustom(definition, new ParsedTag(definition.Name, false, []), lineTag: true); + LeaveOpen(context); + state.Line = MxpMode.Secure; + var reader = new MxpReader(MxpOptions.Default) { _context = context }; + reader.OpenCustom(definition, new ParsedTag(definition.Name, false, []), lineTag: true, trusted: true); } else if (mode switch { 10 => MxpFlagMarkup.RoomName, 11 => MxpFlagMarkup.RoomDescription, 12 => MxpFlagMarkup.RoomExits, 19 => MxpFlagMarkup.Welcome, _ => null } is { } flag) { + LeaveOpen(context); + state.Line = MxpMode.Secure; var markup = new MxpFlagMarkup(flag); if (context.Open(markup)) state.Open.Add(new MxpOpenElement(flag, markup, openMode: false, lineTag: true)); } + // A number with no meaning moves nothing, in either direction. break; } } + /// Leaving open mode mid-line ends what the open part of the line opened, as the line's end would. + private static void LeaveOpen(DecodeContext context) + { + var state = context.State(); + if (state.Line != MxpMode.Open) return; + var index = state.Open.FindIndex(element => element.OpenMode); + if (index < 0) return; + for (var i = index; i < state.Open.Count; i++) + { + if (state.Open[i].First is { } first) + { + context.Close(first); + break; + } + } + + state.Open.RemoveRange(index, state.Open.Count - index); + } + + // ── ISequenceObserver ──────────────────────────────────────────────────────── + + /// + /// A sequence between ESC [ 4 z and the tag it is for ends it: the one tag it makes secure must come + /// straight after. ESC [ 4 z itself is a sequence too, and arms it again as it is handled. + /// + public void SequenceSeen(DecodeContext context) => context.State().TemporarySecure = false; + // ── ITagSink ──────────────────────────────────────────────────────────────── public void Text(ReadOnlySpan text) { var state = State; + + // Text between ESC [ 4 z and a tag ends it, locked text too, so the tag after the server's words is not the + // one it was for. + if (!text.IsEmpty) state.TemporarySecure = false; if (state.Capture is not null) { Captured(text, text); @@ -280,6 +456,7 @@ public void Entity(ReadOnlySpan name) { var decoded = TagSyntax.Entity(name, LookupEntity); var state = State; + state.TemporarySecure = false; if (state.Capture is not null) { Captured($"&{name};", decoded ?? $"&{name};"); @@ -316,36 +493,54 @@ public void Tag(ReadOnlySpan body) if (!body.IsEmpty && body[0] == '!') { if (secure) Definition(body[1..]); - else Context.Diagnostics.UnhandledSequences++; + else Refuse(body); return; } var tag = TagSyntax.Parse(body, LookupEntity); if (tag is null) return; + var custom = state.Elements.TryGetValue(tag.Name, out var definition) ? definition : null; + var open = custom?.Open ?? OpenTags.Contains(tag.Name); + if (tag.Closing) { + // The close of a secure tag nothing opened is refused like its opener; one that closes what is open is not. + if (!secure && !open && state.Open.FindLastIndex(element => element.Name.Equals(tag.Name, StringComparison.OrdinalIgnoreCase)) < 0) + { + Refuse(body); + return; + } + Close(tag.Name); return; } - var custom = state.Elements.TryGetValue(tag.Name, out var definition) ? definition : null; - var open = custom?.Open ?? OpenTags.Contains(tag.Name); if (!secure && !open) { - Context.Diagnostics.UnhandledSequences++; + Refuse(body); return; } if (custom is not null) { - OpenCustom(custom, tag, lineTag: false); + OpenCustom(custom, tag, lineTag: false, trusted: secure); return; } if (!Element(tag, openMode: !secure)) Context.Diagnostics.UnhandledSequences++; } + /// A tag this line may not use: counted, and shown as the text it was when the options say so. + private void Refuse(ReadOnlySpan body) + { + Context.Diagnostics.UnhandledSequences++; + if (!options.ShowRefusedTags || State.Replaying) return; + Context.Print("<"); + Context.Print(body); + Context.Print(">"); + } + // ── Elements ──────────────────────────────────────────────────────────────── private bool Element(ParsedTag tag, bool openMode) @@ -387,7 +582,7 @@ private bool Element(ParsedTag tag, bool openMode) IMarkup? link = href is not null && Context.Options.Links.IsAllowed(href) ? new AnsiMarkup(new AnsiStyle { LinkUrl = href, LinkKind = LinkKind.Url, LinkText = tag.Get("HINT", 1) }) : null; - OpenElement(tag.Name, link is null ? [] : [link], openMode); + OpenElement(tag.Name, link is null ? [] : [link], openMode, lineTag: true); return true; } case "SEND": @@ -409,15 +604,23 @@ private bool Element(ParsedTag tag, bool openMode) context.Point(new ExpireLinksMarkup(tag.Get("NAME", 0))); return true; case "VERSION": + if (tag.Get("STYLE", 0) is { } style) + { + // from the server sets the style-sheet version the client reports back. + State.StyleVersion = style.Length <= 32 ? style : style[..32]; + return true; + } + if ((context.Identity.Answers & ClientAnswers.Mxp) != 0) { + var styleField = State.StyleVersion is { } set ? $" STYLE={Quote(set)}" : ""; context.Reply(string.Create(CultureInfo.InvariantCulture, - $"\e[1z\n")); + $"\e[1z\n")); } return true; case "SUPPORT": - if ((context.Identity.Answers & ClientAnswers.Mxp) != 0) context.Reply(Supports(tag)); + if ((context.Identity.Answers & ClientAnswers.Mxp) != 0) context.Reply(Supports(tag, options)); return true; case "IMAGE": Image(tag); @@ -428,7 +631,24 @@ private bool Element(ParsedTag tag, bool openMode) case "FRAME": { var name = tag.Get("NAME", 0); - if (name is not null) State.FrameTitles[name] = tag.Get("TITLE"); + if (name is null) return true; + var action = tag.Get("ACTION")?.ToUpperInvariant() ?? (tag.Has("REDIRECT") ? "REDIRECT" : "OPEN"); + var main = name.Equals("_top", StringComparison.OrdinalIgnoreCase) || name.Equals("_previous", StringComparison.OrdinalIgnoreCase); + switch (action) + { + case "REDIRECT": + // Every line from here goes to the frame, until a redirect back to the main window. + context.Destination = main ? null : name; + break; + case "CLOSE": + State.FrameTitles.Remove(name); + if (string.Equals(context.Destination, name, StringComparison.OrdinalIgnoreCase)) context.Destination = null; + break; + default: + if (!main) State.FrameTitles[name] = tag.Get("TITLE"); + break; + } + return true; } case "DEST": @@ -453,8 +673,15 @@ private bool Element(ParsedTag tag, bool openMode) context.Point(new LoginPromptMarkup(LoginField.Password)); return true; case "GAUGE" or "STAT": - // A gauge stands for a variable the host already has from VAR and ENTITY; there is no text to show. - return true; + { + // A gauge stands for a variable the host already has from VAR and ENTITY; there is no text to show, + // only how the host is asked to show it. + var variable = tag.Get("ENTITY", 0); + if (string.IsNullOrEmpty(variable)) return false; + context.Raise(new GaugeEvent(variable, tag.Get("MAX", 1), tag.Get("CAPTION", 2), tag.Get("COLOR"), + Status: tag.Name.Equals("STAT", StringComparison.OrdinalIgnoreCase))); + return true; + } default: return false; } @@ -463,6 +690,8 @@ private bool Element(ParsedTag tag, bool openMode) private void OpenStyle(string name, AnsiStyle style, bool openMode) => OpenElement(name, style.IsNone ? [] : [new AnsiMarkup(style)], openMode); + // lineTag: the element ends with the line, as a line tag's does. A link does, wherever it was opened, so a SEND the + // server left open does not make every line after it a command. private void OpenElement(string name, IMarkup[] markups, bool openMode, bool lineTag = false) { IMarkup? first = null; @@ -503,35 +732,59 @@ private void OpenSend(ParsedTag tag, string href, bool openMode) var hints = tag.Get("HINT", 1)?.Split('|') ?? []; var prompt = tag.Has("PROMPT"); - // With one hint more than commands, the first is the link's own and the rest label the menu. + // One command has the whole hint. With several, one hint more than commands is the link's own and then the + // menu's labels, and as many hints as commands are the labels alone. string? hint; string[] labels; - if (hints.Length == commands.Length + 1) + if (commands.Length == 1) + { + hint = tag.Get("HINT", 1); + labels = commands; + } + else if (hints.Length == commands.Length + 1) { hint = hints[0]; labels = hints[1..]; } + else if (hints.Length == commands.Length) + { + hint = null; + labels = hints; + } else { hint = hints.Length > 0 ? hints[0] : null; - labels = hints.Length == commands.Length ? hints : commands; + labels = commands; } var link = new AnsiMarkup(new AnsiStyle { LinkUrl = commands[0], LinkKind = LinkKind.Command, LinkText = hint }); if (commands.Length == 1 && !prompt) { - OpenElement(tag.Name, [link], openMode); + OpenElement(tag.Name, [link], openMode, lineTag: true); return; } var choices = new CommandChoice[commands.Length]; for (var i = 0; i < commands.Length; i++) choices[i] = new CommandChoice(labels[i], commands[i]); - OpenElement(tag.Name, [new CommandMenuMarkup(choices, hint, prompt), link], openMode); + OpenElement(tag.Name, [new CommandMenuMarkup(choices, hint, prompt), link], openMode, lineTag: true); } - private void OpenCustom(MxpElementDefinition definition, ParsedTag tag, bool lineTag) + /// + /// Opens a custom element. Its definition's tags run with the trust the element was used with: anything, used on a + /// secure line; only open tags, used by a player on an open line, where whatever else the definition holds is + /// dropped (it is the server's text, not the player's, so it is not shown). + /// + private void OpenCustom(MxpElementDefinition definition, ParsedTag tag, bool lineTag, bool trusted) { var state = State; + if (state.ExpansionDepth >= MxpPlugin.MaxExpansionDepth || state.ExpansionsThisLine >= MxpPlugin.MaxExpansionsPerLine) + { + // An element that opens itself, or a line of thousands of them, is a bomb rather than markup. + Context.Diagnostics.LimitsReached++; + return; + } + + state.ExpansionsThisLine++; var before = Context.Elements.Count; var opened = state.Open.Count; @@ -542,8 +795,20 @@ private void OpenCustom(MxpElementDefinition definition, ParsedTag tag, bool lin var replay = new TagScanner(); state.TemporarySecure = false; var saved = state.Line; - state.Line = MxpMode.Secure; - replay.Feed(expanded, this); + var replaying = state.Replaying; + state.Line = trusted ? MxpMode.Secure : MxpMode.Open; + state.Replaying = true; + state.ExpansionDepth++; + try + { + replay.Feed(expanded, this); + } + finally + { + state.ExpansionDepth--; + state.Replaying = replaying; + } + state.Line = saved; // What the definition opened is one element now, closed together. @@ -566,6 +831,9 @@ private static string Expand(MxpElementDefinition definition, ParsedTag tag) { var name = attribute.Name ?? attribute.Value; var value = tag.Get(name, position++) ?? (attribute.Name is null ? string.Empty : attribute.Value); + + // A value cannot close the tag it is put into and open another. + value = value.Replace("<", string.Empty, StringComparison.Ordinal).Replace(">", string.Empty, StringComparison.Ordinal); text = text.Replace($"&{name};", value, StringComparison.OrdinalIgnoreCase); } @@ -590,8 +858,18 @@ private void Definition(ReadOnlySpan body) return; } + // The definition is the second positional value, unless that is one of the element's keywords. + var second = tag.Get("DEFINITION", 1); + var definitionText = tag.Get("DEFINITION") ?? (second is not null && !ElementKeywords.Contains(second) ? second : null) ?? string.Empty; + if (IsBuiltIn(name) || definitionText.Length > MxpPlugin.MaxDefinitionLength + || (!state.Elements.ContainsKey(name) && state.Elements.Count >= MxpPlugin.MaxElements)) + { + Context.Diagnostics.LimitsReached++; + return; + } + var attributes = TagSyntax.Parse("x " + (tag.Get("ATT") ?? string.Empty))?.Arguments ?? []; - var definition = new MxpElementDefinition(name, tag.Get("DEFINITION", 1) ?? string.Empty, attributes, tag.Get("FLAG"), tag.Has("OPEN"), tag.Has("EMPTY")); + var definition = new MxpElementDefinition(name, definitionText, attributes, tag.Get("FLAG"), tag.Has("OPEN"), tag.Has("EMPTY")); state.Elements[name] = definition; if (int.TryParse(tag.Get("TAG"), NumberStyles.None, CultureInfo.InvariantCulture, out var number) && number is >= 20 and <= 99) { @@ -616,7 +894,7 @@ private void Definition(ReadOnlySpan body) } case "ENTITY" or "EN": { - if (tag.Has("DELETE") || tag.Has("REMOVE")) + if (tag.Has("DELETE")) { state.Entities.Remove(name); Context.Raise(new VariableEvent(name, null)); @@ -624,7 +902,26 @@ private void Definition(ReadOnlySpan body) } var value = tag.Get("VALUE", 1) ?? string.Empty; - if (tag.Has("ADD") && state.Entities.TryGetValue(name, out var existing)) value = existing + "|" + value; + state.Entities.TryGetValue(name, out var existing); + if (tag.Has("REMOVE")) + { + // REMOVE takes the value out of the list the entity holds; the entity stays. + if (existing is null) return; + value = string.Join('|', existing.Split('|').Where(item => !item.Equals(value, StringComparison.Ordinal))); + } + else if (tag.Has("ADD") && existing is not null) + { + value = existing + "|" + value; + } + + if (TagSyntax.Entity(name, static _ => null) is not null || value.Length > MxpPlugin.MaxDefinitionLength + || (!state.Entities.ContainsKey(name) && state.Entities.Count >= MxpPlugin.MaxEntities)) + { + // < and the other built-in entities keep their meaning, and the table stays bounded. + Context.Diagnostics.LimitsReached++; + return; + } + state.Entities[name] = value; if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(name, value)); return; @@ -632,6 +929,12 @@ private void Definition(ReadOnlySpan body) } } + private static readonly HashSet ElementKeywords = new(["OPEN", "EMPTY", "DELETE"], StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet BuiltIns = new(MxpPlugin.Supported.Concat(["STRIKE", "SMALL", "TT", "GAUGE", "STAT", "V"]), StringComparer.OrdinalIgnoreCase); + + private static bool IsBuiltIn(string name) => BuiltIns.Contains(name) || OpenTags.Contains(name); + // ── Capturing the text of SEND and VAR ────────────────────────────────────── private void StartCapture(ParsedTag tag) @@ -699,7 +1002,7 @@ private void Image(ParsedTag tag) { var file = tag.Get("FNAME", 0); if (string.IsNullOrEmpty(file)) return; - var source = Address(tag.Get("URL"), file); + var source = Address(Directory(tag.Get("URL"), tag.Get("T")), file); var (width, widthCells) = Size(tag.Get("W")); var (height, heightCells) = Size(tag.Get("H")); var align = Enum.TryParse(tag.Get("ALIGN"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null; @@ -707,11 +1010,22 @@ private void Image(ParsedTag tag) if (widthCells > 0 && heightCells > 0) { - // Sized in cells: drawn over the cells the server leaves for it on this line and the ones after. - State.Overlays.Add((image, heightCells, widthCells, Context.Column, 0)); + // Sized in cells: drawn over the cells the server leaves for it on this line and the ones after. If it left + // none, the picture goes in the line where its tag was, as one sized in pixels does. + var set = Context.CurrentMarkup; + var layers = new IMarkup[(set?.Count ?? 0) + 1]; + layers[0] = image; + for (var i = 1; i < layers.Length; i++) layers[i] = set![i - 1]; + layers[0] = image with { Width = widthCells * Context.Identity.CellWidth, Height = heightCells * Context.Identity.CellHeight }; + State.Overlays.Add((image, heightCells, widthCells, Context.Column, 0, MarkupText.Wrap(MarkupSet.Of(layers.AsSpan()), file))); return; } + // Sized in cells one way only, the other way is the client's cell size in pixels. + width ??= widthCells > 0 ? widthCells * Context.Identity.CellWidth : null; + height ??= heightCells > 0 ? heightCells * Context.Identity.CellHeight : null; + image = image with { Width = width, Height = height }; + Context.Write(file, image); } @@ -731,16 +1045,44 @@ private void DrawOverlays() var cursor = context.Column; for (var i = 0; i < overlays.Count; i++) { - var (image, rows, columns, column, next) = overlays[i]; - context.MoveToColumn(column); - context.Write(new string(' ', columns), image with { Row = new PictureRow(next, rows, columns) }); - overlays[i] = (image, rows, columns, column, next + 1); + var (image, rows, columns, column, next, inline) = overlays[i]; + if (next == 0 && !Blank(context.PendingLine, column, columns)) + { + // The server left no room for it here: the picture goes where its tag was instead of over its text. + context.Insert(column, inline); + if (cursor >= column) cursor += inline.DisplayWidth; + overlays[i] = (image, rows, columns, column, rows, inline); + continue; + } + + // A later row drawn over text would hide it; that row of the picture is left out instead. + if (next == 0 || Blank(context.PendingLine, column, columns)) + { + context.MoveToColumn(column); + context.Write(new string(' ', columns), image with { Row = new PictureRow(next, rows, columns) }); + } + + overlays[i] = (image, rows, columns, column, next + 1, inline); } overlays.RemoveAll(overlay => overlay.Next >= overlay.Rows); context.MoveToColumn(cursor); } + /// Whether the line has blank cells from . + private static bool Blank(MarkupText line, int column, int columns) + { + if (line.DisplayWidth < column + columns) return false; + var start = DisplayWidth.IndexAtWidth(line.Text, column); + var end = DisplayWidth.IndexAtWidth(line.Text, column + columns); + foreach (var c in line.Text.AsSpan(start, end - start)) + { + if (c is not (' ' or '\u00a0')) return false; + } + + return true; + } + private void Sound(ParsedTag tag) { var music = tag.Name.Equals("MUSIC", StringComparison.OrdinalIgnoreCase); @@ -758,7 +1100,20 @@ private void Sound(ParsedTag tag) ? l < 0 ? SoundMarkup.Forever : l : null; var continues = music && tag.Get("C") == "1"; - Context.Point(new SoundMarkup(Address(tag.Get("U"), file), channel, volume, repeats, continues)); + int? priority = !music && int.TryParse(tag.Get("P"), NumberStyles.None, CultureInfo.InvariantCulture, out var p) ? Math.Clamp(p, 0, 100) : null; + var type = tag.Get("T") is { Length: > 0 } t ? t : null; + Context.Point(new SoundMarkup(Address(tag.Get("U"), file), channel, volume, repeats, continues) + { + Priority = priority, + Type = type, + }); + } + + // MXP puts an image type's files in a folder of that name under the URL. + private static string? Directory(string? directory, string? type) + { + if (string.IsNullOrEmpty(directory) || string.IsNullOrWhiteSpace(type)) return directory; + return (directory.EndsWith('/') ? directory : directory + "/") + type.Trim().Trim('/'); } private static string Address(string? directory, string file) @@ -787,25 +1142,11 @@ private static string Address(string? directory, string file) private static string Quote(string value) => "\"" + value.Replace("\"", "'", StringComparison.Ordinal) + "\""; - private static string Supports(ParsedTag tag) + private static string Supports(ParsedTag tag, MxpOptions options) { - var supported = new HashSet(MxpPlugin.Supported, StringComparer.OrdinalIgnoreCase); - var answer = new StringBuilder("\e[1z argument.Name is null).Select(argument => argument.Value).ToList(); - if (asked.Count == 0) - { - foreach (var name in MxpPlugin.Supported) answer.Append(" +").Append(name.ToLowerInvariant()); - } - else - { - foreach (var item in asked) - { - // "send.prompt" asks about an attribute; the element's answer stands for it. - var element = item.Split('.')[0]; - answer.Append(supported.Contains(element) ? " +" : " -").Append(item.ToLowerInvariant()); - } - } - + var answer = new StringBuilder("\e[1z\n").ToString(); } } diff --git a/MarkupString.Mxp/PublicAPI.Unshipped.txt b/MarkupString.Mxp/PublicAPI.Unshipped.txt index ea461a6..c6a30a8 100644 --- a/MarkupString.Mxp/PublicAPI.Unshipped.txt +++ b/MarkupString.Mxp/PublicAPI.Unshipped.txt @@ -24,3 +24,26 @@ static MarkupString.Mxp.Decoding.MxpFlagMarkup.operator !=(MarkupString.Mxp.Deco static MarkupString.Mxp.Decoding.MxpFlagMarkup.operator ==(MarkupString.Mxp.Decoding.MxpFlagMarkup? left, MarkupString.Mxp.Decoding.MxpFlagMarkup? right) -> bool static MarkupString.Mxp.Decoding.MxpPlugin.Instance.get -> MarkupString.Mxp.Decoding.MxpPlugin! static MarkupString.Mxp.Decoding.MxpPlugin.Supported.get -> System.Collections.Generic.IReadOnlyList! +const MarkupString.Mxp.Decoding.MxpPlugin.MaxElements = 256 -> int +const MarkupString.Mxp.Decoding.MxpPlugin.MaxEntities = 512 -> int +const MarkupString.Mxp.Decoding.MxpPlugin.MaxDefinitionLength = 1024 -> int +const MarkupString.Mxp.Decoding.MxpPlugin.MaxExpansionDepth = 4 -> int +const MarkupString.Mxp.Decoding.MxpPlugin.MaxExpansionsPerLine = 64 -> int +MarkupString.Mxp.Decoding.MxpOptions +MarkupString.Mxp.Decoding.MxpOptions.$() -> MarkupString.Mxp.Decoding.MxpOptions! +MarkupString.Mxp.Decoding.MxpOptions.Answer(System.Collections.Generic.IReadOnlyList! asked) -> (System.Collections.Generic.IReadOnlyList! Supported, System.Collections.Generic.IReadOnlyList! Unsupported) +MarkupString.Mxp.Decoding.MxpOptions.Equals(MarkupString.Mxp.Decoding.MxpOptions? other) -> bool +MarkupString.Mxp.Decoding.MxpOptions.MxpOptions() -> void +MarkupString.Mxp.Decoding.MxpOptions.ShowRefusedTags.get -> bool +MarkupString.Mxp.Decoding.MxpOptions.ShowRefusedTags.init -> void +MarkupString.Mxp.Decoding.MxpOptions.Supports.get -> System.Collections.Generic.IReadOnlyDictionary!>? +MarkupString.Mxp.Decoding.MxpOptions.Supports.init -> void +MarkupString.Mxp.Decoding.MxpPlugin.MxpPlugin(MarkupString.Mxp.Decoding.MxpOptions! options) -> void +MarkupString.Mxp.Decoding.MxpPlugin.Options.get -> MarkupString.Mxp.Decoding.MxpOptions! +override MarkupString.Mxp.Decoding.MxpOptions.Equals(object? obj) -> bool +override MarkupString.Mxp.Decoding.MxpOptions.GetHashCode() -> int +override MarkupString.Mxp.Decoding.MxpOptions.ToString() -> string! +static MarkupString.Mxp.Decoding.MxpDecoding.WithMxp(this MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Mxp.Decoding.MxpOptions! options) -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Mxp.Decoding.MxpOptions.Default.get -> MarkupString.Mxp.Decoding.MxpOptions! +static MarkupString.Mxp.Decoding.MxpOptions.operator !=(MarkupString.Mxp.Decoding.MxpOptions? left, MarkupString.Mxp.Decoding.MxpOptions? right) -> bool +static MarkupString.Mxp.Decoding.MxpOptions.operator ==(MarkupString.Mxp.Decoding.MxpOptions? left, MarkupString.Mxp.Decoding.MxpOptions? right) -> bool diff --git a/MarkupString.Pueblo/Decoding/PuebloDecoding.cs b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs index dec48d6..063889c 100644 --- a/MarkupString.Pueblo/Decoding/PuebloDecoding.cs +++ b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs @@ -100,6 +100,11 @@ public void EndLine(DecodeContext context) { _context = context; _scanner.Flush(this); + + // A link ends with its line, so one the server left open does not make what follows a link. + var open = State.Open; + var index = open.FindIndex(element => element.Name is "a" or "send"); + if (index >= 0) CloseFrom(index); } public void Reset(DecodeContext context) @@ -197,6 +202,13 @@ private bool Element(ParsedTag tag, string name) if (tag.Get("xch_cmd") is { Length: > 0 } command) { link = new AnsiStyle { LinkUrl = command, LinkKind = LinkKind.Command, LinkText = hint }; + if (string.Equals(tag.Get("xch_mode"), "prompt", StringComparison.OrdinalIgnoreCase)) + { + // xch_mode=prompt puts the command on the player's input line instead of sending it. + OpenElement(name, new CommandMenuMarkup([new CommandChoice(command, command)], hint, Prompt: true)); + OpenElement(name, new AnsiMarkup(link.Value)); + return true; + } } else if (tag.Get("href") is { } href && Context.Options.Links.IsAllowed(href)) { @@ -206,6 +218,14 @@ private bool Element(ParsedTag tag, string name) OpenElement(name, link is { } style ? new AnsiMarkup(style) : null); return true; } + case "send": + { + // Not Pueblo's own, but servers that speak MXP too send it: a command link. + var command = tag.Get("href", 0); + OpenElement(name, string.IsNullOrEmpty(command) ? null + : new AnsiMarkup(new AnsiStyle { LinkUrl = command, LinkKind = LinkKind.Command, LinkText = tag.Get("hint") })); + return true; + } case "br": context.EndLine(); return true; @@ -291,9 +311,25 @@ private void OpenElement(string name, IMarkup? markup) private void Close(string name) { + // HTML's other spellings of the same element close it: closes . + var canonical = Canonical(name); var open = State.Open; - var index = open.FindLastIndex(element => element.Name == name); + var index = open.FindLastIndex(element => Canonical(element.Name) == canonical); if (index < 0) return; + CloseFrom(index); + } + + private static string Canonical(string name) => name switch + { + "strong" => "b", + "em" => "i", + "strike" or "del" => "s", + _ => name, + }; + + private void CloseFrom(int index) + { + var open = State.Open; for (var i = index; i < open.Count; i++) { if (open[i].First is { } first) diff --git a/MarkupString.Tests/Decoding/MxpDecodingTests.cs b/MarkupString.Tests/Decoding/MxpDecodingTests.cs index 734a819..954328d 100644 --- a/MarkupString.Tests/Decoding/MxpDecodingTests.cs +++ b/MarkupString.Tests/Decoding/MxpDecodingTests.cs @@ -40,7 +40,9 @@ public async Task SecureTags_AreDroppedOnAnOpenLine() var (lines, _, decoder) = Decode("look\n"); await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("look"); await Assert.That(Spans(lines[0]).Single().Style.Interaction).IsNull(); - await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(1); + + // The opener, and the close of a secure tag nothing opened. + await Assert.That(decoder.Diagnostics.UnhandledSequences).IsEqualTo(2); } [Test] @@ -162,6 +164,15 @@ await Assert.That(events.OfType().Select(reply => reply.Text)).IsEqu ]); } + [Test] + public async Task Version_ReportsTheStyleVersionTheServerSet() + { + var options = new DecoderOptions { Identity = new ClientIdentity { Name = "Test", Version = "3" } }; + var (_, events, _) = Decode("\e[1z\e[1z", options); + await Assert.That(events.OfType().Single().Text) + .IsEqualTo("\e[1z\n"); + } + [Test] public async Task Var_SetsAVariable_AndShowsItsText() { @@ -234,4 +245,213 @@ public async Task Image_InCells_IsDrawnOverTheLines() await Assert.That(lines[1].Content.Runs.SelectMany(run => run.Markups).OfType().Single().Row!.Value.Row).IsEqualTo(1); await Assert.That(lines[1].Content.ToPlainText()).IsEqualTo(" b"); } + + [Test] + public async Task Gauge_OnASecureLine_RaisesAnEvent() + { + var (lines, events, _) = Decode("\e[1z\n"); + await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo(string.Empty); + await Assert.That(events.OfType()).IsEquivalentTo([ + new GaugeEvent("hp", "maxhp", "Health", "red", Status: false), + new GaugeEvent("xp", null, null, null, Status: true), + ]); + } + + [Test] + public async Task Gauge_OnAnOpenLine_IsDropped() + { + var (_, events, _) = Decode("\n"); + await Assert.That(events.OfType()).IsEmpty(); + } + + [Test] + public async Task FrameRedirect_SendsLinesToTheFrameUntilTop() + { + var (lines, _, _) = Decode("\e[1z\none\ntwo\n\e[1z\nthree\n"); + var shown = lines.Where(line => !line.Content.ToPlainText().Equals(string.Empty)).Select(line => (line.Content.ToPlainText(), line.Destination)); + await Assert.That(shown).IsEquivalentTo([("one", "chat"), ("two", "chat"), ("three", (string?)null)]); + } + + [Test] + public async Task FrameClose_EndsARedirectToIt() + { + var (lines, _, _) = Decode("\e[1z\none\n\e[1z\ntwo\n"); + await Assert.That(lines.Single(line => line.Content.ToPlainText() == "two").Destination).IsNull(); + } + + [Test] + public async Task Sound_CarriesItsPriorityAndType() + { + var (lines, _, _) = Decode("\e[1z\n"); + var sound = Markups(lines[0]).OfType().Single(); + await Assert.That(sound.Source).IsEqualTo("https://example.test/sounds/hit.wav"); + await Assert.That(sound.Priority).IsEqualTo(70); + await Assert.That(sound.Type).IsEqualTo("combat"); + } + + [Test] + public async Task Image_PutsItsTypeBetweenTheUrlAndTheFile() + { + var (lines, _, _) = Decode("\e[1z\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Source).IsEqualTo("https://example.test/images/maps/map.png"); + } + + [Test] + public async Task AnElementThatOpensItself_IsCutOffAtTheDepthLimit() + { + var (lines, _, decoder) = Decode("\e[1zx\">\n\e[1ztext\n"); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo(new string('x', MxpPlugin.MaxExpansionDepth) + "text"); + await Assert.That(decoder.Diagnostics.LimitsReached).IsGreaterThan(0); + } + + [Test] + public async Task ALineOfElements_IsCappedPerLine() + { + var many = string.Concat(Enumerable.Repeat("", MxpPlugin.MaxExpansionsPerLine + 10)); + var (_, _, decoder) = Decode("\e[1z\" EMPTY>\n\e[1z" + many + "\n"); + await Assert.That(decoder.Diagnostics.LimitsReached).IsEqualTo(10); + } + + [Test] + public async Task ABuiltInCannotBeRedefined() + { + var (lines, _, _) = Decode("\e[1z\">\n\e[1zbold\n"); + var style = Spans(lines[^1]).Single().Style; + await Assert.That(style.Has(TextAttributes.Bold)).IsTrue(); + await Assert.That(style.Has(TextAttributes.Italic)).IsFalse(); + } + + [Test] + public async Task ABuiltInEntityCannotBeRedefined() + { + var (lines, events, _) = Decode("\e[1z\n<\n"); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("<"); + await Assert.That(events.OfType()).IsEmpty(); + } + + [Test] + public async Task DefinitionsAreBounded() + { + var decoder = Registry.CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + var events = new List(); + decoder.EventRaised += events.Add; + for (var i = 0; i < MxpPlugin.MaxEntities + 5; i++) decoder.Feed($"\e[1z\n"); + decoder.Feed($"\e[1z\n"); + await Assert.That(events.OfType().Count()).IsEqualTo(MxpPlugin.MaxEntities); + await Assert.That(decoder.Diagnostics.LimitsReached).IsEqualTo(6); + } + + [Test] + public async Task OpenElements_ListWhatGoesOnIntoTheNextLine() + { + var decoder = Registry.CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + decoder.Feed("\e[1zbold\n"); + await Assert.That(decoder.OpenElements.Count).IsEqualTo(1); + decoder.Feed("open\n"); + await Assert.That(decoder.OpenElements.Count).IsEqualTo(1); + } + + [Test] + public async Task EntityAddAndRemove_EditTheList() + { + var (_, events, _) = Decode("\e[1z\n"); + await Assert.That(events.OfType().Select(e => e.Value)).IsEquivalentTo(["a", "a|b", "b", null]); + } + + private static readonly DecoderRegistry Showing = VtDecoding.Terminal.WithMxp(new MxpOptions { ShowRefusedTags = true }); + + private static string Shown(string text) + { + var decoder = Showing.CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + return decoder.Feed(text)[0].Content.ToPlainText(); + } + + [Test] + public async Task ShowRefusedTags_KeepsARefusedTagAsItWasTyped() + { + const string tag = ""; + await Assert.That(Shown("Rivane says, '" + tag + "click'\n")).IsEqualTo("Rivane says, '" + tag + "click'"); + await Assert.That(Shown("'> and \n")).IsEqualTo("'> and "); + } + + [Test] + public async Task ShowRefusedTags_StillHonoursACloseOfWhatIsOpen() + { + var decoder = Showing.CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + var line = decoder.Feed("\e[1z\e[0zY said\n")[0]; + await Assert.That(line.Content.ToPlainText()).IsEqualTo("Y said"); + } + + [Test] + [Arguments("plain ")] + [Arguments("\e[0m")] + [Arguments("\e]0;title\a")] + [Arguments("\e[0z")] + [Arguments("&")] + public async Task TemporarySecure_IsEndedByAnythingBeforeItsTag(string between) + { + var (lines, _, _) = Decode("\e[4z" + between + "click\n"); + await Assert.That(Spans(lines[0]).Any(span => span.Style.Interaction is not null)).IsFalse(); + } + + [Test] + public async Task TemporarySecure_SurvivesARepeatOfItself() + { + var (lines, _, _) = Decode("\e[4z\e[4za\n"); + await Assert.That(Spans(lines[0]).Single().Style.Interaction).IsNotNull(); + } + + [Test] + public async Task TemporarySecure_IsEndedByLockedText() + { + var (lines, _, _) = Decode("\e[7zRivane says, \e[4zhello\e[0zclick\n"); + await Assert.That(Spans(lines[0]).Any(span => span.Style.Interaction is not null)).IsFalse(); + } + + [Test] + public async Task AnUnknownLineTagNumber_LeavesTheModeAlone() + { + var (secure, _, _) = Decode("\e[1z\e[99za\n"); + await Assert.That(Spans(secure[0]).Single().Style.Interaction).IsNotNull(); + var (open, _, _) = Decode("\e[15za\n"); + await Assert.That(Spans(open[0]).Single().Style.Interaction).IsNull(); + } + + [Test] + public async Task LeavingOpenModeMidLine_ClosesWhatTheOpenPartOpened() + { + var (lines, _, _) = Decode("says '' \e[1zserver text\n"); + await Assert.That(Spans(lines[0])[^1].Style.Foreground).IsNull(); + } + + [Test] + public async Task Send_HintsFollowTheCommands() + { + var (one, _, _) = Decode("\e[1zx\n"); + await Assert.That(Spans(one[0]).Single().Style.Interaction!.Hint).IsEqualTo("Look|around"); + var (labels, _, _) = Decode("\e[1zx\n"); + var menu = Markups(labels[0]).OfType().Single(); + await Assert.That(menu.Hint).IsNull(); + await Assert.That(menu.Choices.Select(c => c.Label)).IsEquivalentTo(["North", "South"]); + } + + [Test] + public async Task Supports_AnswersFromTheOptionsList() + { + var options = new MxpOptions { Supports = new Dictionary> { ["send"] = ["href", "hint"], ["b"] = [] } }; + var (supported, unsupported) = options.Answer(["send.href", "send.prompt", "send.*", "b", "image"]); + await Assert.That(supported).IsEquivalentTo(["send.href", "send.href", "send.hint", "b"]); + await Assert.That(unsupported).IsEquivalentTo(["send.prompt", "image"]); + } + + [Test] + public async Task AnEmptyElementWithNoDefinition_PrintsNothingOfItsOwn() + { + var (lines, _, _) = Decode("\e[1z\n\e[1z1234 here\n"); + await Assert.That(lines[^1].Content.ToPlainText()).IsEqualTo("1234 here"); + } } diff --git a/MarkupString/Decoding/DecodeContext.cs b/MarkupString/Decoding/DecodeContext.cs index 47fcc34..95c34ff 100644 --- a/MarkupString/Decoding/DecodeContext.cs +++ b/MarkupString/Decoding/DecodeContext.cs @@ -219,6 +219,25 @@ public void Write(ReadOnlySpan text, IMarkup? inner) Line.Write(text.ToString(), set); } + /// The line read so far, not yet ended. + public MarkupText PendingLine => Line.Peek(); + + /// + /// Puts into the line at display column , moving what is there to + /// the right, and the cursor with it when it is past that column. A column past the end puts it at the end. + /// + public void Insert(int column, MarkupText text) + { + ArgumentNullException.ThrowIfNull(text); + if (text.Length == 0) return; + var line = Line.Peek(); + var index = DisplayWidth.IndexAtWidth(line.Text, Math.Clamp(column, 0, line.DisplayWidth)); + var cursor = Line.Cursor; + Line.Take(); + if (!Line.Put(line.Insert(index, text))) Diagnostics.LimitsReached++; + Line.Cursor = cursor >= column ? cursor + text.DisplayWidth : cursor; + } + /// Writes already built text at the cursor. public void Write(MarkupText text) { @@ -346,6 +365,11 @@ internal void Feed(ReadOnlySpan text) else Print(text); } + internal void SequenceSeen() + { + if (_active is ISequenceObserver observer) observer.SequenceSeen(this); + } + internal void ResetAll() { _active?.Reset(this); diff --git a/MarkupString/Decoding/DecodeModel.cs b/MarkupString/Decoding/DecodeModel.cs index 013fdb8..dba35b6 100644 --- a/MarkupString/Decoding/DecodeModel.cs +++ b/MarkupString/Decoding/DecodeModel.cs @@ -73,6 +73,17 @@ public sealed record VariableEvent(string Name, string? Value) : DecodeEvent; /// Its size in bytes. public sealed record DownloadEvent(string? Name, int Size) : DecodeEvent; +/// +/// The server asked for a variable to be shown as a gauge or a status value (MXP <GAUGE>, +/// <STAT>). The values come from s; this says how to show them. +/// +/// The variable that holds the value. +/// The variable that holds its maximum, if any. +/// What to call it, if the server said. +/// The colour the server asked for, as it wrote it, if any. +/// True for <STAT>, a value shown as text; false for <GAUGE>, a bar. +public sealed record GaugeEvent(string Variable, string? Maximum, string? Caption, string? Color, bool Status) : DecodeEvent; + /// A text protocol such as MXP was turned on or off. /// The protocol. /// Whether it is now on. diff --git a/MarkupString/Decoding/Decoder.cs b/MarkupString/Decoding/Decoder.cs index 0132d80..7261022 100644 --- a/MarkupString/Decoding/Decoder.cs +++ b/MarkupString/Decoding/Decoder.cs @@ -50,6 +50,12 @@ internal Decoder(DecoderRegistry registry, DecoderOptions options) /// Whether anything has been read into the current line. public bool HasPendingLine => !Context.Line.IsEmpty; + /// + /// The elements a text protocol has open, outermost first: what the text read next is written inside. An + /// element still here after a line ended goes on into the next line. + /// + public IReadOnlyList OpenElements => Context.Elements; + /// The text protocol now reading the text, such as mxp, or null. public string? ActiveProtocol => Context.ActiveProtocolName; @@ -157,6 +163,7 @@ public void Execute(char control) public void Csi(in CsiSequence sequence) { + Context.SequenceSeen(); if (Registry.Csi.TryGetValue(new CsiKey(sequence.Prefix, sequence.Intermediates.ToString(), sequence.Final), out var handlers)) { foreach (var handler in handlers) @@ -170,6 +177,7 @@ public void Csi(in CsiSequence sequence) public void Esc(in EscSequence sequence) { + Context.SequenceSeen(); if (Registry.Esc.TryGetValue(new CsiKey('\0', sequence.Intermediates.ToString(), sequence.Final), out var handlers)) { foreach (var handler in handlers) @@ -183,6 +191,7 @@ public void Esc(in EscSequence sequence) public void Osc(ReadOnlySpan payload) { + Context.SequenceSeen(); var separator = payload.IndexOf(';'); var number = separator < 0 ? payload : payload[..separator]; var rest = separator < 0 ? [] : payload[(separator + 1)..]; @@ -200,6 +209,7 @@ public void Osc(ReadOnlySpan payload) public void Dcs(in CsiSequence header, ReadOnlySpan data) { + Context.SequenceSeen(); if (Registry.Dcs.TryGetValue(new CsiKey(header.Prefix, header.Intermediates.ToString(), header.Final), out var handlers)) { foreach (var handler in handlers) @@ -213,6 +223,7 @@ public void Dcs(in CsiSequence header, ReadOnlySpan data) public void Apc(ReadOnlySpan payload) { + Context.SequenceSeen(); foreach (var handler in Registry.Apc) { if (handler(payload, Context)) return; diff --git a/MarkupString/Decoding/DecoderRegistry.cs b/MarkupString/Decoding/DecoderRegistry.cs index a53a7c9..896a1b6 100644 --- a/MarkupString/Decoding/DecoderRegistry.cs +++ b/MarkupString/Decoding/DecoderRegistry.cs @@ -43,6 +43,16 @@ public interface ITextProtocol void Reset(DecodeContext context); } +/// +/// A text protocol that wants to know when an escape sequence (CSI, ESC, OSC, DCS or APC) arrives between two pieces +/// of text, such as MXP, whose one-tag secure mode a sequence in between ends. +/// +public interface ISequenceObserver +{ + /// A sequence has been read and is about to be handled. + void SequenceSeen(DecodeContext context); +} + /// A protocol that adds handlers to a . public interface IDecoderPlugin { diff --git a/MarkupString/Elements/SoundMarkup.cs b/MarkupString/Elements/SoundMarkup.cs index 619cbf7..2d99315 100644 --- a/MarkupString/Elements/SoundMarkup.cs +++ b/MarkupString/Elements/SoundMarkup.cs @@ -40,6 +40,15 @@ public sealed record SoundMarkup( /// The value that loops a sound until it is stopped. public const int Forever = -1; + /// + /// For an effect: from 0 to 100, how much it matters, so a client playing one sound at a time knows whether this + /// one cuts in. The client's default when null. MXP and MSP call it P. + /// + public int? Priority { get; init; } + + /// The kind of sound the server filed it under, such as combat, if it said. MXP and MSP call it T. + public string? Type { get; init; } + /// Whether this sound loops until it is stopped. public bool Loops => Repeats == Forever; } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index de9ef4b..5c3bad9 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -765,3 +765,33 @@ MarkupString.Decoding.DecodeDrop.Limit = 3 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.Picture = 4 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.UnhandledControl = 2 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.UnhandledSequence = 1 -> MarkupString.Decoding.DecodeDrop +MarkupString.Decoding.Decoder.OpenElements.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Decoding.GaugeEvent +MarkupString.Decoding.GaugeEvent.Caption.get -> string? +MarkupString.Decoding.GaugeEvent.Caption.init -> void +MarkupString.Decoding.GaugeEvent.Color.get -> string? +MarkupString.Decoding.GaugeEvent.Color.init -> void +MarkupString.Decoding.GaugeEvent.Deconstruct(out string! Variable, out string? Maximum, out string? Caption, out string? Color, out bool Status) -> void +MarkupString.Decoding.GaugeEvent.Equals(MarkupString.Decoding.GaugeEvent? other) -> bool +MarkupString.Decoding.GaugeEvent.GaugeEvent(string! Variable, string? Maximum, string? Caption, string? Color, bool Status) -> void +MarkupString.Decoding.GaugeEvent.Maximum.get -> string? +MarkupString.Decoding.GaugeEvent.Maximum.init -> void +MarkupString.Decoding.GaugeEvent.Status.get -> bool +MarkupString.Decoding.GaugeEvent.Status.init -> void +MarkupString.Decoding.GaugeEvent.Variable.get -> string! +MarkupString.Decoding.GaugeEvent.Variable.init -> void +MarkupString.SoundMarkup.Priority.get -> int? +MarkupString.SoundMarkup.Priority.init -> void +MarkupString.SoundMarkup.Type.get -> string? +MarkupString.SoundMarkup.Type.init -> void +override MarkupString.Decoding.GaugeEvent.$() -> MarkupString.Decoding.GaugeEvent! +override MarkupString.Decoding.GaugeEvent.Equals(object? obj) -> bool +override MarkupString.Decoding.GaugeEvent.GetHashCode() -> int +override MarkupString.Decoding.GaugeEvent.ToString() -> string! +override sealed MarkupString.Decoding.GaugeEvent.Equals(MarkupString.Decoding.DecodeEvent? other) -> bool +static MarkupString.Decoding.GaugeEvent.operator !=(MarkupString.Decoding.GaugeEvent? left, MarkupString.Decoding.GaugeEvent? right) -> bool +static MarkupString.Decoding.GaugeEvent.operator ==(MarkupString.Decoding.GaugeEvent? left, MarkupString.Decoding.GaugeEvent? right) -> bool +MarkupString.Decoding.ISequenceObserver +MarkupString.Decoding.ISequenceObserver.SequenceSeen(MarkupString.Decoding.DecodeContext! context) -> void +MarkupString.Decoding.DecodeContext.PendingLine.get -> MarkupString.MarkupText! +MarkupString.Decoding.DecodeContext.Insert(int column, MarkupString.MarkupText! text) -> void diff --git a/docs/decoding.md b/docs/decoding.md index d7b08a1..8918c26 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -93,6 +93,11 @@ Output two A `DisplayLine` carries `Content`, `Kind`, `Sequence` (its number within the decoder, from 0, for keying a view) and `Destination` (the pane it was sent to, or null for the main output). +`PendingLine` is the text read so far on a line that has not ended, as `MarkupText`, and +`OpenElements` lists the elements a text protocol has open, innermost last. A host that keeps state +for as long as an element stays open (an MXP room-name flag that spans several lines, say) checks +`OpenElements` after each line. + `Reset()` forgets the style, open elements, half-read sequences and the pending line. Set `decoder.Identity` again when the pane is resized; answers read it each time. @@ -108,6 +113,7 @@ Anything that is not text to show is raised through `EventRaised`, as it is foun | `ClipboardEvent(Selection, Text)` | OSC 52. `Text` is null when the server asked to read the clipboard. | | `ProtocolEvent(Name, Active)` | A text protocol was turned on or off. | | `VariableEvent(Name, Value)` | A server variable (MXP). | +| `GaugeEvent(Variable, Maximum, Caption, Color, Status)` | An MXP `GAUGE` (`Status` false) or `STAT` (`Status` true) over an entity. | | `DownloadEvent(Name, Size)` | A file offered to save (iTerm2 `inline=0`). The decoder never saves it. | The decoder acts on none of these. Refuse a clipboard write unless the player allowed it. @@ -406,7 +412,9 @@ with one more layer inside, `Point(point)`, move the cursor (`CarriageReturn`, ` ### Text protocols A text protocol reads every piece of printable text while it is on, and prints what is literal with -`context.Print`. Only one is on at a time. This one makes `*text*` bold: +`context.Print`. Only one is on at a time. A protocol that also implements `ISequenceObserver` is told +each time an escape sequence arrives while it is on; MXP uses this to spend a pending `ESC [ 4 z`. +`context.Insert(column, text)` puts text into the line being read at a display column. This one makes `*text*` bold: ```csharp sealed class StarsPlugin : IDecoderPlugin @@ -555,7 +563,7 @@ host turns it on after telnet negotiates option 91: ```csharp using MarkupString.Mxp.Decoding; -var registry = DecoderRegistry.Empty.WithVt().WithMxp(); +var registry = DecoderRegistry.Empty.WithVt().WithMxp(new MxpOptions { ShowRefusedTags = true }); var decoder = registry.CreateDecoder(); decoder.EnableProtocol(MxpDecoding.Protocol); @@ -568,14 +576,25 @@ Line modes (`ESC [ n z`) decide what a line may use. Until MXP is on they are cl |---|---| | `0`, `1`, `2` | This line is open, secure or locked | | `3` | Close every MXP element and go back to open | -| `4` | The next tag is secure | +| `4` | The next tag is secure. Any text, entity or escape sequence before it spends this; a second `ESC [ 4 z` arms it again. | | `5`, `6`, `7` | Open, secure or locked from now on | | `10`, `11`, `12`, `19` | This line is a room name, description, exits or welcome text: an `MxpFlagMarkup` over it | | `20`–`99` | This line is the element the server tied to that number with `TAG=` | +Any other number in `10`–`99`, and `ESC [ z` with no number, changes nothing. Leaving open mode in the +middle of a line (modes `1`, `2`, `6`, `7` or a line tag) closes the elements the open part opened. + On an open line only the open tags work: `B`, `I`, `U`, `S`, `H`, `C`/`COLOR`, `FONT`, `BR`, `SBR`, `P`, `NOBR`. A secure tag there is dropped and counted in `UnhandledSequences`. On a locked line `<` -and `&` are text. Elements opened on an open line close at its end. +and `&` are text. Elements opened on an open line close at its end, and `A` and `SEND` always end +with their line. + +`MxpOptions` changes two things: + +| Property | Default | | +|---|---|---| +| `ShowRefusedTags` | false | Show a refused tag (a secure tag or a definition on an open line, the close of a secure tag nothing opened) as the text it was, instead of dropping it | +| `Supports` | null | The tags and attributes `` is answered with, such as `{ ["send"] = ["href", "hint"] }`. Null answers with every tag the package reads. `Answer(asked)` gives the same answer to a host that answers MXP support itself. | What the tags become: @@ -583,19 +602,36 @@ What the tags become: |---|---| | `B`, `I`, `U`, `S`, `H`, `COLOR`, `FONT` | An `AnsiMarkup`. SGR inside an MXP colour wins, as in an MXP client. | | `A href hint` | A URL link, when `DecoderOptions.Links` allows the address | -| `SEND href hint prompt` | A command link. With several commands (`a\|b`) or `PROMPT`, a `CommandMenuMarkup` as well. With no `href`, or `&text;` in it, the command is the tag's text. | -| `IMAGE fname url w h align` | An `ImageMarkup`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. | -| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points | -| `FRAME`, `DEST` | A `PaneMarkup` over the text in `DEST`, titled from its `FRAME` | +| `SEND href hint prompt` | A command link. With several commands (`a\|b`) or `PROMPT`, a `CommandMenuMarkup` as well. With no `href`, or `&text;` in it, the command is the tag's text. One command takes the whole hint. With several, one more hint than commands makes the first the link's hint and the rest the menu labels; as many hints as commands are the labels alone. | +| `IMAGE fname url t w h align` | An `ImageMarkup`, from `url` + `t` + `fname`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves blank for it; when those cells hold text, it goes inline at the tag instead. Sized in cells one way only, the other size is converted to pixels with the identity's cell size. | +| `SOUND`, `MUSIC` | `SoundMarkup` (with `Priority` from `P` and `Type` from `T`) or `SoundStopMarkup` points | +| `FRAME`, `DEST` | A `PaneMarkup` over the text in `DEST`, titled from its `FRAME`. `FRAME ACTION=REDIRECT` (or the `REDIRECT` flag) sends the lines after it to that frame through `DisplayLine.Destination`, until a redirect to `_top` or `_previous` or `FRAME … CLOSE`. | +| `GAUGE`, `STAT` | A `GaugeEvent` | | `VAR name` | A `VariableMarkup` over its text, and a `VariableEvent` | | `EXPIRE`, `RELOCATE`, `USER`, `PASSWORD` | Their points | -| `VERSION`, `SUPPORT` | Answered (`\e[1z`, `\e[1z`) when `ClientAnswers.Mxp` allows | +| `VERSION`, `SUPPORT` | Answered (`\e[1z`, `\e[1z`) when `ClientAnswers.Mxp` allows. `` from the server sets the `STYLE` the answer reports. | `` defines an element from other tags, with `ATT` attributes substituted for `&name;`, `FLAG` for an `MxpFlagMarkup`, `OPEN`, `EMPTY` and `TAG`. `` defines an entity, used as `&name;` and published as a `VariableEvent` unless `PRIVATE`. `` and `` are read too. To serialise lines with `MxpFlagMarkup`, add `MxpDecoding.FlagCodec` to the `MarkupRegistry`. +An element defined on an open line plays back as open text, so a server cannot launder a secure tag +through one; it plays back secure only when used on a secure line or by a line tag. Definitions are +bounded by `MxpPlugin` constants: + +| Constant | Value | | +|---|---|---| +| `MaxElements` | 256 | Elements defined at once | +| `MaxEntities` | 512 | Entities defined at once | +| `MaxDefinitionLength` | 1024 | Characters in an element's definition or an entity's value | +| `MaxExpansionDepth` | 4 | Elements expanding inside each other (an element that uses itself stops here) | +| `MaxExpansionsPerLine` | 64 | Custom elements expanded on one line | + +A definition past a limit is refused and counted in `LimitsReached`. The built-in tags and the entities +the decoder already knows (`lt`, `nbsp` and the rest) cannot be redefined. `` takes `value` +out of a `|` list, and `DELETE` deletes the entity. + What `MarkupFormat.Mxp` writes reads back to the same markup. ## Pueblo @@ -610,11 +646,14 @@ switches back, and its region is a `PreformattedMarkup`. ANSI is read in both. | Tag | Markup | |---|---| | `b`, `i`, `u`, `s`, `font color` | An `AnsiMarkup` | -| `a xch_cmd xch_hint`, `a href` | A command or URL link | +| `a xch_cmd xch_hint`, `a href` | A command or URL link. `xch_mode=prompt` puts the command on the input line, as a `CommandMenuMarkup` with `Prompt`. | +| `send href` | A command link | | `img src alt` | An `ImageMarkup` over the alt text | | `img xch_sound=play\|loop\|stop href xch_volume` | `SoundMarkup` or `SoundStopMarkup` | | `xch_pane action=redirect name panetitle` | A `PaneMarkup` until the next redirect | | `xch_page clear=text`, `xch_prefetch href` | `ClearScreenMarkup`, `PrefetchMarkup` | | `br`, `p`, `hr`, `pre` | Line breaks, and a `PreformattedMarkup` for `pre` | +`strong` and `em` close `b` and `i`, and `strike` and `del` close `s`. Links end with their line. + What `MarkupFormat.Pueblo` writes reads back to the same markup. From f7ef54614d3c320be5c3ad9c1ce851be388c98d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:28:28 +0000 Subject: [PATCH 3/3] Address review findings on MXP and Pueblo decoding - Bound writes by the entity limits. - Ignore cell sizes over 1024 so a huge IMAGE goes inline instead of throwing. - Shift later cell-sized images by what an earlier one put inline. - Leave the line alone when an inline insertion would overfill it. - Clear an MXP FRAME redirect when MXP is turned off. - Restore the line mode in a finally during element and capture playback. - Write SOUND P and T as MXP, without doubling the T folder. - Pueblo: close a prompt link's menu with it, keep the
 count right when a
  link closes it, and make a multi-command  a menu.

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01Rqqt7WA8C5XG14TAWB6b9V
---
 CHANGELOG.md                                  | 10 +++
 MarkupString.Mxp/Decoding/MxpDecoding.cs      | 90 +++++++++++++++----
 .../Emitters/ElementMxpEmitter.cs             | 11 +++
 .../Decoding/PuebloDecoding.cs                | 69 ++++++++++++--
 .../Decoding/MxpDecodingTests.cs              | 58 ++++++++++++
 .../Decoding/PuebloDecodingTests.cs           | 26 ++++++
 MarkupString/Decoding/DecodeContext.cs        |  9 +-
 docs/decoding.md                              |  4 +-
 8 files changed, 249 insertions(+), 28 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a63caa..ed41d84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   specification says: `` is `https://h/img/maps/map.png`.
 - An SGR extended colour with too few components (`38;2;40`) no longer reads what is left as codes of their own,
   so it does not set a black background.
+- A `` could grow the entity table past `MaxEntities` or store a value past `MaxDefinitionLength`.
+- An `IMAGE` sized in cells with a huge `W` or `H` could throw at the end of the line. A size over 1024 cells is
+  ignored, and the picture goes inline.
+- A second cell-sized `IMAGE` on a line looked for its cells in the wrong place after the first went inline.
+- A picture put inline on a line that had no room for it emptied the line. The line is now left as it was.
+- A FRAME redirect outlived turning MXP off.
+- A handler that threw during an element's playback could leave the line secure.
+- Writing a sound as MXP dropped its `P` and `T`.
+- Pueblo: `` left a `xch_mode=prompt` menu open; a `
` closed with the link around it stayed in force;
+  `` was one link to `buy|look` rather than a menu.
 
 ## 2.26.0 — 2026-11-06
 
diff --git a/MarkupString.Mxp/Decoding/MxpDecoding.cs b/MarkupString.Mxp/Decoding/MxpDecoding.cs
index 4eec2cf..2e953ed 100644
--- a/MarkupString.Mxp/Decoding/MxpDecoding.cs
+++ b/MarkupString.Mxp/Decoding/MxpDecoding.cs
@@ -292,12 +292,18 @@ internal sealed class MxpState
 	public int ExpansionsThisLine { get; set; }
 
 	public string? StyleVersion { get; set; }
+
+	/// The frame a FRAME redirect sent lines to, so turning MXP off sends them back to the main window.
+	public string? Redirect { get; set; }
 }
 
 internal sealed class MxpReader(MxpOptions options) : ITextProtocol, ITagSink, ISequenceObserver
 {
 	private const int MaxCapture = 4096;
 
+	/// The most cells a picture sized in cells may cover either way; a larger size is ignored.
+	private const int MaxImageCells = 1024;
+
 	private static readonly HashSet OpenTags = new(StringComparer.OrdinalIgnoreCase)
 	{
 		"B", "BOLD", "STRONG", "I", "ITALIC", "EM", "U", "UNDERLINE", "S", "STRIKEOUT", "STRIKE", "H", "HIGH", "C", "COLOR",
@@ -363,6 +369,12 @@ public void Reset(DecodeContext context)
 		if (state.Open.Count > 0) CloseFrom(0);
 		state.Capture = null;
 		state.Overlays.Clear();
+		if (state.Redirect is { } redirect && string.Equals(context.Destination, redirect, StringComparison.OrdinalIgnoreCase))
+		{
+			context.Destination = null;
+		}
+
+		state.Redirect = null;
 		state.Line = state.Default = MxpMode.Open;
 		state.TemporarySecure = false;
 	}
@@ -639,6 +651,7 @@ private bool Element(ParsedTag tag, bool openMode)
 						case "REDIRECT":
 							// Every line from here goes to the frame, until a redirect back to the main window.
 							context.Destination = main ? null : name;
+							State.Redirect = context.Destination;
 							break;
 						case "CLOSE":
 							State.FrameTitles.Remove(name);
@@ -805,12 +818,12 @@ private void OpenCustom(MxpElementDefinition definition, ParsedTag tag, bool lin
 		}
 		finally
 		{
+			// Restored even when a handler throws, or open-line text after it would be read as secure.
+			state.Line = saved;
 			state.ExpansionDepth--;
 			state.Replaying = replaying;
 		}
 
-		state.Line = saved;
-
 		// What the definition opened is one element now, closed together.
 		if (state.Open.Count > opened) state.Open.RemoveRange(opened, state.Open.Count - opened);
 		var first = Context.Elements.Count > before ? Context.Elements[before] : null;
@@ -914,13 +927,7 @@ private void Definition(ReadOnlySpan body)
 						value = existing + "|" + value;
 					}
 
-					if (TagSyntax.Entity(name, static _ => null) is not null || value.Length > MxpPlugin.MaxDefinitionLength
-						|| (!state.Entities.ContainsKey(name) && state.Entities.Count >= MxpPlugin.MaxEntities))
-					{
-						// < and the other built-in entities keep their meaning, and the table stays bounded.
-						Context.Diagnostics.LimitsReached++;
-						return;
-					}
+					if (!CanStoreEntity(name, value)) return;
 
 					state.Entities[name] = value;
 					if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(name, value));
@@ -954,6 +961,23 @@ private void Captured(ReadOnlySpan raw, ReadOnlySpan text)
 		if (state.CaptureRaw.Length > MaxCapture) FinishCapture();
 	}
 
+	/// 
+	/// Whether  may be set to : &lt; and the other built-in entities
+	/// keep their meaning, and the table stays bounded in size and in each value's length.
+	/// 
+	private bool CanStoreEntity(string name, string value)
+	{
+		var state = State;
+		if (TagSyntax.Entity(name, static _ => null) is null && value.Length <= MxpPlugin.MaxDefinitionLength
+			&& (state.Entities.ContainsKey(name) || state.Entities.Count < MxpPlugin.MaxEntities))
+		{
+			return true;
+		}
+
+		Context.Diagnostics.LimitsReached++;
+		return false;
+	}
+
 	private void FinishCapture()
 	{
 		var state = State;
@@ -979,8 +1003,12 @@ private void FinishCapture()
 			var variable = tag.Get("NAME", 0);
 			if (!string.IsNullOrEmpty(variable))
 			{
-				state.Entities[variable] = text;
-				if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(variable, text));
+				if (CanStoreEntity(variable, text))
+				{
+					state.Entities[variable] = text;
+					if (!tag.Has("PRIVATE")) Context.Raise(new VariableEvent(variable, text));
+				}
+
 				OpenElement(tag.Name, [new VariableMarkup(variable)], openMode: false);
 			}
 			else
@@ -991,8 +1019,15 @@ private void FinishCapture()
 
 		var saved = state.Line;
 		state.Line = MxpMode.Secure;
-		new TagScanner().Feed(raw, this);
-		state.Line = saved;
+		try
+		{
+			new TagScanner().Feed(raw, this);
+		}
+		finally
+		{
+			state.Line = saved;
+		}
+
 		Close(tag.Name);
 	}
 
@@ -1032,7 +1067,11 @@ private void Image(ParsedTag tag)
 	private static (int? Pixels, int Cells) Size(string? value)
 	{
 		if (string.IsNullOrEmpty(value)) return (null, 0);
-		if (value.EndsWith('c') && int.TryParse(value.AsSpan(0, value.Length - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var cells)) return (null, cells);
+		if (value.EndsWith('c') && int.TryParse(value.AsSpan(0, value.Length - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var cells))
+		{
+			return (null, cells <= MaxImageCells ? cells : 0);
+		}
+
 		var digits = value.EndsWith("px", StringComparison.OrdinalIgnoreCase) ? value[..^2] : value;
 		return int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var pixels) && pixels > 0 ? (pixels, 0) : (null, 0);
 	}
@@ -1043,22 +1082,35 @@ private void DrawOverlays()
 		if (overlays.Count == 0) return;
 		var context = Context;
 		var cursor = context.Column;
+		List<(int Column, int Width)>? inserted = null;
 		for (var i = 0; i < overlays.Count; i++)
 		{
 			var (image, rows, columns, column, next, inline) = overlays[i];
-			if (next == 0 && !Blank(context.PendingLine, column, columns))
+
+			// A picture put inline earlier on this line moved the cells after it to the right.
+			var at = column;
+			if (inserted is not null)
+			{
+				foreach (var (insertedAt, width) in inserted)
+				{
+					if (insertedAt <= column) at += width;
+				}
+			}
+
+			if (next == 0 && !Blank(context.PendingLine, at, columns))
 			{
 				// The server left no room for it here: the picture goes where its tag was instead of over its text.
-				context.Insert(column, inline);
-				if (cursor >= column) cursor += inline.DisplayWidth;
+				context.Insert(at, inline);
+				if (cursor >= at) cursor += inline.DisplayWidth;
+				(inserted ??= []).Add((column, inline.DisplayWidth));
 				overlays[i] = (image, rows, columns, column, rows, inline);
 				continue;
 			}
 
 			// A later row drawn over text would hide it; that row of the picture is left out instead.
-			if (next == 0 || Blank(context.PendingLine, column, columns))
+			if (next == 0 || Blank(context.PendingLine, at, columns))
 			{
-				context.MoveToColumn(column);
+				context.MoveToColumn(at);
 				context.Write(new string(' ', columns), image with { Row = new PictureRow(next, rows, columns) });
 			}
 
diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
index 1c9c50d..b9d0a82 100644
--- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
+++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
@@ -38,10 +38,21 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context
 					if (!Supports(name)) return;
 
 					var (file, directory) = Split(sound.Source);
+					var type = sound.Type?.Trim().Trim('/');
+					if (string.IsNullOrEmpty(type)) type = null;
+
+					// A reader puts T's folder between U and the file, so a source already under it loses it from U.
+					if (type is not null && directory is not null && directory.EndsWith("/" + type + "/", StringComparison.OrdinalIgnoreCase))
+					{
+						directory = directory[..^(type.Length + 1)];
+					}
+
 					tag.Open(name).Positional(file)
 						.Named("V", sound.Volume)
 						.Named("L", sound.Repeats)
+						.Named("P", sound.Priority)
 						.Named("C", sound.Channel == SoundChannel.Music && sound.Continues ? "1" : null)
+						.Named("T", type)
 						.Named("U", directory)
 						.Close();
 					return;
diff --git a/MarkupString.Pueblo/Decoding/PuebloDecoding.cs b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs
index 063889c..50ded05 100644
--- a/MarkupString.Pueblo/Decoding/PuebloDecoding.cs
+++ b/MarkupString.Pueblo/Decoding/PuebloDecoding.cs
@@ -167,7 +167,6 @@ public void Tag(ReadOnlySpan body)
 
 		if (tag.Closing)
 		{
-			if (name == "pre" && state.Preformatted > 0) state.Preformatted--;
 			Close(name!);
 			return;
 		}
@@ -205,8 +204,8 @@ private bool Element(ParsedTag tag, string name)
 						if (string.Equals(tag.Get("xch_mode"), "prompt", StringComparison.OrdinalIgnoreCase))
 						{
 							// xch_mode=prompt puts the command on the player's input line instead of sending it.
-							OpenElement(name, new CommandMenuMarkup([new CommandChoice(command, command)], hint, Prompt: true));
-							OpenElement(name, new AnsiMarkup(link.Value));
+							// One entry for both, so  closes the menu and the link together.
+							OpenElement(name, new CommandMenuMarkup([new CommandChoice(command, command)], hint, Prompt: true), new AnsiMarkup(link.Value));
 							return true;
 						}
 					}
@@ -222,8 +221,13 @@ private bool Element(ParsedTag tag, string name)
 				{
 					// Not Pueblo's own, but servers that speak MXP too send it: a command link.
 					var command = tag.Get("href", 0);
-					OpenElement(name, string.IsNullOrEmpty(command) ? null
-						: new AnsiMarkup(new AnsiStyle { LinkUrl = command, LinkKind = LinkKind.Command, LinkText = tag.Get("hint") }));
+					if (string.IsNullOrEmpty(command))
+					{
+						OpenElement(name, null);
+						return true;
+					}
+
+					OpenSend(name, command, tag.Get("hint"));
 					return true;
 				}
 			case "br":
@@ -309,6 +313,52 @@ private void OpenElement(string name, IMarkup? markup)
 		State.Open.Add((name, markup));
 	}
 
+	/// Opens several markups as one element: closing it closes them all.
+	private void OpenElement(string name, IMarkup first, IMarkup second)
+	{
+		IMarkup? opened = null;
+		if (Context.Open(first)) opened = first;
+		if (Context.Open(second)) opened ??= second;
+		State.Open.Add((name, opened));
+	}
+
+	/// 
+	/// A SEND link. Several commands (buy|look) are a menu, labelled by the hints as MXP labels them: one hint
+	/// more than the commands is the link's own hint and then the labels, as many hints as commands are the labels.
+	/// 
+	private void OpenSend(string name, string href, string? hintText)
+	{
+		var commands = href.Split('|');
+		var link = new AnsiMarkup(new AnsiStyle { LinkUrl = commands[0], LinkKind = LinkKind.Command, LinkText = commands.Length == 1 ? hintText : null });
+		if (commands.Length == 1)
+		{
+			OpenElement(name, link);
+			return;
+		}
+
+		var hints = hintText?.Split('|') ?? [];
+		string? hint = null;
+		var labels = commands;
+		if (hints.Length == commands.Length + 1)
+		{
+			hint = hints[0];
+			labels = hints[1..];
+		}
+		else if (hints.Length == commands.Length)
+		{
+			labels = hints;
+		}
+		else if (hints.Length > 0)
+		{
+			hint = hints[0];
+		}
+
+		link = new AnsiMarkup(new AnsiStyle { LinkUrl = commands[0], LinkKind = LinkKind.Command, LinkText = hint });
+		var choices = new CommandChoice[commands.Length];
+		for (var i = 0; i < commands.Length; i++) choices[i] = new CommandChoice(labels[i], commands[i]);
+		OpenElement(name, new CommandMenuMarkup(choices, hint), link);
+	}
+
 	private void Close(string name)
 	{
 		// HTML's other spellings of the same element close it:  closes .
@@ -329,7 +379,8 @@ private void Close(string name)
 
 	private void CloseFrom(int index)
 	{
-		var open = State.Open;
+		var state = State;
+		var open = state.Open;
 		for (var i = index; i < open.Count; i++)
 		{
 			if (open[i].First is { } first)
@@ -339,6 +390,12 @@ private void CloseFrom(int index)
 			}
 		}
 
+		// A 
 closed along with the element around it is no longer in force.
+		for (var i = index; i < open.Count; i++)
+		{
+			if (open[i].Name == "pre" && state.Preformatted > 0) state.Preformatted--;
+		}
+
 		open.RemoveRange(index, open.Count - index);
 	}
 
diff --git a/MarkupString.Tests/Decoding/MxpDecodingTests.cs b/MarkupString.Tests/Decoding/MxpDecodingTests.cs
index db92d71..7e3d01e 100644
--- a/MarkupString.Tests/Decoding/MxpDecodingTests.cs
+++ b/MarkupString.Tests/Decoding/MxpDecodingTests.cs
@@ -305,6 +305,64 @@ public async Task Sound_CarriesItsPriorityAndType()
 		await Assert.That(sound.Type).IsEqualTo("combat");
 	}
 
+	[Test]
+	public async Task Sound_PriorityAndType_ReadBackFromWhatThisPackageWrites()
+	{
+		var registry = MarkupRegistry.Empty.WithAnsi().WithMxp().WithMxpSecureLines();
+		var sound = new SoundMarkup("https://example.test/sounds/combat/hit.wav", Volume: 50) { Priority = 70, Type = "combat" };
+		var wire = MarkupText.Point(sound).Render(MarkupFormat.Mxp, registry);
+
+		var (lines, _, _) = Decode(wire + "\n");
+		await Assert.That(Markups(lines.Single()).OfType().Single()).IsEqualTo(sound);
+	}
+
+	[Test]
+	public async Task FrameRedirect_EndsWhenMxpIsTurnedOff()
+	{
+		var decoder = Registry.CreateDecoder();
+		decoder.EnableProtocol(MxpDecoding.Protocol);
+		var lines = new List(decoder.Feed("\e[1z\none\n"));
+		decoder.DisableProtocol(MxpDecoding.Protocol);
+		lines.AddRange(decoder.Feed("two\n"));
+		await Assert.That(lines.Single(line => line.Content.ToPlainText() == "one").Destination).IsEqualTo("chat");
+		await Assert.That(lines.Single(line => line.Content.ToPlainText() == "two").Destination).IsNull();
+	}
+
+	[Test]
+	public async Task Var_KeepsTheEntityTableBounded()
+	{
+		var tooLong = new string('x', MxpPlugin.MaxDefinitionLength + 1);
+		var (_, events, decoder) = Decode($"\e[1zok\e[1z{tooLong}\n");
+		await Assert.That(events.OfType().Select(e => e.Name)).IsEquivalentTo(["short"]);
+		await Assert.That(decoder.Diagnostics.LimitsReached).IsGreaterThan(0);
+	}
+
+	[Test]
+	public async Task Image_InCells_TooLargeGoesInlineInsteadOfThrowing()
+	{
+		var (lines, _, _) = Decode("x\e[1zy\n");
+		var image = Markups(lines[0]).OfType().Single();
+		await Assert.That(image.Row).IsNull();
+		await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("xcat.pngy");
+	}
+
+	[Test]
+	public async Task Image_InCells_AfterOneThatWentInline_StillUsesItsCells()
+	{
+		var (lines, _, _) = Decode("\e[1zxx\e[1z  \n");
+		var images = Markups(lines[0]).OfType().ToList();
+		await Assert.That(images.Single(image => image.Source.EndsWith("a.png", StringComparison.Ordinal)).Row).IsNull();
+		await Assert.That(images.Single(image => image.Source.EndsWith("b.png", StringComparison.Ordinal)).Row).IsNotNull();
+	}
+
+	[Test]
+	public async Task Image_InCells_InlineThatWouldOverfillTheLine_LeavesTheLineAlone()
+	{
+		var (lines, _, decoder) = Decode("\e[1zxxxx\n", new DecoderOptions { MaxLineLength = 4 });
+		await Assert.That(lines[0].Content.ToPlainText()).IsEqualTo("xxxx");
+		await Assert.That(decoder.Diagnostics.LimitsReached).IsGreaterThan(0);
+	}
+
 	[Test]
 	public async Task Image_PutsItsTypeBetweenTheUrlAndTheFile()
 	{
diff --git a/MarkupString.Tests/Decoding/PuebloDecodingTests.cs b/MarkupString.Tests/Decoding/PuebloDecodingTests.cs
index 54c2ca5..ee171ff 100644
--- a/MarkupString.Tests/Decoding/PuebloDecodingTests.cs
+++ b/MarkupString.Tests/Decoding/PuebloDecodingTests.cs
@@ -87,4 +87,30 @@ public async Task Greeting()
 		await Assert.That(PuebloDecoding.IsGreeting("This world is Pueblo 1.10 Enhanced.")).IsTrue();
 		await Assert.That(PuebloDecoding.IsGreeting("Welcome!")).IsFalse();
 	}
+
+	[Test]
+	public async Task PromptLink_EndsAtItsClosingTag()
+	{
+		var lines = Decode("look plain
"); + var content = lines[0].Content; + var plain = content.Text.IndexOf("plain", StringComparison.Ordinal); + IEnumerable At(int index) => content.Runs.Where(run => run.Start <= index && index < run.Start + run.Length).SelectMany(run => run.Markups); + await Assert.That(At(plain).OfType()).IsEmpty(); + await Assert.That(At(0).OfType()).IsNotEmpty(); + } + + [Test] + public async Task Pre_ClosedWithTheLinkAroundIt_EndsWithIt() + { + var lines = Decode("
xa\nb
"); + await Assert.That(lines.Select(line => line.Content.ToPlainText())).IsEquivalentTo(["xab"]); + } + + [Test] + public async Task Send_WithSeveralCommands_IsAMenu() + { + var lines = Decode("sword
"); + var menu = Markups(lines[0]).OfType().Single(); + await Assert.That(menu.Choices).IsEquivalentTo([new CommandChoice("Buy", "buy"), new CommandChoice("Look", "look")]); + } } diff --git a/MarkupString/Decoding/DecodeContext.cs b/MarkupString/Decoding/DecodeContext.cs index 95c34ff..0dd1ffb 100644 --- a/MarkupString/Decoding/DecodeContext.cs +++ b/MarkupString/Decoding/DecodeContext.cs @@ -231,10 +231,17 @@ public void Insert(int column, MarkupText text) ArgumentNullException.ThrowIfNull(text); if (text.Length == 0) return; var line = Line.Peek(); + if (line.Length + text.Length > Line.MaxLength) + { + // Too long to fit: the line stays as it was, and so does the cursor. + Diagnostics.LimitsReached++; + return; + } + var index = DisplayWidth.IndexAtWidth(line.Text, Math.Clamp(column, 0, line.DisplayWidth)); var cursor = Line.Cursor; Line.Take(); - if (!Line.Put(line.Insert(index, text))) Diagnostics.LimitsReached++; + Line.Put(line.Insert(index, text)); Line.Cursor = cursor >= column ? cursor + text.DisplayWidth : cursor; } diff --git a/docs/decoding.md b/docs/decoding.md index 23860c8..07b98df 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -625,11 +625,11 @@ bounded by `MxpPlugin` constants: |---|---|---| | `MaxElements` | 256 | Elements defined at once | | `MaxEntities` | 512 | Entities defined at once | -| `MaxDefinitionLength` | 1024 | Characters in an element's definition or an entity's value | +| `MaxDefinitionLength` | 1024 | Characters in an element's definition or an entity's value, `` included | | `MaxExpansionDepth` | 4 | Elements expanding inside each other (an element that uses itself stops here) | | `MaxExpansionsPerLine` | 64 | Custom elements expanded on one line | -A definition past a limit is refused and counted in `LimitsReached`. The built-in tags and the entities +A cell size (`W=Nc`, `H=Nc`) over 1024 is ignored, so the picture goes inline. A definition past a limit is refused and counted in `LimitsReached`. The built-in tags and the entities the decoder already knows (`lt`, `nbsp` and the rest) cannot be redefined. `` takes `value` out of a `|` list, and `DELETE` deletes the entity.