Skip to content

Core: line pipeline (one split, one ANSI strip, Line type) - #3

Merged
MHohlios merged 7 commits into
wiffletree/phase-0-rust-workspace-and-gpui-spike-virtualizefrom
wiffletree/core-line-pipeline-one-split-one-ansi-strip-line
Oct 30, 2026
Merged

MHohlios merged 7 commits into
wiffletree/phase-0-rust-workspace-and-gpui-spike-virtualizefrom
wiffletree/core-line-pipeline-one-split-one-ansi-strip-line

Conversation

@MHohlios

Copy link
Copy Markdown
Contributor

Each server line is now split once and stripped of ANSI codes once, and every reader gets the same Line (PRD §12, §5 #1).

Linear: VYT-191 (Phase 1: Core, VYT-186). Stacked on #1 (Phase 0).

What's in it

  • line::Line is a single Arc. It holds raw, plain (one copy when the two are equal), styled SGR spans and links, which stays empty until MXP. It also records how the line ended: Newline, PromptEnd or Cut. It is Send + Sync and cheap to clone.
  • LinePipeline:
    • push(&[u8]), then prompt_end() for GA/EOR.
    • pending() returns the partial line, a possible prompt. Its number stays the same while it grows, so half a prompt split across network reads is never taken for a whole one. That is the prototype's 2026-10-28 bug.
  • Encoding: UTF-8 with lossy decoding, or Latin-1, set per connection. A code point split across chunks is held until the rest arrives.
  • Carriage returns: every \r is dropped and only \n ends a line, as in the prototype.
  • Line cap: a line is cut at 64 KiB, without splitting a code point. An escape sequence that the cut lands in carries over to the next line.
  • ansi.rs becomes a streaming parser. Escapes split across chunks are handled, and ESC ( B-style intermediates are consumed whole. parse_line is unchanged.

Numbers

On the real logs, a release build: p50 0.17 µs and p99 0.29–0.42 µs per line, against a 5 µs target for this stage.

Tests

  • A synthetic stream is split at every byte offset and fed one byte at a time. It holds SGR codes, an OSC title, a character-set select, 2-, 3- and 4-byte UTF-8 and an invalid byte.
  • The rest cover every CRLF variant, prompts without a newline, Latin-1 and switching encoding, and the cut with chunk-straddling code points and escape sequences.

Follow-ups

  • Scrollback should hold line::Line, and ansi::Line should be retired.
  • pending() and prompt_end() disagree while a UTF-8 lead byte is held.

mhohlios added 7 commits October 29, 2026 12:54
Server bytes become shared Line values in one streaming pass: memchr
finds line ends (\n, \r\n, \n\r, lone \r), the bytes are decoded once
(UTF-8 lossy or Latin-1), and only the new text goes through the ANSI
parser. The pending partial line, a possible prompt, is a view of the
same buffers and keeps its line number while it grows. GA/EOR finish it
through prompt_end.

The ANSI parser now streams, so escapes split across chunks parse;
parse_line keeps its behavior. mudling-bench times the pipeline per line.
Match the prototype, which removed every \r and ended lines only at
\n: a lone \r no longer ends a line, and any run of \r before \n ends
exactly one. A line with no end is cut at MAX_LINE_BYTES (64 KiB, never
inside a UTF-8 code point) and marked LineEnd::Cut, so a broken or
hostile server can't grow memory without limit.
…witches

A 64 KiB cut that falls inside a code point whose start is held from the
last chunk now moves that code point to the next line, instead of
replacing both halves. Switching away from UTF-8 replaces a held code
point where it stands, not at the end of the line. The bench's line
pipeline report keeps its internals private.
ESC ( B and the like (ECMA-48 intermediates 0x20-0x2F up to a final
0x30-0x7E) are now removed whole, even split across chunks, instead of
leaving their final byte in the text. A 64 KiB line cut that falls
inside an escape sequence carries it to the next line; CSI parameters
past 256 bytes are skipped rather than buffered, so that carry can't
grow without limit.
…lize' into wiffletree/core-line-pipeline-one-split-one-ansi-strip-line

# Conflicts:
#	crates/mudling-bench/src/main.rs
@MHohlios
MHohlios added this pull request to stack #12 October 30, 2026 11:37
@MHohlios
MHohlios merged commit 96510f2 into wiffletree/phase-0-rust-workspace-and-gpui-spike-virtualize Oct 30, 2026
4 checks passed
MHohlios added a commit that referenced this pull request Oct 30, 2026
…gins, Luau, Avatar, map (#15)

* Add Cargo workspace, core scrollback and headless benchmark

mudling-core holds the ANSI parser, the ring-buffer scrollback, the
MUDPad log replay and percentile stats. mudling-bench measures append and
find against the PRD budgets. ffi, cli and desktop are stubs for now.

* Add the line pipeline: one split, one ANSI strip per line

Server bytes become shared Line values in one streaming pass: memchr
finds line ends (\n, \r\n, \n\r, lone \r), the bytes are decoded once
(UTF-8 lossy or Latin-1), and only the new text goes through the ANSI
parser. The pending partial line, a possible prompt, is a view of the
same buffers and keeps its line number while it grows. GA/EOR finish it
through prompt_end.

The ANSI parser now streams, so escapes split across chunks parse;
parse_line keeps its behavior. mudling-bench times the pipeline per line.

* Line pipeline: drop every \r, and cut lines at 64 KiB

Match the prototype, which removed every \r and ended lines only at
\n: a lone \r no longer ends a line, and any run of \r before \n ends
exactly one. A line with no end is cut at MAX_LINE_BYTES (64 KiB, never
inside a UTF-8 code point) and marked LineEnd::Cut, so a broken or
hostile server can't grow memory without limit.

* Add completion: last 500 commands, known words, Tab, history file

Echo-off input is never recorded. The history is saved by a worker thread
to its own file in the OS data folder. Adds a completion-bench binary for
the 20,000-word, 1 ms p99 budget.

* Add the connection layer: telnet, GMCP, MCCP2 and tokio net

A sans-IO telnet parser slices server text between IAC bytes with memchr
into zero-copy Bytes, negotiates ECHO, EOR, GMCP, MSSP and MCCP2, and
refuses every other option, NAWS included. Subnegotiation spans chunks,
MCCP2 starts mid-chunk and ends cleanly, and MSSP surfaces only NAME.
The net module wraps it in a tokio task with a non-blocking handle,
sends Core.Hello and Core.Supports.Set when GMCP is agreed, and offers a
login hook for plugins. mudling-bench-telnet measures throughput and the
cost per 4 KB chunk; examples/connect prints a server's events.

* Line pipeline: keep held code points whole across cuts and encoding switches

A 64 KiB cut that falls inside a code point whose start is held from the
last chunk now moves that code point to the next line, instead of
replacing both halves. Switching away from UTF-8 replaces a held code
point where it stands, not at the end of the line. The bench's line
pipeline report keeps its internals private.

* Cap telnet subnegotiation payloads at 8 MiB

A longer subnegotiation is dropped up to IAC SE, reported once as
Event::SubnegotiationDropped, and parsing carries on, so a broken server
can't grow memory without limit.

* Add versioned stores, batched diffs, UniFFI bindings and iOS build

mudling-core gains a store module: the §12 store set (terminal, vitals,
room, map, mobs, characters, gear, settings), each with a revision that
bumps only on a real change. A StoreHub coalesces a frame's changes into
one batch that the shell drains on its frame tick, and wakes the shell once
per batch. Memo caches derived data against input revisions. Payloads other
than the terminal's are placeholders for later tickets.

mudling-ffi exposes a coarse UniFFI API (open, subscribe, snapshot, drain,
send a command) and builds for iOS. scripts/build-ios.sh produces the
XCFramework and Swift bindings; a CI workflow runs it on macOS.
mudling-bench measures a frame's batch apply and a 50 lines/s burst.

* Add SQLite persistence with a writer thread

One WAL database per server under the OS data folder (servers/), with a
versioned schema, a writer thread that commits each batch of row upserts
and deletes in one transaction, and a separate read-only connection for
startup loads and lists. Map history is a header table plus rows copied
from the live map in SQL, so listing reads headers only.

The persist bench (mudling-bench --bin persist) measures move enqueue
cost, writer throughput, history listing and a 10,000-room startup read.

* Correct two completion doc comments

* Line pipeline test: a generic prompt instead of a game's

* Cap a coalesced terminal append at the store's capacity

A shell that stops draining, such as a minimized window, no longer grows the
pending terminal diff without bound; the lines dropped are ones the terminal
would trim anyway. The terminal tests move next to the payload, and the bench
derives its frame budget from the frame length.

* Phase 0: gpui chat, IME input, AccessKit pass, measuring and report

* Address connection review: buffer reuse, connect timeout, byte login answers

- Reuse the read buffer, reserving only when under 4 KB remains.
- Time the connect out (ConnectOptions::connect_timeout, 10 s default)
  and abort it on close or when every handle drops.
- LoginHook::answer returns bytes in the server's encoding.
- Ignore the wall-clock linear-time test; the bench measures it.
- The telnet bench uses the core's GMCP framing and constants, and
  names its decompressed totals telnet_*.
- Say why MCCP2 output blocks are 16 KB.

* Announce new lines by name too, for NVDA and Orca

The Windows and Linux AccessKit adapters announce a live node's name,
which for a Status node is its label, so a fixed "New lines" label hid
the text. The live region now carries the lines as label and value, and
the AX check asserts both.

* Hold the reading position from the list's real offset; announce only server lines

Whether a trim keeps the view still is now decided from the list's
offset at trim time, not from a flag refreshed in render, so lines that
arrive between a scroll and the next frame no longer move the view.
Echoed commands stay out of the live region. Scrollback::capacity and
the chat's LINE_HEIGHT and FONT_SIZE constants are no longer public.

* ANSI: consume escapes with intermediate bytes; carry escapes across cuts

ESC ( B and the like (ECMA-48 intermediates 0x20-0x2F up to a final
0x30-0x7E) are now removed whole, even split across chunks, instead of
leaving their final byte in the text. A 64 KiB line cut that falls
inside an escape sequence carries it to the next line; CSI parameters
past 256 bytes are skipped rather than buffered, so that carry can't
grow without limit.

* Persist: fix review findings

- Keep listed exits, explored links and seen doorways in tables of their
  own, as the prototype's map does, so a link or doorway outlives the
  room's listing.
- Store rooms per area in each map version's header.
- Copy and restore map versions from one column list per table.
- Keep failed batches, capped, for the session to collect with
  Writer::take_failures; flush still returns them.
- Migrate under an immediate transaction, and retry the WAL switch, so
  connections opening a new file at once can't race.
- Neutral host name in tests, bench labels from constants, small renames.

* Add the gpui-pre verification and Linux dependency scripts; run the AX check in CI

scripts/verify-gpui-pre.sh diffs the gpui-pre crates against the Zed
revision they name; scripts/install-linux-deps.sh is the one list of
gpui's Linux libraries, used by CI and linked from the README and docs.
The macOS CI job now runs --ax-check. The report also records that
headless find misses its budget on a heavily loaded machine.

* Add the plugin host: API v1 manifests, merged matchers, layers

A plugin is a folder with plugin.toml (id, name, version, api = 1,
license, layer, servers, encoding) and one TOML file per extension
point. All nine API v1 points parse into typed declarations, and every
pattern compiles at load; a bad plugin is rejected with an error naming
the file, and the line where TOML gives one.

The host runs Text end to end: patterns emit PluginEvents with named
captures and an optional Luau hook name to a PluginSink, color rules
paint lines the server sent plain, and prompt hints and the pager read
the pending line. Each matcher is one RegexSet behind an Aho-Corasick
filter of literals every match must contain, rebuilt only when the set
of enabled plugins changes. Layers run defaults, game, override; single
values come from the highest layer. Each plugin's cost per line is
sampled on its own.

A test fails if mudling-core holds the prototype's Avatar wording.
mudling-bench times pipeline plus host with 300 synthetic patterns.

* Plugin host: review fixes

- Only the merged text matcher gets the 16 MiB lazy DFA cache; the
  pending and per-plugin cost matchers keep the regex default.
- read_pending caches its answer by the pending text itself, so a host
  reused with a new pipeline (a reconnect) never answers from the old one.
- Server hosts are exact or one leading `*.domain`; any other wildcard is
  rejected at load.
- Point, Point::ALL and Point::name come from one list.
- PluginError's `file` is now `source`: a file, a plugin id or the host.
- mudling-bench reports the host's heap by stage with 6 and 20 plugins.

* Plugin host: less memory per pattern, chunked merged sets

- Pattern wraps a regex-automata meta regex built without the one-pass
  DFA, which alone cost up to 440 KB for a pattern with Unicode classes.
  The engine is private: Pattern offers is_match and captures, and
  PatternError says why a pattern doesn't compile.
- The per-plugin cost matchers are gone. Sampled lines time each
  plugin's own rules one by one; costs() is unchanged.
- The merged text matcher splits its filtered patterns into sets of 64.
  One Aho-Corasick scan says which sets' literals a line holds, and only
  those run. One set of 1,000 patterns cleared its DFA cache constantly
  (661 µs p99 per line); in chunks it's 11 µs.
- mudling-bench reports heap and host p99 for a realistic set (defaults,
  a 150-pattern game, an override) and for 6 and 20 uniform plugins.

* Plugin host: prefiltered cost sampling; bench gates on §11's measure

- Sampled lines time only each plugin's rules that the merged matcher's
  prefilter lets through (their required literals appear, or they have
  none), and extract captures only where a rule matches.
- mudling-bench --check gates on the whole line (≤ 20 µs p99) and the
  marginal cost of one plugin (≤ 5 µs p99). Each plugin's sampled cost is
  reported as an ungated upper bound, beside what sampled lines cost.
- The bench module's budgets and report fields are private, and
  PROMPT_HINTS sets the number of prompt hints each plugin gets.

* Add the map model and the live GMCP automap

A `map` module in mudling-core: rooms built live from Room.Info, the
game's text and the player's commands, one map per server. A move
updates a few rooms in memory and yields a batch of only the changed
rows for the persistence writer, plus a MapChange for views with a
revision that bumps only on a real change. Nothing re-encodes the world.

- Layout on persistent maps (imbl), so each explanation of an unsure
  walk copies in O(1), with indexes for look-alikes, squares, inbound
  links, hubs, buildings and area counts: no move scans the world.
- The prototype's tracker: the expected-move queue with the speedwalk
  fix, guesses scored and confirmed over later rooms, failed moves,
  flees, dark walks, mazes, and room identity by GMCP number or by
  name, exits and description.
- Straightening and buildings ported; the drawing search runs on dense
  indices and skips choices a known contradiction already rules out,
  so it picks the same drawing far faster.
- Derived areas and buildings cached by revision; walk-to on a
  VecDeque BFS; mob memory; map versions through the writer.
- MapRules: the game's wording as a trait with neutral defaults.
- mudling-bench `map`: move cost on a 10,000-room map and on replayed
  session logs, and an informational comparison with a copy of the
  prototype's world file.

* Format the line pipeline with rustfmt

* Format completion with rustfmt

* Persist tests: use a plain area name

* Map: a maze message marks the room in every guess

* Make mudling-bench the default binary of its crate

The telnet bench is a second binary, so cargo run -p mudling-bench needs
to know which one to run.

* Run mudling-bench by default now that the crate has two binaries

* Map: review fixes (derived count test, dead returns, save_version, empty replay)

* Map: room numbers overrule a move the game didn't make

* No game strings test: find its own file the same way on every OS

On Windows file!() uses backslashes, so trimming a forward-slash prefix
left the path unmatched and the test flagged its own denylist.

* Plugin host tests: build fixture paths from components

On Windows the loader joins with a backslash, so the expected error
prefix for a missing plugin folder is now built with Path::join and
display, like the error. The fixtures folder is built the same way.

* Core: line pipeline (one split, one ANSI strip, Line type) (#3)

* Add the line pipeline: one split, one ANSI strip per line

Server bytes become shared Line values in one streaming pass: memchr
finds line ends (\n, \r\n, \n\r, lone \r), the bytes are decoded once
(UTF-8 lossy or Latin-1), and only the new text goes through the ANSI
parser. The pending partial line, a possible prompt, is a view of the
same buffers and keeps its line number while it grows. GA/EOR finish it
through prompt_end.

The ANSI parser now streams, so escapes split across chunks parse;
parse_line keeps its behavior. mudling-bench times the pipeline per line.

* Line pipeline: drop every \r, and cut lines at 64 KiB

Match the prototype, which removed every \r and ended lines only at
\n: a lone \r no longer ends a line, and any run of \r before \n ends
exactly one. A line with no end is cut at MAX_LINE_BYTES (64 KiB, never
inside a UTF-8 code point) and marked LineEnd::Cut, so a broken or
hostile server can't grow memory without limit.

* Line pipeline: keep held code points whole across cuts and encoding switches

A 64 KiB cut that falls inside a code point whose start is held from the
last chunk now moves that code point to the next line, instead of
replacing both halves. Switching away from UTF-8 replaces a held code
point where it stands, not at the end of the line. The bench's line
pipeline report keeps its internals private.

* Line pipeline test: a generic prompt instead of a game's

* ANSI: consume escapes with intermediate bytes; carry escapes across cuts

ESC ( B and the like (ECMA-48 intermediates 0x20-0x2F up to a final
0x30-0x7E) are now removed whole, even split across chunks, instead of
leaving their final byte in the text. A 64 KiB line cut that falls
inside an escape sequence carries it to the next line; CSI parameters
past 256 bytes are skipped rather than buffered, so that carry can't
grow without limit.

* Format the line pipeline with rustfmt

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>

* Core: plugin host (API v1, plugin.toml, merged matchers, layers) (#7)

* Add the plugin host: API v1 manifests, merged matchers, layers

A plugin is a folder with plugin.toml (id, name, version, api = 1,
license, layer, servers, encoding) and one TOML file per extension
point. All nine API v1 points parse into typed declarations, and every
pattern compiles at load; a bad plugin is rejected with an error naming
the file, and the line where TOML gives one.

The host runs Text end to end: patterns emit PluginEvents with named
captures and an optional Luau hook name to a PluginSink, color rules
paint lines the server sent plain, and prompt hints and the pager read
the pending line. Each matcher is one RegexSet behind an Aho-Corasick
filter of literals every match must contain, rebuilt only when the set
of enabled plugins changes. Layers run defaults, game, override; single
values come from the highest layer. Each plugin's cost per line is
sampled on its own.

A test fails if mudling-core holds the prototype's Avatar wording.
mudling-bench times pipeline plus host with 300 synthetic patterns.

* Plugin host: review fixes

- Only the merged text matcher gets the 16 MiB lazy DFA cache; the
  pending and per-plugin cost matchers keep the regex default.
- read_pending caches its answer by the pending text itself, so a host
  reused with a new pipeline (a reconnect) never answers from the old one.
- Server hosts are exact or one leading `*.domain`; any other wildcard is
  rejected at load.
- Point, Point::ALL and Point::name come from one list.
- PluginError's `file` is now `source`: a file, a plugin id or the host.
- mudling-bench reports the host's heap by stage with 6 and 20 plugins.

* Plugin host: less memory per pattern, chunked merged sets

- Pattern wraps a regex-automata meta regex built without the one-pass
  DFA, which alone cost up to 440 KB for a pattern with Unicode classes.
  The engine is private: Pattern offers is_match and captures, and
  PatternError says why a pattern doesn't compile.
- The per-plugin cost matchers are gone. Sampled lines time each
  plugin's own rules one by one; costs() is unchanged.
- The merged text matcher splits its filtered patterns into sets of 64.
  One Aho-Corasick scan says which sets' literals a line holds, and only
  those run. One set of 1,000 patterns cleared its DFA cache constantly
  (661 µs p99 per line); in chunks it's 11 µs.
- mudling-bench reports heap and host p99 for a realistic set (defaults,
  a 150-pattern game, an override) and for 6 and 20 uniform plugins.

* Plugin host: prefiltered cost sampling; bench gates on §11's measure

- Sampled lines time only each plugin's rules that the merged matcher's
  prefilter lets through (their required literals appear, or they have
  none), and extract captures only where a rule matches.
- mudling-bench --check gates on the whole line (≤ 20 µs p99) and the
  marginal cost of one plugin (≤ 5 µs p99). Each plugin's sampled cost is
  reported as an ungated upper bound, beside what sampled lines cost.
- The bench module's budgets and report fields are private, and
  PROMPT_HINTS sets the number of prompt hints each plugin gets.

* No game strings test: find its own file the same way on every OS

On Windows file!() uses backslashes, so trimming a forward-slash prefix
left the path unmatched and the test flagged its own denylist.

* Plugin host tests: build fixture paths from components

On Windows the loader joins with a backslash, so the expected error
prefix for a missing plugin folder is now built with Path::join and
display, like the error. The fixtures folder is built the same way.

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>

* Core: default plugins (prompt learning, HUD fields, chaining, pagers, game detection) (#8)

* Add the default plugins: prompt learning, HUD fields, chaining, pagers, game detection

A server with no game plugin now gets a useful client (VYT-198, PRD §11
"Defaults that adapt"). The new `defaults` module in mudling-core:

- A built-in default-layer plugin (`defaults::plugin`), loaded through
  `Plugin::from_files`: text passes through uncolored, and the generic
  pager prompts ([C]ontinue, --More--, Press RETURN) are its pager, which
  a game plugin's pager replaces through the host's settings.
- Prompt learning, ported from the prototype's GamePrompt with its
  2026-10-28 fix. Shapes are counted, the most seen first, ties shortest.
  Pending text is never learned while it can still grow; a prompt is
  learned when its line ends, at GA, or when the next GMCP message shows
  the server finished writing it. A full store drops the rarest shape,
  ties longest, so glued output can't push out the real prompt. A learned
  prompt in pending text is ended with prompt_end. Prompt lines are
  hidden by default (`Defaults::hide_prompts`).
- HUD fields from the first Char.Vitals keys by common names, typed as
  `Vital`, with each enabled plugin's HUD vitals over them in run order.
- Command chaining (separate lines without a separator, packed into the
  highest layer's separator within 200 bytes) and speedwalk (`3n2e`,
  `8n, [s], d`), sent in the Commands point's speedwalk syntax and door
  command when a plugin declares them.
- A table-read pager that answers each pager prompt once, only between
  begin_table_read and end_table_read.
- Game detection from MSSP NAME, then the GMCP Core.Hello name, then the
  host, against each game plugin's [servers].

mudling-bench replays the logs' GMCP notes through the line pipeline,
the plugin host and the defaults. It reports the prompt match rate and
checks that no half-prompt shape is learned over three replays split
into random small reads, and it times the defaults per line.

* HUD values: match GMCP package names ignoring case

* Defaults review fixes: prompts start after the vitals, gmcp_arrived

- Text already pending when Char.Vitals arrives, such as a pager prompt
  or unended output, came before it and isn't learned as the prompt.
  The learner counts lines and awaits the prompt only on a line that
  began after the vitals.
- Rename Defaults::gmcp to gmcp_arrived, to match vitals_arrived.
- The bench matches hud::VITALS ignoring case, fails loudly when nothing
  was timed, and keeps its doc's bullet list whole.

* Defaults tests: build the fixture path from components

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>

* Core: Luau hooks (sandbox, permissions, instruction budget) (#9)

* Add Luau hooks: sandbox, permissions, instruction budget

Plugins list Luau files under [hooks] and declare permissions
(read_state, write_store:<name>, send_commands). A pattern that names a
hook hands its event to plugin::luau, which runs it on its own worker
thread in a sandboxed per-plugin VM: no io, os, debug, require or
loadstring, read-only globals, capped memory. An interrupt-counted
budget stops a runaway hook; three overruns in a session turn the plugin
off through the host with a PluginDisabled notice. Hooks emit StoreWrite
and rate-limited SendCommand outputs; a plugin that sends commands runs
no hooks until approved.

mudling-bench times the hand-off, a trivial hook, a column-splitting
hook and how fast the budget stops an endless loop.

* Luau hooks: a wall-clock time limit alongside the tick budget

A hook whose ticks are slow, such as big string work in a short loop,
stayed under the tick budget while holding the worker for seconds. The
interrupt now also checks a deadline (2 ms by default) and counts
crossing it as an overrun.

* Luau hooks: review fixes

- Time box in thread CPU time (cpu-time crate: clock_gettime with
  CLOCK_THREAD_CPUTIME_ID on Unix, GetThreadTimes on Windows), so
  preemption, suspension or a blocked StateView read isn't charged. The
  wall clock only decides when the CPU time needs reading.
- Loading a plugin's hooks checks that every hook its points name is
  defined, and rejects the plugin otherwise.
- The job queue is bounded (1,024 by default); when full, a new event is
  dropped, never waited for, and counted per plugin in
  LuauHooks::dropped.
- LuauHooks::start reports a failed thread spawn as StartError::Spawn.
- The manifest doc example puts permissions before the tables, and a
  test loads it as written.
- The module doc's first sentence reads properly.

* Luau hooks: PluginError.file is now .source

* Luau hooks: second review fixes

- Compiling a hook file no longer counts against its time box; only
  running the chunk does.
- A plugin whose hooks don't load is left out and listed in
  LuauHooks::failed_to_load; the others run. start fails only when the
  worker can't be spawned.
- set_approved never waits: jobs go on an unbounded channel, and a
  counter bounds the events instead. The waiting call is now
  wait_until_idle, for tests, benches and shutdown.
- turn_off_overrunning tries every plugin and returns each result.
- The bench times the hand-off with the queue never full.

* Luau hooks: rustfmt

* Luau hooks: own error for VM setup; refund a failed hook's sends

- A Luau VM that can't be set up is Problem::LuauSetup, sourced to the
  plugin, rather than Problem::Invalid on plugin.toml.
- A hook that sends and then fails gives its rate-limit slots back,
  since its sends never went out.

* Luau hooks: confirm a CPU-time overrun; the bench counts overruns

A trivial hook overran its 2 ms box on GitHub's macOS runner. It makes
one or two interrupt ticks, so the CPU-time check tripped: a stall past
2 ms of wall time made the meter read the thread's CPU clock, and on a
virtual machine that clock can count time the host spent elsewhere, or
advance in scheduler ticks.

- Crossing the CPU-time limit now only makes a hook suspect; it overruns
  if it keeps running for half the limit again, so a real runaway stops
  at 3 ms and a hook that met one jump has returned by then.
- The box starts after the event table is built, so its allocation and
  any GC step that runs isn't charged to the hook.
- The message says which limit tripped, with the ticks or the CPU and
  wall time used.
- The bench counts overruns of well-behaved hooks and reports the first
  reason; --check fails only above 1 in 10,000 calls.

* Luau hooks: time-box tests that don't depend on the machine

- Loading a hook file is its own function, and a unit test shows the box
  starts only after compiling: a file that doesn't compile never starts
  it. The large-file test now only checks that such a file loads.
- The slow-ticks test asserts which limit stopped the hook, not how long
  the whole run took.
- The module doc says pattern-match steps tick too.

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>

* Avatar game plugin (TOML + Luau, public API only) (#10)

* Add the Avatar game plugin: TOML and Luau on the public API

plugins/avatar holds everything the prototype hard-wired about Avatar,
written only against the plugin API. The host runs only the Text point,
so every line reader is a Text pattern: a table's start opens a Luau
reader, its rows queue store writes, and the next prompt (or the next
table) sends them. A table left at an unanswered pager sends nothing.

- text.toml: the prompt hint (Avatar's two shapes and <..> prompts), the
  pager ("c"), score, practice, slist, aff, eq, identify, examine, wear,
  remove and put, failed moves, darkness, flee, area names and aggro.
- readers.luau, character.luau, gear.luau: framing, fixed-column slist
  with wrapped rows, practice across pages, identify base vs enchant,
  slots and command words (never a possessive).
- commands.toml (verbs, doors, no separator), gear.toml (15 slots in
  order), map.toml, mobs.toml (mob name from a room title),
  character.toml (background commands and their readers).
- README.md: the event and store vocabulary.
- mudling-plugin-avatar: fixture tests through Plugin::load, the host and
  HookRunner, and avatar-replay for local logs.
- mudling-bench --plugin <dir>: a real plugin's marginal cost per line.

* Avatar plugin: bench gates on marginal cost only; maze comment names the API gap

* Avatar plugin tests: drop the blank line by lines, so CRLF checkouts pass

* Avatar plugin: a last page with no rows still answers the pager

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>

* Docs: match the README and comments to the merged core

The integration landed the connection layer, line pipeline, plugin host and Avatar plugin, so wording that called them future or called mudling-ffi a stub was false.

* Docs: use the reviewed wording for stale comments

---------

Co-authored-by: mhohlios <mhohlios@vyttle.com>
@MHohlios
MHohlios deleted the wiffletree/core-line-pipeline-one-split-one-ansi-strip-line branch November 1, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant