Skip to content

Cursor motion, margins and tab stops as xterm does them - #93

Merged
JohnCampionJr merged 4 commits into
mainfrom
cursor-margins
Aug 29, 2026
Merged

Cursor motion, margins and tab stops as xterm does them#93
JohnCampionJr merged 4 commits into
mainfrom
cursor-margins

Conversation

@JohnCampionJr

Copy link
Copy Markdown
Collaborator

Fourth of eight PRs from the whole-codebase audit. Stacked on #92 (which is stacked on #89) — merge those first.

Ten divergences from xterm's cursor and margin behavior. Each was reproduced against the built library before being fixed.

Divergence What a program saw
CUU/CUD/CNL/CPL ignored the scrolling region CUF/CUB already had the in/out rule (start inside → stop at the margin, start outside → screen edge); the row motions now share it, so a full-screen editor's status line stops scrolling with the text
CUB counted from the phantom column printing to the end of a line leaves the cursor one past the last column; counting back from there landed one column right of every other terminal, so a shell's redraw overwrote the wrong character
DECAWM off discarded characters past the margin the VT100, xterm and xterm.js all keep overwriting the last column — a full-width status bar drawn with wrapping off lost its final character
CSI 0;0r clamped the region to one row an explicit 0 means the default exactly as a missing parameter does, and the parser seeds parameters with 0, so the bare CSI ;r form arrived the same way
IL/DL left the cursor column alone xterm moves to the left margin; an editor inserting a line then writing to it started from wherever the cursor sat
DECSC/DECRC saved position and SGR only they now carry charset designations, origin mode and the pending-wrap flag. SavedCursor.Charset had sat unassigned, which was the tell — and saving which G-set is active is not enough, because ESC ( 0 changes what G0 means
ICH/DCH/ECH no-opped from a full line they tested the phantom column against the right-margin guard and returned, so inserting a character into a filled line did nothing
Tab stops were arithmetic HTS could not create one and TBC cleared nothing — its own comment admitted it. There is a real stop set now, so tabs 4 works
C0 HT hardcoded 8 columns while CHT/CBT honoured Options.TabStopWidth, so the two tab motions disagreed on one screen
HPA, HPR, VPR unimplemented silently ignored, though xterm's terminfo lists hpa and programs emit it

Behavior changes worth a deliberate look

  • DECAWM off now overwrites rather than discards. Correct per every reference, but it changes what a program with wrapping disabled puts on screen.
  • C0 HT now honours TabStopWidth. A host that set the option while relying on tabs staying at 8 will see different columns.

Public API

None. The tab-stop methods on Terminal are internal.

Validation

12 new tests, each naming the program behavior at stake. Suite green: 1911. Perf 3×3 vs main, measured together with #92 beneath it: every corpus inside noise (unicode +2.6% ±3%, alt-redraw +0.1% ±3%, flood −3.2% ±6%).

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Perf comparison

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.35 → 3.38 +0.8% ±1% 5%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.81 → 9.41 +6.8% 👀 ±20% 60%
truecolor 0.00 → 0.00 0.00 → 0.00 9.72 → 10.85 +11.6% 👀 ±18% 53%
alt-redraw 0.00 → 0.00 0.00 → 0.00 13.64 → 14.42 +5.7% 👀 ±8% 25%
unicode 7.66 → 7.66 0.45 → 0.45 33.25 → 33.65 +1.2% ±4% 11%
flood 0.00 → 0.00 0.00 → 0.00 94.95 → 94.57 -0.4% ±2% 5%

Each corpus is gated at max(5%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.0.0 mvid:33c4e71d-0e35-4181-b8da-135b19204a82
  • head: XTerm.NET 2.0.0.0 mvid:d7c94fcb-5f9b-4327-8c03-052d95aaaa9a

Worth a look — over the floor, under this run's gate, so not failed:

  • sgr-churn time 8.81 → 9.41 ns/char (6.8%, under its 59.8% gate but over the 5% floor)
  • truecolor time 9.72 → 10.85 ns/char (11.6%, under its 52.9% gate but over the 5% floor)
  • alt-redraw time 13.64 → 14.42 ns/char (5.7%, under its 24.6% gate but over the 5% floor)

Re-run on a quieter machine, or with more --chars, to tell a real change from a busy runner. Both narrow the noise column, which tightens the gate.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Tab-stop behavior is incomplete/inconsistent because CBT (CSI Z) and TBC (CSI g) do not yet use the new real tab-stop set, so programs cannot reliably clear or backward-tab to custom stops.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR is part of the ongoing audit to align XTerm.NET’s behavior with xterm (and reference VT500 parsing behavior), focusing on cursor motion within margins/scroll regions, tab-stop correctness, and hard bounds for hostile/untrusted input so the terminal stays responsive.

Changes:

  • Align cursor motion/editing operations with xterm semantics (scroll-region-aware CUU/CUD/CNL/CPL; correct “phantom column” behavior; DECAWM-off overwrite behavior; proper scroll-region parameter defaults; more accurate DECSC/DECRC state).
  • Implement “real” tab stops (HTS/TBC) and unify tab motion behavior around them; add HPA/HPR/VPR mappings.
  • Add parser/bounds hardening: correct CAN/SUB cancellation, C1 ST OSC termination, DEL handling, CSI param saturation, OSC payload cap, cluster/table caps; bound PNG inflate and Kitty payload accumulation; add regression tests.
File summaries
File Description
src/XTerm.NET/Terminal.cs Adds tab-stop storage + helpers; integrates tab-stop reset into ctor/reset/resize; HT now uses tab-stop set; negative resize guard.
src/XTerm.NET/Parser/EscapeSequenceParser.cs VT500-state-machine fixes (CAN/SUB cancellation, ESC clear action, ST/DEL behavior) plus parameter saturation and OSC payload cap/reset hygiene.
src/XTerm.NET/InputHandler.cs Cursor/margin correctness fixes, DECAWM-off overwrite semantics, editing ops settling from phantom column, charset designation save/restore, HTS hook, Kitty/OSC/cluster bounds.
src/XTerm.NET/Graphics/PngDecoder.cs Bounds zlib inflation to expected size to avoid decompression bombs.
src/XTerm.NET/Common/CommandExtensions.cs Maps ECMA-48 HPA/HPR/VPR spellings to existing cursor motions.
src/XTerm.NET/Common/ClusterTable.cs Caps distinct interned clusters to prevent unbounded process-lifetime growth.
src/XTerm.NET/Buffer/TerminalBuffer.cs Extends saved cursor state (origin mode, pending-wrap) and adds controlled pending-wrap restore API.
src/XTerm.NET.Tests/Parser/ParserConformanceTests.cs New tests covering VT500-diagram conformance cases and regressions.
src/XTerm.NET.Tests/HostileInputBoundsTests.cs New tests asserting bounds for hostile but protocol-legal inputs (clusters, scroll counts, OSC, params, PNG inflate, resize).
src/XTerm.NET.Tests/CursorAndMarginTests.cs New tests pinning xterm-compatible cursor/margin/tab-stop behavior.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +4949 to 4953
for (var i = 0; i < count; i++)
_buffer.SetCursor(_terminal.NextTabStop(_buffer.X), _buffer.Y);
}

private void CursorBackwardTab(Params parameters)
_buffer.SetCursor(Math.Min(nextTabStop, _terminal.Cols - 1), _buffer.Y);
}
for (var i = 0; i < count; i++)
_buffer.SetCursor(_terminal.NextTabStop(_buffer.X), _buffer.Y);

