A VT/ANSI emulator complete enough to run escape sequences from real programs, so a byte stream from a shell, a server, or your own tooling renders correctly on a TextMode grid.
Summary
TerminalEmulator takes a stream of bytes and turns it into cells. That is the whole job, and it is more useful than it sounds: once you can consume a byte stream correctly, anything that speaks terminal can drive your display. A shell over a pipe, a log tailer, a MUD (Multi-User Dungeon) connection, a build tool’s colored output, or your own game code emitting escape sequences because that is a convenient way to describe a screen.
Underneath it is a parser built on Paul Williams’ DEC-compatible state machine, the same shape most real terminal emulators use. It is a byte-at-a-time finite state machine with thirteen states covering ground text, escape sequences, CSI (Control Sequence Introducer) parameters, OSC (Operating System Command) strings and DCS (Device Control String) passthrough. Feeding it bytes one at a time rather than whole strings is deliberate: a sequence split across two network packets, or a UTF-8 codepoint split across two reads, survives the gap because the parser’s state carries across calls.
The emulator sits behind the parser as its handler, and it is the part that knows what the sequences mean: move the cursor, set a color, scroll a region, switch to the alternate screen, erase to end of line. It owns a CellGrid and writes into it, which means everything else in TextMode composes with it. An effect can animate text the terminal wrote, a theme change recolors it, and your own drawing code can share the same grid.
If you want a terminal in your scene rather than a byte-stream consumer, you almost certainly want Terminal Frontend instead, which wraps this in a component with input handling, scrollback display and a command line. This page is the layer underneath.
Use the emulator directly for:
- Rendering output from a process, a socket, or a serial connection.
- Driving a display with escape sequences, because the source already speaks in that format.
- Building your own terminal-shaped component on top of a correct parser.
- Implementing a subset of a protocol where you want control over what happens to unhandled sequences.
Features
- Byte-level parser. Thirteen-state FSM (Finite State Machine) after Paul Williams’ DEC parser, fed one byte at a time so split sequences and split UTF-8 codepoints survive across reads.
- UTF-8 decoding. Multi-byte codepoints are assembled as they arrive rather than requiring a decoded string up front.
- Cursor control. Absolute and relative movement, save and restore, column and line addressing, next and previous line, and cursor visibility.
- Full SGR. Bold, dim, italic, underline, blink, inverse and hidden, the sixteen indexed colors, the 256-color extension, and 24-bit truecolor through the extended color forms.
- Scrolling regions. A top and bottom margin that the cursor and scrolling honor. Programs use it to keep a status line pinned while the rest of the screen scrolls.
- Alternate screen buffer. The full-screen mode a text editor switches into, so it can take over the display and hand it back with the previous contents intact. Each screen keeps its own saved cursor, so a program that saves and restores its cursor on the alternate screen cannot move the cursor of the program it returns to.
- Cursor shapes. A program can pick a block, underline or bar cursor, blinking or steady, and hand the choice back when it exits.
- Line drawing. The DEC Special Graphics character set turns plain ASCII letters into box-drawing lines, corners and symbols, which is how many older and curses-based programs draw their frames.
- Erase and edit operations. Erase in display and in line, insert and delete lines, insert and delete characters, erase characters in place.
- Tab stops. A default interval plus runtime stop setting and clearing.
- Mouse reporting. Normal, button-event and any-event tracking, in default or SGR encoding, so a program running in the terminal can read the mouse. See Selection and Mouse.
- Bracketed paste. Pasted text is wrapped in markers so a program can tell it apart from typing.
- Hyperlinks. OSC 8 links are recorded per cell and can be queried by position, which is how a clickable URL in terminal output works.
- Scrollback. Lines scrolled off the top of the primary screen go into a ring buffer, so the cost is bounded and paid up front. Only a line that leaves the top row counts, as in xterm, so a scrolling region further down the screen does not fill the history with fragments.
ED 3andClearScrollbackempty it.
What it understands
The emulator implements the subset of VT behavior that real programs actually use. Everything below is dispatched; anything else is parsed correctly and ignored rather than corrupting the screen.
| Area | Sequences | Notes |
|---|---|---|
| Cursor movement | CUU, CUD, CUF, CUB, CNL, CPL, CHA, CUP, HVP, VPA | The A through H family plus line and column addressing. Coordinates are one-based in the protocol and clamped to the grid, so a program that overshoots does not write out of bounds. |
| Save and restore | DECSC, DECRC, SCP, RCP | Cursor position, colors, attributes and character sets saved and restored, which programs use around a transient status update. Each screen has its own saved state. |
| Erasing | ED, EL, ECH | Erase in display and in line each take a mode selecting before, after, or all. ED 3 erases the scrollback instead of the screen. Erase character blanks in place without moving anything. |
| Editing | IL, DL, ICH, DCH | Insert and delete lines and characters. These are what make a full-screen editor’s redraws cheap. |
| Scrolling | SU, SD, DECSTBM | Scroll up and down, plus the margin pair that sets the region they apply to. |
| Styling | SGR | The full attribute and color set, including the extended 38 and 48 forms for 256-color and truecolor, and 39 and 49 to return to the defaults. |
| Reporting | DSR | CSI 5 n answers CSI 0 n, and CSI 6 n answers the cursor position as CSI row ; column R, relative to the scroll region in origin mode. The DEC form CSI ? 6 n answers with the question mark as well. Answers go to OnTerminalOutput, which the host passes back to the program as input. |
| Tab stops | HTS, TBC | Set a stop at the cursor; clear one stop or all of them. |
| Cursor shape | DECSCUSR | CSI Ps SP q: 1 and 2 are a blinking and steady block, 3 and 4 an underline, 5 and 6 a bar, and 0 returns to the host’s own cursor. |
| Character sets | SCS, SO, SI | ESC ( 0 and ESC ) 0 select DEC Special Graphics for G0 and G1, ESC ( B returns to ASCII, and SO and SI switch between the two sets. |
| DEC private modes | DECCKM, DECAWM, DECTCEM, plus the mouse and screen modes below | Set and reset through the question-mark prefixed forms. |
| Mouse tracking | modes 1000, 1002, 1003, 1006 | Normal, button-event and any-event tracking, plus SGR encoding. |
| Screen buffers | modes 1047, 1048, 1049 | Alternate screen, cursor save, and the combined form most programs use. |
| Bracketed paste | mode 2004 | Wraps pasted input in markers. |
| OSC | 0, 2, 8 | Window and icon title, and hyperlinks. |
Recipes
Render output from a process
Problem. Your game has a diegetic terminal that shows real output: a build log, a server response, a script running in the background. The source emits ANSI color codes and cursor movement, and you want them to render rather than appear as garbage.
Solution. Feed the bytes to the emulator as they arrive. Do not decode to a string first; the parser wants bytes so it can handle split sequences and UTF-8 correctly.
/* One emulator owns one grid. Sized to the display you want, not to the source. */
_emulator = new TerminalEmulator(rows: 25, columns: 80,
defaultFG: ANSIColor.White, defaultBG: ANSIColor.Black);
/* Point a view at the emulator's grid so whatever it writes is what renders. */
_view.SetGrid(_emulator.Grid);
/* Then, whenever bytes arrive, hand them over. Partial sequences are fine: the parser keeps
its state across calls, so a color code split across two reads still resolves. */
private void OnBytesReceived(byte[] buffer, int count)
{
for (var i = 0; i < count; i++)
{
_emulator.Feed(buffer[i]);
}
}
Drive a screen with escape sequences from your own code
Problem. You are writing a diegetic computer, and a string of escape sequences is genuinely the most natural way to describe its screen, since that is exactly the byte stream the fictional machine would produce.
Solution. FeedString for convenience when the source is your own well-formed text.
/* Safe here because the string is complete and yours. Home the cursor, set green on black,
clear the screen, and print. In C# the escape byte is written as \x1b. */
_emulator.FeedString("\x1b[H\x1b[32;40m\x1b[2J");
_emulator.FeedString("SYSTEM ONLINE\r\n");
/* A scrolling region keeps the header pinned while the log below it scrolls. Rows are
one-based in the protocol, so this reserves the top two lines. */
_emulator.FeedString("\x1b[3;25r");
Query a hyperlink under the cursor
Problem. Output from a build tool contains OSC 8 hyperlinks, and you want clicking one to open it.
Solution. TextModeTerminal already opens a link on Ctrl+click, so this is only needed when the emulator runs under a view of your own. The emulator records links per cell. Convert the click to a cell and ask.
if (_view.ScreenPointToCell(Input.mousePosition, _eventCamera, out var cell))
{
/* Returns null when that cell carries no link, so this doubles as the hit test. */
var url = _emulator.GetHyperlinkAt(cell.Row, cell.Column);
if (!string.IsNullOrEmpty(url))
{
Application.OpenURL(url);
}
}
GetHyperlinkAt reads the live screen. For a cell from the scrollback, or from a view composed from it, pass the cell’s attributes to GetHyperlinkURI instead.
Keep a status line pinned while output scrolls
Problem. Your terminal shows a live connection status on the top line and scrolling output below it. Without a scrolling region, new output pushes the status line off the top.
Solution. Set the margins. Everything that scrolls afterwards respects them.
/* Reserve the top line by starting the scrolling region at row 2. The protocol is one-based
and both bounds are inclusive, so this scrolls rows 2 through 25 and leaves row 1 alone. */
_emulator.FeedString("\x1b[2;25r");
/* Now write the status line, which sits outside the region and stays put. Attribute 7 is
inverse, which is the conventional way to mark a status bar. */
_emulator.FeedString("\x1b[1;1H\x1b[7m CONNECTED \x1b[0m");
Switch to the alternate screen and back
Problem. Your in-game editor should take over the display and, on exit, give the player back exactly what was on screen before, the way a real full-screen program does.
Solution. Use the alternate screen buffer. The emulator keeps the primary buffer intact while the alternate is active.
/* Mode 1049 saves the cursor, switches to the alternate screen and clears it, which is the
combined form most full-screen programs use. */
_emulator.FeedString("\x1b[?1049h");
DrawEditor();
/* Reset the same mode to restore the primary buffer and the saved cursor. Nothing the editor
drew persists, which is exactly the contract. */
_emulator.FeedString("\x1b[?1049l");
Handle sequences the emulator does not implement
Problem. You are speaking a protocol with a custom sequence, or you want to intercept a specific CSI before the emulator sees it.
Solution. The parser dispatches to an IVTParserHandler, and the emulator is just one implementation. Wrap it to intercept.
/* Implement the handler interface and forward everything you do not care about. The emulator
is a handler too, so composing is a matter of holding one and delegating. */
public sealed class InterceptingHandler : IVTParserHandler
{
private readonly TerminalEmulator _inner;
public void CSIDispatch(byte final, ReadOnlySpan<int> parameters,
ReadOnlySpan<byte> intermediates)
{
/* Claim the one sequence you care about; hand everything else to the emulator so
ordinary terminal behavior keeps working. */
if (final == (byte)'z')
{
HandleCustomSequence(parameters);
return;
}
_inner.CSIDispatch(final, parameters, intermediates);
}
/* Print, Execute, ESCDispatch, OSCDispatch and the DCS trio forward the same way. */
}
API Sketch
class TerminalEmulator : IVTParserHandler {
TerminalEmulator(CellGrid grid, GlyphAtlasCache glyphCache, int scrollbackCapacity);
CellGrid Grid; CellGrid PrimaryGrid; CellGrid AlternateGrid; VTParser Parser;
int Rows, Columns, CursorRow, CursorCol;
Color32 CurrentFG, CurrentBG;
bool CursorVisible; CursorStyle CursorStyle; bool CursorBlinking;
bool HasCursorStyleOverride; bool IsDECGraphicsActive;
bool IsAlternateScreen, BracketedPasteMode;
bool ApplicationCursorKeys, ApplicationKeypad;
MouseTrackingMode MouseTrackingMode; MouseEncodingMode MouseEncodingMode;
bool HasActiveHyperlink; bool AllowClipboardRead;
ScyllaRingBuffer<Cell[]> Scrollback;
Action OnCursorStyleChanged; Action<CellGrid> OnActiveGridChanged;
Action<string> OnTitleChanged; Action OnBell;
void Feed(ReadOnlySpan<byte> data); /* the safe path for streamed input */
void FeedString(string text); /* convenience for complete, local strings */
void Reset();
void Resize(int rows, int columns); /* both screens, scroll region and cursor */
void SetGlyphCache(GlyphAtlasCache glyphCache);
void ClearScrollback();
void SetDefaultColors(Color32 fg, Color32 bg);
string GetHyperlinkAt(int row, int column);
string GetHyperlinkURI(CellAttributes attributes); /* any cell, scrollback included */
/* IVTParserHandler, callable directly when composing a custom handler */
void Print(uint codepoint);
void Execute(byte control);
void CSIDispatch(...); void ESCDispatch(...); void OSCDispatch(...);
void DCSHook(...); void DCSPut(...); void DCSUnhook();
}
class VTParser {
void Feed(byte b);
void FeedString(string text);
void Reset();
}
interface IVTParserHandler {
void Print(uint codepoint);
void Execute(byte control);
void CSIDispatch(...); void ESCDispatch(...); void OSCDispatch(...);
void DCSHook(...); void DCSPut(...); void DCSUnhook();
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Best Practices
- Feed bytes, not strings, for anything streamed. The parser’s state machine exists to survive split reads, and converting to a string first throws that away.
- Size the emulator to your display, not to the source. A program asks what size it is and adapts; giving it the grid you can actually show is the honest answer.
- Pass
OnTerminalOutputback to the program. Status reports and clipboard answers are the terminal talking to the program, so they belong in the program’s input stream. A program that asks for the cursor position and never gets an answer usually waits until it times out. - Resize through the emulator.
Resizechanges both screens, resets the scroll region and moves the cursor into bounds. Resizing only the grid on show leaves the other screen and the scroll region at the old size. - Hand the emulator the new glyph cache after a font change. Cells carry the atlas coordinates of the font they were written with, and
SetGlyphCacherestamps everything the emulator holds.TextModeTerminaldoes this for you inUpdateFont. - Set the default colors on the emulator, not just the view. SGR 39 and 49 return to those defaults, so a mismatch shows up the first time a program resets its colors.
- Reset between sessions.
Resetreturns the emulator to a known state, which matters when you reuse one across connections rather than constructing a fresh one. - Expect unhandled sequences and do not panic about them. The parser consumes them correctly even when the emulator ignores their meaning, so an unknown sequence is invisible rather than corrupting.
- Check
IsAlternateScreenbefore assuming the grid is yours. A hosted program may have switched buffers. - Treat the scrollback as bounded. It is a ring buffer sized from the configuration, so old lines are dropped rather than growing memory. If your game needs a full transcript, keep your own.
- Use a scrolling region rather than redrawing a pinned header. Margins are cheaper and they keep working when the program controlling the screen scrolls on its own.
- Compose a handler rather than forking the emulator when you need custom sequences. The interface exists for that, and forking means reimplementing everything you did not want to change.
- Remember the protocol is one-based. Row 1 column 1 is the top left in an escape sequence, while the grid is zero-based. Off-by-one bugs here are common and look like a cursor that is consistently one cell out.
Pitfalls
Related
- 01 Scylla TextMode
- 07 TextMode – Terminal Frontend
- 05 TextMode – Colors and Attributes
- 03 TextMode – Views and Grid
- API reference:
Scylla.TextMode.TerminalEmulator - API reference:
Scylla.TextMode.VTParser - API reference:
Scylla.TextMode.IVTParserHandler - API reference:
Scylla.TextMode.TerminalScrollbackBuffer