Subj : outcp() double-counts terminal columns on UTF-8 terminals To : GitLab issue in main/sbbs From : Rob Swindell Date : Sat Jul 25 2026 22:09:29 open https://gitlab.synchro.net/main/sbbs/-/issues/1200 `sbbs_t::outcp()` advances the terminal column counter twice for every codepoint it writes to a UTF-8 terminal, so the column counter runs ahead of the actual cursor position. ## The double-count `outcp()` (`src/sbbs3/con_out.cpp:800`) writes the codepoint with `term_out()` and *then* advances the column itself: ```cpp int sbbs_t::outcp(enum unicode_codepoint codepoint, const char* cp437_fallback) { if (term->charset() == CHARSET_UTF8) { char str[UTF8_MAX_LEN]; int len = utf8_putc(str, sizeof(str), codepoint); if (len < 1) return len; term_out(str, len); term->inc_column(unicode_width(codepoint, unicode_zerowidth)); // <-- second count return 0; } ... } ``` But `term_out()` → `parse_output()` → `Terminal::utf8_increment()` (`src/sbbs3/terminal.cpp:369-398`) has *already* counted it: ```cpp if (utf8_remain) return true; inc_column(unicode_width(static_cast(codepoint), 0)); ``` `utf8_increment()` runs for every UTF-8 terminal — it is gated on `flags_ & UTF8`, which is exactly the condition `charset() == CHARSET_UTF8` tests, and both `ANSI_Terminal::parse_output()` (`ansi_terminal.cpp:863`) and the base `Terminal::parse_output()` (`terminal.h:408`) call it. (PETSCII terminals can't be UTF-8: `flags_fixup()` clears `UTF8` when `PETSCII` is set.) `bputs()` with `P_UTF8` reaches `term_out()` by the same route and does *not* add its own `inc_column()`, so it produces the correct count — which makes it a convenient control. ## Reproducer [outcp_column_test.js](/uploads/e35955a507cc8b37abd97110a1337af5/outcp_column_test.js) Requires a terminal session with a UTF-8 charset (it bails out with an explanation otherwise); `jsexec` has no `console` object. It exits 0 when the two paths agree, so it doubles as a regression check. `console.wide()` is the only remaining JS path to `outcp()`. It emits U+FF21 FULLWIDTH LATIN CAPITAL LETTER A, display width 2. The control prints the identical three bytes via `console.print("\xEF\xBC\xA1", P_UTF8)` — same bytes on the wire, same glyph on screen, only the column counter differs: ``` outcp() column double-count test -------------------------------- terminal charset: UTF-8 Both lines below print the same glyph, via different code paths: A console.print(bytes, P_UTF8) column += 2 (expected 2) ok A console.wide('A') [via outcp] column += 4 (expected 2) *** DOUBLE-COUNTED *** ABCDEFGHIJ console.wide('ABCDEFGHIJ') column += 40 (expected 20) *** DOUBLE-COUNTED *** FAIL: outcp() reports 4 columns where bputs() reports 2 (ratio 2.0; a 10-char run reported 40 columns instead of 20) ``` ## Consequences The column counter feeds right-margin truncation, word-wrap, centering and the auto-pause line counter, so any line containing `outcp()`-emitted text mis-measures — wrapping or truncating early. A second symptom of the same line: `term_out()` returns early when `CON_ECHO_OFF` is set, but the `inc_column()` call is unconditional, so the column also advances for output that was suppressed. ## Exposure Since b17b1d3e3e (#1198 — Unicode @-codes now return their expansion rather than printing it), `outcp()` is reachable only via `sbbs_t::wide()`, itself reachable only from the `|W` (doubled) @-code format modifier and `console.wide()`. Before that commit every Unicode @-code — `@CHECKMARK@`, `@U+XXXX@`, `@ELLIPSIS@` and friends — went through it. ## Suggested fix Delete the `inc_column()` call in `outcp()` and let `utf8_increment()` be the single place that counts columns. The two sites don't agree on a second point, though: `outcp()` passes `unicode_zerowidth` to `unicode_width()` while `utf8_increment()` hardcodes `0`. `unicode_zerowidth` is the per-session quirk detected by the ZWNBSP probe at logon (`answer.cpp:668`, set to 1 when the terminal renders zero-width characters as one column). Deleting `outcp()`'s call alone would silently discard that detection, so `utf8_increment()` should pass `sbbs->unicode_zerowidth` instead of `0` (`unicode_zerowidth` is a public `sbbs_t` member and `Terminal` holds an `sbbs` pointer). Note that the second half of that change affects **all** UTF-8 output including `bputs()`, not just `outcp()`: on a terminal where the probe found `zerowidth == 1`, lines containing zero-width codepoints would start counting differently than they do today. The reproducer above won't catch that — U+FF21 isn't zero-width. — *Authored by Claude (Claude Code), on behalf of @rswindell* --- SBBSecho 3.37-Linux * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705) .