@JohnCampionJr JohnCampionJr left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two cursor/tab-state issues should be addressed before merge.

Comment thread src/XTerm.NET/Terminal.cs
}

/// <summary>Clears the stop at <paramref name="column"/>, or every stop. TBC.</summary>
internal void ClearTabStop(int column, bool all)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new tab-stop operations are not actually used by TBC or CBT: TabClear in InputHandler still clears nothing, and CursorBackwardTab still uses TabStopWidth arithmetic. Thus CSI 3 g leaves all default stops active, and backward tabs ignore HTS-created stops. The new test does not expose this because its custom column-4 stop merely precedes the unchanged default column-8 stop. Please wire both handlers to ClearTabStop and PreviousTabStop and test that removed defaults are absent.

_currentCharset = _buffer.SavedCursorState.Charset;
RefreshActiveCharset();
_terminal.OriginMode = _buffer.SavedCursorState.OriginMode;
_buffer.SetPendingWrap(_buffer.SavedCursorState.PendingWrap);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flag alone cannot restore a pending-wrap cursor. The saved position may have X == Cols, but the preceding SetCursor clamps it to Cols - 1; the next printed character then overwrites the final cell instead of wrapping. Please restore the raw coordinate when PendingWrap is saved, and add a full-line DECSC/DECRC/print regression test.

@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Perf comparison — this change, against its base

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.36 → 3.38 +0.5% ±1% 4%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.99 → 8.99 +0.1% ±1% 4%
truecolor 0.00 → 0.00 0.00 → 0.00 9.92 → 10.08 +1.6% ±3% 9%
alt-redraw 0.00 → 0.00 0.00 → 0.00 13.82 → 13.54 -2.0% ±3% 8%
unicode 7.66 → 7.66 0.45 → 0.45 33.80 → 33.75 -0.1% ±3% 10%
flood 0.00 → 0.00 0.00 → 0.00 94.57 → 94.92 +0.4% ±2% 6%

Each corpus is gated at max(4%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.0.0 mvid:b572b252-3fa1-4433-b1f8-e47897e53c93
  • head: XTerm.NET 2.0.0.0 mvid:193ac3bd-c410-40e8-b0b9-3d24710c3df2

Perf comparison — cumulative, everything since 2.0.0-rc002

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.37 → 3.38 +0.2% ±1% 5%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.83 → 8.99 +1.8% ±1% 5%
truecolor 0.00 → 0.00 0.00 → 0.00 9.75 → 10.08 +3.4% ±3% 9%
alt-redraw 0.00 → 0.00 0.00 → 0.00 13.81 → 13.54 -2.0% ±4% 13%
unicode 7.66 → 7.66 0.45 → 0.45 33.40 → 33.75 +1.1% ±3% 8%
flood 0.00 → 0.00 0.00 → 0.00 93.95 → 94.92 +1.0% ±1% 5%

Each corpus is gated at max(5%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.0.0 mvid:1b573f7c-2562-4a81-9dc9-0cdd9bd9fb1d
  • head: XTerm.NET 2.0.0.0 mvid:193ac3bd-c410-40e8-b0b9-3d24710c3df2

@JohnCampionJr

Copy link
Copy Markdown
Collaborator Author

Rebased onto the current #92, and both findings fixed. You were both right, and the second one is the more interesting failure.

TBC and CBT were never wired to the stop set. This PR gave Terminal a real tab-stop set and then left two of its four consumers on the old arithmetic: HTS could add a stop, HT and CHT honoured it, but CSI 3 g cleared nothing and backward tab ignored everything a program had set. Both now go through ClearTabStop and PreviousTabStop.

They were written and lost rather than never written, which is worth recording: the patch script that made those two edits asserted on a third, failed, and never reached its write — so all three were discarded together. Re-running only the third compiled, the suite passed, and nothing pointed at the hole.

And the test would not have caught it, exactly as @JohnCampionJr said. The custom stop at column 4 merely preceded the untouched default at 8, so a tab landed on 4 whether or not TBC worked. The replacement clears every stop and asks whether a default went with them, which only passes if TBC does something.

DECRC could not restore a pending wrap. That position is X == Cols — one past the last column, where no character sits — and SetCursor clamps it to Cols - 1. So the restore put the cursor on the last cell and the next character overwrote it instead of wrapping; setting the flag afterwards could not undo that, because the coordinate was already gone. It restores rawly now when the saved cursor carried the flag.

Three regression tests, each verified to fail with the fix reverted and pass with it — the property the original tab test lacked.

Suite green: 1923.

@JohnCampionJr

Copy link
Copy Markdown
Collaborator Author

Reviewed this PR's own hot path, as asked. Two touches, both minor, one of which was an allocation worth removing.

Per-run: the ASCII fast-path gate gained !_terminal.Options.Wraparound — two field reads per PrintAsciiRun call, not per character. It is there because the run path cannot model DECAWM-off overwrite semantics and defers to Print when wrapping is off.

Per-DECSC, and now gone: SaveCursor copied the charset designation map into a fresh Dictionary on every save. DECSC is not rare — a full-screen program saves and restores the cursor on every redraw — so that was an allocation per redraw. There are exactly four G-slots and CharsetMode numbers them from zero, so it is a fixed array now and the save is four reference writes into storage allocated once.

Everything else here is per-sequence rather than per-character: the margin limits in CUU/CUD/CNL/CPL, SettleForEditing in the editing operations, the tab-stop machinery, and the three HPA/HPR/VPR dictionary entries.

Perf 3×3 against main, measured with nothing else running: scroll-ascii +0.4%, sgr-churn +1.1%, truecolor +0.9%, alt-redraw +0.6%, unicode +1.9%, flood +0.4% — every corpus inside noise (±2–3%) and well under gate.

Suite green: 1923. Rebased onto main with #92 merged; MERGEABLE.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new PendingWrap-based logic and saved charset designation storage need corrections to avoid incorrect cursor/edit behavior and cross-buffer DECSC/DECRC state leakage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (5)

src/XTerm.NET/InputHandler.cs:4497

  • CursorBackward uses PendingWrap alone to decide whether to subtract 1 from the starting column, but PendingWrap can be stale when X is within the margins (only X>WrapLimit is the phantom-column case). This makes CUB move one extra column left after ordinary printing that didn't reach the right margin.

This issue also appears in the following locations of the same file:

  • line 4837
  • line 6529
    }

src/XTerm.NET/InputHandler.cs:4838

  • SettleForEditing settles the cursor whenever PendingWrap is true, but PendingWrap can be true even when the cursor is not in the phantom column. This would make ICH/DCH/ECH operate one column to the left in common cases; gate settling on the cursor actually being past the wrap limit.

    /// <summary>

src/XTerm.NET/InputHandler.cs:6527

  • SaveCursor overwrites the saved charset designations globally; with per-buffer DECSC/DECRC semantics this should save the snapshot keyed by the current buffer instance.
    /// the cursor mid-border finished the box in letters.

src/XTerm.NET/InputHandler.cs:6548

  • RestoreCursor should restore the charset designations snapshot that was saved for the current buffer; otherwise DECRC can apply a snapshot saved on the other screen.
        _buffer.SavedCursorState.PendingWrap = _buffer.PendingWrap;
    }

    private void RestoreCursor()
    {

src/XTerm.NET/InputHandler.cs:6529

  • SaveCursor persists PendingWrap directly, but PendingWrap is only meaningful as "wrap is pending" when the cursor is actually in the phantom column (X > WrapLimit). Saving it unconditionally can cause DECRC to restore a phantom-wrap state that never existed.
    private void SaveCursor()
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/XTerm.NET/InputHandler.cs Outdated
private CharsetMode _currentCharset;

/// <summary>What each G-slot pointed at when DECSC last ran. See <see cref="SaveCursor"/>.</summary>
private Dictionary<CharsetMode, Dictionary<char, string>?>? _savedCharsetDesignations;
JohnCampionJr and others added 4 commits August 29, 2026 13:41
Ten divergences, each reproduced before fixing:

- CUU/CUD/CNL/CPL ignored the scrolling region. CUF/CUB had the
  in/out rule already -- a cursor starting inside stops at the margin,
  one starting outside at the screen edge -- and the row motions now
  share it, so a full-screen editor's status line stops scrolling with
  the text.
- CUB counted from the phantom column a filled line leaves the cursor
  in, landing one right of where every other terminal puts it.
- DECAWM off DISCARDED characters past the margin; the VT100, xterm
  and xterm.js all keep overwriting the last column. The ASCII run
  path bails to Print for this rather than learning it twice, which
  costs nothing while wrapping is on -- the default.
- CSI 0;0r clamped the region to one row. An explicit 0 means the
  default, exactly as a missing parameter does, and the parser seeds
  parameters with 0 so the bare CSI ;r form arrived the same way.
- IL/DL left the cursor column alone instead of moving to the left
  margin, so an editor inserting a line then writing to it started
  from wherever the cursor happened to sit.
- DECSC/DECRC saved position and SGR only. They now carry the charset
  DESIGNATIONS (ESC ( 0 changes what G0 means, so saving which G-set
  was active restored a pointer to a table the program had replaced),
  origin mode, and the pending-wrap flag. SavedCursor.Charset had sat
  unassigned, which was the tell.
- ICH/DCH/ECH tested the phantom column against their right-margin
  guard and returned, so an editor that filled a line and inserted a
  character saw nothing happen.
- Tab stops were arithmetic, so HTS could not create one and TBC
  cleared nothing -- its own comment admitted it. There is a real stop
  set now; 
�[3g   �H
 works, and C0 HT stops hardcoding 8 while CHT
  honoured the option, which had the two tab motions disagreeing on
  one screen.
- HPA, HPR and VPR were unimplemented and silently ignored, though
  xterm's terminfo lists hpa and programs emit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both reviewers caught the same thing and both were right: this PR gave
Terminal a real tab-stop set and then left two of its four consumers
using the old arithmetic. HTS could add a stop, HT and CHT honoured
it, but TBC cleared nothing and CBT ignored it -- so CSI 3 g left
every default standing and backward tab disagreed with forward tab on
the same screen.

They were written and lost rather than never written. The patch script
that made them asserted on a third edit, failed, and never reached its
write, so all three were discarded together; re-running only the third
compiled and passed, and nothing pointed at the hole. The tests here
would not have caught it either -- as noted in review, the custom stop
at column 4 merely preceded the untouched default at 8, so a tab
landed on 4 whether or not TBC worked. The new one clears every stop
and asks whether a DEFAULT went with them.

Also from review: DECRC could not restore a cursor that was pending a
wrap. That position is X == Cols, one past the last column, and
SetCursor clamps it to Cols - 1 -- so the restore put the cursor ON
the last cell and the next character overwrote it instead of wrapping.
The flag set afterwards could not undo that; the coordinate was
already gone. Restored rawly when the saved cursor carried the flag.

Three tests, each verified to fail without its fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DECSC is not rare -- a full-screen program saves and restores the
cursor on every redraw -- and copying the designation map allocated a
Dictionary per save. There are exactly four G-slots and the enum
numbers them from zero, so a fixed array indexes them directly and the
save becomes four reference writes into storage allocated once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
state already lives

The designations were held in an array on the input handler while every
other thing DECSC saves -- position, attributes, selected G-set, origin
mode, pending wrap -- sits in the buffer's own SavedCursorState. DECSC is
per-screen, so that one field was shared between the normal and alternate
buffers: a full-screen program saving and restoring on its redraws
overwrote what the shell had saved, and after the program exited the
shell's DECRC brought back the program's designations. A shell that had
selected line drawing got ASCII.

Putting the array in SavedCursor gives it the same lifetime as the rest,
and the array being null is a better "DECSC has not run here" than the
separate bool it replaces -- one less thing with its own places to be
cleared and forgotten.

The regression test drives the actual sequence: designate line drawing on
the normal screen, DECSC, enter 1049, designate ASCII, save and restore
there, leave 1049, DECRC, then print. It fails on the shared field.
@JohnCampionJr

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (post-#97), 1934 tests green.

Copilot's per-screen finding is right, and it was mine. The designations were the only thing DECSC saves that did not live on the buffer — position, attributes, selected G-set, origin mode and pending wrap are all in SavedCursorState, and the array I added sat on the input handler instead. That made it shared between the normal and alternate screens, so the sequence a full-screen program actually performs breaks it:

ESC ( 0   shell designates line drawing
ESC 7     shell saves
ESC [ ? 1049 h   program starts
ESC ( B   program wants ASCII
ESC 7 ESC 8      program saves and restores on a redraw  <- overwrites the shell's
ESC [ ? 1049 l   program exits
ESC 8     shell restores, and gets the program's designations

The shell's line drawing comes back as ASCII. It is in SavedCursor now, which gives it the same per-screen lifetime as everything beside it, and the array being null replaces the separate _hasSavedCharsets bool — one less piece of state with its own places to be cleared and forgotten. All three flagged locations go away with the field.

The test drives that exact sequence and fails against the previous commit.

The three earlier comments on this PR were addressed in the push before this one, and still stand after the rebase: CBT routes through PreviousTabStop, TBC calls ClearTabStop for both CSI g and CSI 3 g, and DECRC restores through SetCursorRaw when the saved position was pending a wrap, so the coordinate at X == Cols survives instead of being clamped onto the last cell.

@JohnCampionJr
JohnCampionJr merged commit 3271bfc into main Aug 29, 2026
2 checks passed
tomlm pushed a commit that referenced this pull request Aug 30, 2026
SetCursorRaw set PendingWrap = true on EVERY raw advance -- after every
printed character -- documented as harmless because "only the boundary
column reads it". #93 quietly broke that contract by adding two readers
that consult the flag anywhere: SettleForEditing (ICH/DCH/ECH) and the
CUB phantom-column adjustment. From then on, any CUB or DCH issued
immediately after printing acted one column LEFT of the cursor.

On screen that was the asciiquarium bug: duplicated fragments trailing
left-moving sprites (CUB off by one leaves the old cells unerased) and
characters eaten from right-moving ones (DCH deletes one cell early).
It looked rate- and machine-dependent, but it never was -- it needed a
print immediately followed by a relative-back or edit in the stream,
which curses emits as a cursor-motion optimisation, so it tracked what
the animation happened to draw. A 16-byte repro settles it:
ESC[5;1H AB ESC[1P eats the B.

The fix makes the flag state a fact instead of trusting its readers:
PendingWrap = x > _scrollRight, true exactly at the one-past-the-margin
position only a fill can produce. DECRC's explicit restore still runs
through SetPendingWrap afterwards, so the saved ambiguity survives.

Tests: the three repros (DCH after print, CUB after print, a stale flag
carried across a cursor address), plus two guards proving the fix does
not regress what the flag is FOR -- autowrap at the last column, and
SettleForEditing's fill-then-insert case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JohnCampionJr added a commit to JohnCampionJr/XTerm.NET that referenced this pull request Sep 1, 2026
Two findings, both worth acting on, one for a different reason than the
one given.

The API break is real as a type change and cannot break anyone: the
property arrived in tomlm#93 on 2026-08-29, and the latest release is v1.2
from 2026-08-26, so no published package has ever carried it. That makes
the answer easier rather than harder -- it is DECSC scratch a consumer
cannot do anything with, so it is internal now, and the shape of an
internal detail stops being an API question every time DECSC learns
something more. Nothing outside the assembly referenced it; the solution
builds, demos included.

The untested case was the sharper find. The three cases in the test move
DECNRCM AFTER the DECRC, so replaying a saved table would satisfy all
three -- the very contract the doc comment claims, that a restore
resolves against the mode as it is at restore time, had no test at all.

Both directions now, because they fail oppositely:

    French designated with NRC off, DECSC, NRC on,  DECRC  -> a-grave
    French designated with NRC on,  DECSC, NRC off, DECRC  -> @

On the pre-fix code those give @ and a-grave respectively -- each the
table that was saved rather than what the designation means at the
moment it is put back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnCampionJr added a commit that referenced this pull request Sep 1, 2026
* Save the designation at DECSC, not the table it resolved to

Recovered from #146, which was closed unmerged when the branch it was
stacked on went away with #141. The 96-set half of that PR landed
independently in 1f08d48; this half did not, and still reproduces on
6d32504.

DECSC saved the TABLE each G-set had resolved to. The screen after a
DECRC was therefore right and the state behind it was not: the
identifier stayed as whatever had been designated after the save, so the
next DECNRCM re-resolved the restored slot into that instead.

    ESC ( 0, DECSC, ESC ( R, DECRC   line drawing back, correct
    ...then DECNRCM                  letters -- re-resolved as French

    ESC - A, DECSC, ESC ) A, DECRC   Latin-1 back, correct
    ...then DECNRCM                  a pound sign -- the UK set

The first is older than the 96-set work; it arrived with the identifiers
themselves. The second is the identifier collision again, one save and
restore later: A is ISO Latin-1 in the 96-set space and the United
Kingdom set in the 94-set one, and the saved table records neither.

DECSC now saves the designation with the space it came from, and DECRC
resolves it against the mode state as it is then -- which is also the
right answer when DECNRCM moved between the two.

One Resolve answers "what does this designation mean now" for all three
callers that ask: the designation path, DECRC, and the DECNRCM refresh.
They resolved separately before, which is how two of them disagreed.

Every G-set is seeded to B rather than left absent, so a designation is
a value rather than a value-or-absent and the walks over the four -- the
save, the restore, the DECNRCM refresh -- are all total.

Also from Copilot's review on #146: the separate-space test designated
G0 for its UK half and G1 for its Latin-1 half, so the two differed by
two things rather than one. Both designate G1 and invoke it with SO now,
which also takes the last two literal control bytes out of the file.

The new test asserts the restore WITHOUT a mode change as well. That is
the part that was never broken, and a test that stopped there would pass
on the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address Copilot's review: narrow the property, cover the mode change

Two findings, both worth acting on, one for a different reason than the
one given.

The API break is real as a type change and cannot break anyone: the
property arrived in #93 on 2026-08-29, and the latest release is v1.2
from 2026-08-26, so no published package has ever carried it. That makes
the answer easier rather than harder -- it is DECSC scratch a consumer
cannot do anything with, so it is internal now, and the shape of an
internal detail stops being an API question every time DECSC learns
something more. Nothing outside the assembly referenced it; the solution
builds, demos included.

The untested case was the sharper find. The three cases in the test move
DECNRCM AFTER the DECRC, so replaying a saved table would satisfy all
three -- the very contract the doc comment claims, that a restore
resolves against the mode as it is at restore time, had no test at all.

Both directions now, because they fail oppositely:

    French designated with NRC off, DECSC, NRC on,  DECRC  -> a-grave
    French designated with NRC on,  DECSC, NRC off, DECRC  -> @

On the pre-fix code those give @ and a-grave respectively -- each the
table that was saved rather than what the designation means at the
moment it is put back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants