diff --git a/config/spotbugs/baseline.xml b/config/spotbugs/baseline.xml index 6dbb0b8f..738ae9c2 100644 --- a/config/spotbugs/baseline.xml +++ b/config/spotbugs/baseline.xml @@ -28677,61 +28677,7 @@ - - - - Field is a mutable array - - - li.cil.oc2.common.vm.terminal.color.TerminalColors.DIM_COLORS is a mutable array - - - - - - - - - At TerminalColors.java:[lines 5-71] - - - - - - In class li.cil.oc2.common.vm.terminal.color.TerminalColors - - - - - - - - - - - - In TerminalColors.java - - - - - - Field li.cil.oc2.common.vm.terminal.color.TerminalColors.DIM_COLORS - - - - - - - - - At TerminalColors.java:[line 16] - - - - - @@ -32967,4 +32913,141 @@ + + May expose internal representation by incorporating reference to mutable object + new li.cil.oc2.common.vm.terminal.escapes.osc.OSCHandler(Terminal) may expose internal representation by storing an externally mutable object into OSCHandler.terminal + + + At OSCHandler.java:[lines 13-15] + + In class li.cil.oc2.common.vm.terminal.escapes.osc.OSCHandler + + + + In method new li.cil.oc2.common.vm.terminal.escapes.osc.OSCHandler(Terminal) + + + + In OSCHandler.java + + Field li.cil.oc2.common.vm.terminal.escapes.osc.OSCHandler.terminal + + + Local variable named terminal + + + At OSCHandler.java:[line 14] + + + + + + May expose internal representation by returning reference to mutable object + li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.palette() may expose internal representation by returning TerminalDiff$Snapshot.palette + + + At TerminalDiff.java:[line 58] + + In class li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot + + + + In method li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.palette() + + + + In TerminalDiff.java + + Field li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.palette + + + At TerminalDiff.java:[line 58] + + + + + + May expose internal representation by incorporating reference to mutable object + new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) may expose internal representation by storing an externally mutable object into TerminalDiff$Snapshot.palette + + + At TerminalDiff.java:[line 58] + + In class li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot + + + + In method new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) + + + + In TerminalDiff.java + + Field li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.palette + + + Local variable named palette + + + At TerminalDiff.java:[line 58] + + + + + + May expose internal representation by incorporating reference to mutable object + new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) may expose internal representation by storing an externally mutable object into TerminalDiff$Snapshot.rowData + + + At TerminalDiff.java:[line 58] + + In class li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot + + + + In method new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) + + + + In TerminalDiff.java + + Field li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.rowData + + + Local variable named rowData + + + At TerminalDiff.java:[line 58] + + + + + + May expose internal representation by incorporating reference to mutable object + new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) may expose internal representation by storing an externally mutable object into TerminalDiff$Snapshot.rows + + + At TerminalDiff.java:[line 58] + + In class li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot + + + + In method new li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot(boolean, int, boolean, int[], byte[][], int, int, int, int, int, boolean, boolean, long, int[]) + + + + In TerminalDiff.java + + Field li.cil.oc2.common.vm.terminal.TerminalDiff$Snapshot.rows + + + Local variable named rows + + + At TerminalDiff.java:[line 58] + + + + diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java b/src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java index 8e969dd2..35025933 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java @@ -43,6 +43,18 @@ public class Terminal { twoFiftySixColor, backgroundColor, foregroundColor; + // Per-instance 256-color palette: OSC 4 redefines an entry, OSC 104 resets it. Each + // Terminal holds its own copy so a palette change never bleeds across terminals (the + // static defaults stay immutable). Transient — ceres re-inits via the no-arg + // constructor -> RIS -> getDefaultPalette256(), same lifecycle as the buffers. + public transient int[] palette256; + // Monotonic revision bumped on every palette256 write (RIS/OSC4/OSC104). The network diff + // ships the palette to clients only when this differs from lastSentPaletteRevision, so a + // server-side OSC 4 redefinition actually reaches the player's screen (the render path + // reads the client Terminal's palette256). Transient: a freshly-loaded terminal starts at + // revision 0 with the default palette; runtime mutations sync via the diff, not persistence. + private transient int paletteRevision = 0; + private transient int lastSentPaletteRevision = -1; public byte style; public int SCROLL_BACK_COUNT = 20; @@ -366,6 +378,41 @@ public NetworkDirty consumeNetworkDirty() { } } + /** + * Bump the palette revision so the next network diff ships the palette to clients. Called + * wherever palette256 is written (RIS/OSC4/OSC104). Under networkDirtyLock to keep the + * revision check in consumePaletteDirty atomic with the bump. + */ + public void markPaletteDirty() { + networkDirtyLock.lock(); + try { + paletteRevision++; + } finally { + networkDirtyLock.unlock(); + } + } + + /** + * Returns a clone of palette256 to ship to clients if it changed since the last diff, or + * null if unchanged (zero steady-state cost). {@code force} is set by the reset path + * (captureFull) so a RIS reset snapshot always carries the palette even when the revision + * hasn't moved — otherwise a client that missed an earlier change would keep a stale one. + * Updates lastSentPaletteRevision atomically. + */ + @SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull") // null is a load-bearing sentinel: the Snapshot record + stream codec use it to mean "palette unchanged this diff" (skip the ~1 KiB payload). An empty array can't express absence, and Optional allocates on the hot path. + public int[] consumePaletteDirty(final boolean force) { + networkDirtyLock.lock(); + try { + if (!force && paletteRevision == lastSentPaletteRevision) { + return null; + } + lastSentPaletteRevision = paletteRevision; + return palette256.clone(); + } finally { + networkDirtyLock.unlock(); + } + } + @OnlyIn(Dist.CLIENT) public void clientTick() { client().clientTick(); diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java b/src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java index d7488a10..92688778 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java @@ -31,6 +31,7 @@ * single-character echo therefore costs a handful of bytes per changed row instead of the * former fixed 37 bytes per cell. */ +@SuppressWarnings("PMD.CyclomaticComplexity") // class-aggregate complexity is high because the diff touches every facet of terminal state (rows, cursor, modes, bell, palette); each is a small focused method public final class TerminalDiff { // Attribute byte: which non-default fields follow the codepoint. private static final int ATTR_FG_EXPLICIT = 1; @@ -67,7 +68,8 @@ public record Snapshot( int cursorMode, boolean cursorVisible, boolean bell, - long inputModes) {} + long inputModes, + int[] palette) {} /** * Private-mode flags that affect client-side rendering or input handling beyond the @@ -127,20 +129,25 @@ private static void applyInputModes(final PrivateModeState state, final long bit public static Snapshot capture(final Terminal terminal) { final Terminal.NetworkDirty dirty = terminal.consumeNetworkDirty(); final boolean full = dirty.fullRefresh(); - return build(terminal, full, full ? visibleWindowRows(terminal) : dirty.rows()); + return build(terminal, full, false, full ? visibleWindowRows(terminal) : dirty.rows()); } /** Builds a full-screen snapshot flagged as reset (used after VM restarts / RIS). */ public static Snapshot captureFull(final Terminal terminal) { - return build(terminal, true, visibleWindowRows(terminal)); + return build(terminal, true, true, visibleWindowRows(terminal)); } - private static Snapshot build(final Terminal terminal, final boolean reset, final int... rows) { + private static Snapshot build(final Terminal terminal, final boolean reset, final boolean forcePalette, final int... rows) { final boolean alt = terminal.currentPrivateModeState.isAltBufferEnabled(); // Consume the bell flag: it must fire exactly once per emitted diff, otherwise // every subsequent diff would replay the bell until the next one arrives. final boolean bell = terminal.hasPendingBell; terminal.hasPendingBell = false; + // Ship the palette when it changed since the last diff, or always on a reset snapshot — + // captureFull after RIS, but ALSO any full-refresh capture (reset == full): a client + // that missed the original OSC 4 change (opened/tracked the computer later) rebuilds + // its screen from that snapshot and must not render a stale default palette. + final int[] palette = terminal.consumePaletteDirty(forcePalette || reset); return new Snapshot( reset, terminal.width, @@ -154,7 +161,8 @@ private static Snapshot build(final Terminal terminal, final boolean reset, fina terminal.cursorMode, terminal.currentPrivateModeState.DECTCEM, bell, - packInputModes(terminal.currentPrivateModeState)); + packInputModes(terminal.currentPrivateModeState), + palette); } private static int[] visibleWindowRows(final Terminal terminal) { @@ -314,6 +322,11 @@ public static void apply(final Terminal terminal, final Snapshot s) { if (s.bell()) { terminal.hasPendingBell = true; } + // Apply a synced palette (clone so the client's array stays independent of the server's, + // matching the per-instance discipline). Null = unchanged this diff. + if (s.palette() != null) { + terminal.palette256 = s.palette().clone(); + } terminal.markAllDirty(); } @@ -451,6 +464,11 @@ private static void writeSnapshot(final Snapshot s, final ByteBuf buf) { buf.writeBoolean(s.cursorVisible()); buf.writeBoolean(s.bell()); ByteBufCodecs.VAR_LONG.encode(buf, s.inputModes()); + final int[] palette = s.palette(); + buf.writeBoolean(palette != null); + if (palette != null) { + writeByteArray(buf, encodeInts(palette)); + } } private static Snapshot readSnapshot(final ByteBuf buf) { @@ -463,20 +481,32 @@ private static Snapshot readSnapshot(final ByteBuf buf) { for (int i = 0; i < rowCount; i++) { rowData[i] = readByteArray(buf); } + final int cursorX = ByteBufCodecs.VAR_INT.decode(buf); + final int cursorY = ByteBufCodecs.VAR_INT.decode(buf); + final int lastRowToDisplay = ByteBufCodecs.VAR_INT.decode(buf); + final int lastRowToDisplayMax = ByteBufCodecs.VAR_INT.decode(buf); + final int cursorMode = ByteBufCodecs.VAR_INT.decode(buf); + final boolean cursorVisible = buf.readBoolean(); + final boolean bell = buf.readBoolean(); + final long inputModes = ByteBufCodecs.VAR_LONG.decode(buf); + // Palette is written LAST (after inputModes) — read it last or every field above + // decodes from the wrong offset. Repro: CodecRoundTripReproTest. + final int[] palette = buf.readBoolean() ? decodeInts(readByteArray(buf)) : null; return new Snapshot( reset, width, altBuffer, rows, rowData, - ByteBufCodecs.VAR_INT.decode(buf), - ByteBufCodecs.VAR_INT.decode(buf), - ByteBufCodecs.VAR_INT.decode(buf), - ByteBufCodecs.VAR_INT.decode(buf), - ByteBufCodecs.VAR_INT.decode(buf), - buf.readBoolean(), - buf.readBoolean(), - ByteBufCodecs.VAR_LONG.decode(buf)); + cursorX, + cursorY, + lastRowToDisplay, + lastRowToDisplayMax, + cursorMode, + cursorVisible, + bell, + inputModes, + palette); } private static void writeByteArray(final ByteBuf buf, final byte[] data) { diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java b/src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java index ff2d4d51..8f63ab90 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java @@ -3,22 +3,46 @@ import com.google.gson.annotations.SerializedName; public final class TerminalColors { - public static final int[] BRIGHT_COLORS = { + // The palette tables below are private immutable defaults: each Terminal holds its own + // per-instance copy (Terminal.palette256) so OSC 4/104 redefines a color for one terminal + // only. Kept private so external code can't mutate the shared default (SpotBugs + // MS_MUTABLE_ARRAY, resolved by design rather than suppression). + private static final int[] BRIGHT_COLORS = { 0x555555, 0xFF5555, 0x55FF55, 0xFFFF55, 0x5555FF, 0xFF55FF, 0x55FFFF, 0xFFFFFF, }; - public static final int[] COLORS = { + private static final int[] COLORS = { 0x000000, 0xAA0000, 0x00AA00, 0xAAAA00, 0x0000AA, 0xAA00AA, 0x00AAAA, 0xAAAAAA, }; - public static final int[] DIM_COLORS = { - 0x000000, 0x550000, 0x005500, 0x555500, - 0x000055, 0x550055, 0x005555, 0x555555, - }; + // Scale applied to a resolved foreground color when STYLE_DIM (SGR 2, faint) is set. This + // is a computed modifier layered on AFTER color resolution, matching xterm's computeFaint + // (util.c) in mechanism — xterm scales the resolved pixel rather than indexing a fixed dim + // table — so dim composes with bold/blink/256/truecolor instead of overriding them. The 1/2 + // factor reproduces the prior DIM_COLORS table exactly for SIXTEEN_COLOR (0xAA0000 -> 0x550000 + // etc.), so existing dim text is unchanged; what's new is dim now also applies to bright, + // 256-color, and truecolor cells, which the table silently ignored. DEC VTs (VT100-VT420) + // have no faint attribute at all — SGR 2 is an ISO/ECMA extension outside real-VT scope, so + // xterm (the de-facto reference for such extensions) is the guide; the 1/2 scale is a + // deliberate out-of-box choice, xterm's default is 2/3 (faintIsRelative=false). + private static final int DIM_FACTOR_NUMERATOR = 1; + private static final int DIM_FACTOR_DENOMINATOR = 2; + + /** + * Scale a resolved 0xRRGGBB color for SGR 2 (faint/dim). Applied as a tail modifier in the + * render path, after the color mode and bold/blink resolution, so dim composes with every + * other attribute rather than replacing it. + */ + public static int computeFaint(final int rgb) { + final int r = ((rgb >> 16) & 0xFF) * DIM_FACTOR_NUMERATOR / DIM_FACTOR_DENOMINATOR; + final int g = ((rgb >> 8) & 0xFF) * DIM_FACTOR_NUMERATOR / DIM_FACTOR_DENOMINATOR; + final int b = (rgb & 0xFF) * DIM_FACTOR_NUMERATOR / DIM_FACTOR_DENOMINATOR; + return (r << 16) | (g << 8) | b; + } - public static final int[] COLORS_256 = { + private static final int[] COLORS_256 = { // 0-7: Normal ANSI colors (must match COLORS) 0x000000, 0xAA0000, 0x00AA00, 0xAAAA00, 0x0000AA, 0xAA00AA, 0x00AAAA, 0xAAAAAA, // 8-15: Bright ANSI colors (must match BRIGHT_COLORS) @@ -55,6 +79,36 @@ public final class TerminalColors { 0xa8a8a8, 0xb2b2b2, 0xbcbcbc, 0xc6c6c6, 0xd0d0d0, 0xdadada, 0xe4e4e4, 0xeeeeee }; + // Fresh copy of the default 256-color palette. Each Terminal initializes its own + // per-instance palette256 from this (so OSC 4 redefines a color for one terminal + // only) and resets it via OSC 104. + public static int[] getDefaultPalette256() { + return COLORS_256.clone(); + } + + // Fresh copies of the default ANSI-16 tables. The 256-color palette is canonical — + // COLORS_256[0..7] == COLORS, [8..15] == BRIGHT_COLORS — so these exist for tests that + // cross-check the 16 against the 256, and for any code reading the base-16 defaults + // without a Terminal instance. Each returns a defensive clone (the arrays are private). + public static int[] getDefaultColors16() { + return COLORS.clone(); + } + + public static int[] getDefaultBrightColors16() { + return BRIGHT_COLORS.clone(); + } + + // Fixed defaults for the DEFAULT_FOREGROUND/BACKGROUND render modes. These must NOT + // track OSC 4 — in xterm the default fg/bg are addressed by OSC 10/11, not OSC 4 — so + // they read the immutable defaults directly, never the instance palette. + public static int defaultForegroundRgb(final boolean bold) { + return bold ? BRIGHT_COLORS[Color.WHITE] : COLORS[Color.WHITE]; + } + + public static int defaultBackgroundRgb() { + return 0x000000; + } + public static final ColorData DEFAULT_BACKGROUND_COLOR = new ColorData(Color.WHITE, Color.BLACK, 0, ColorMode.DEFAULT_BACKGROUND); public static final ColorData DEFAULT_FOREGROUND_COLOR = diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/index/RIS.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/index/RIS.java index a480ae70..4f49f043 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/index/RIS.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/index/RIS.java @@ -15,6 +15,8 @@ public static void execute(Terminal terminal) { terminal.backgroundColor = TerminalColors.DEFAULT_TRUE_COLOR_BACKGROUND.copy(); terminal.foregroundColor = TerminalColors.DEFAULT_TRUE_COLOR_FOREGROUND.copy(); terminal.twoFiftySixColor = TerminalColors.DEFAULT_256_COLORS.copy(); + terminal.palette256 = TerminalColors.getDefaultPalette256(); + terminal.markPaletteDirty(); terminal.style = TerminalColors.DEFAULT_STYLE; terminal.currentModeState = new ModeState(); terminal.currentPrivateModeState = new PrivateModeState(); diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC104.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC104.java new file mode 100644 index 00000000..fbe7d204 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC104.java @@ -0,0 +1,35 @@ +package li.cil.oc2.common.vm.terminal.escapes.osc; + +import li.cil.oc2.common.vm.terminal.Terminal; +import li.cil.oc2.common.vm.terminal.color.TerminalColors; + +// OSC 104;Ps;... — reset palette entries to their defaults. No Ps resets the whole palette. +// OSC 104 is a set/reset, not a query — no reply. Multiple indices may follow (OSC 104;Ps1;Ps2). +class OSC104 extends OSCHandler { + OSC104(Terminal terminal) { + super(terminal); + } + + @Override + public void execute(final String payload, final char terminator) { + if (payload.isEmpty()) { + // No Ps -> reset the whole palette to defaults. + terminal.palette256 = TerminalColors.getDefaultPalette256(); + terminal.markPaletteDirty(); + return; + } + final int[] defaults = TerminalColors.getDefaultPalette256(); + final String[] parts = payload.split(";", -1); + boolean changed = false; + for (final String part : parts) { + final int ps = OSCParse.parseClampIndex(part); + if (ps >= 0) { + terminal.palette256[ps] = defaults[ps]; + changed = true; + } + } + if (changed) { + terminal.markPaletteDirty(); + } + } +} diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC4.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC4.java new file mode 100644 index 00000000..4ea29e5e --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC4.java @@ -0,0 +1,52 @@ +package li.cil.oc2.common.vm.terminal.escapes.osc; + +import li.cil.oc2.common.vm.terminal.Terminal; + +// OSC 4;Ps;Pt — set (Pt = rgb:rr/gg/bb) or query (Pt = ?) palette entry Ps (0-255). The +// per-instance palette lives on Terminal.palette256, so a redefined color never bleeds across +// terminals. A single OSC 4 may carry several index;spec pairs (c;spec;c;spec;...), as xterm's +// ChangeAnsiColorRequest (misc.c:2981) does — the loop stops on the first malformed entry. +// Replies use OSC framing (ESC ] ... terminator), mirroring the query's own terminator +// (BEL or ST), and report 16-bit per channel (rgb:%04x/%04x/%04x) as xterm does (misc.c:2647). +class OSC4 extends OSCHandler { + OSC4(Terminal terminal) { + super(terminal); + } + + @Override + public void execute(final String payload, final char terminator) { + // payload = "Ps;Pt[;Ps;Pt]..." (everything after "4;"). Walk index;spec pairs; a bare + // "Ps" with no Pt is a no-op (query requires an explicit "?"). + final String[] parts = payload.split(";", -1); + for (int i = 0; i + 1 < parts.length; i += 2) { + final int ps = OSCParse.parseClampIndex(parts[i]); + if (ps < 0) { + return; // quit on any error, matching xterm's ChangeAnsiColorRequest. + } + final String pt = parts[i + 1]; + if ("?".equals(pt)) { + replyQuery(ps, terminator); + } else { + final Integer rgb = OSCParse.parseRgbSpec(pt); + if (rgb == null) { + return; // malformed spec -> stop on any error, matching xterm's ChangeAnsiColorRequest. + } + terminal.palette256[ps] = rgb; + terminal.markPaletteDirty(); + } + } + } + + // Reply with the current entry as OSC 4;Ps;rgb:rrrr/gggg/bbbb . xterm reports + // 16-bit per channel (XColor.red is 16-bit; an 8-bit palette value is duplicated into the + // high and low bytes), and reuses the query's own terminator (BEL or ST). + private void replyQuery(final int ps, final char terminator) { + final int rgb = terminal.palette256[ps]; + final int r16 = ((rgb >> 16) & 0xFF) * 0x0101; // duplicate 8-bit value into 16-bit + final int g16 = ((rgb >> 8) & 0xFF) * 0x0101; + final int b16 = (rgb & 0xFF) * 0x0101; + final String term = terminator == '\007' ? "\007" : "\033\\"; + terminal.io.putResponse(String.format( + "\033]4;%d;rgb:%04x/%04x/%04x%s", ps, r16, g16, b16, term)); + } +} diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCHandler.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCHandler.java new file mode 100644 index 00000000..13b97561 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCHandler.java @@ -0,0 +1,23 @@ +package li.cil.oc2.common.vm.terminal.escapes.osc; + +import li.cil.oc2.common.vm.terminal.Terminal; + +// One OSC handler per numeric code, mirroring the CSISequenceHandler/CSIManager split: the +// manager accumulates the payload (OSC is an opaque string terminated by ST/BEL, so it is +// only parseable whole, not streaming) and routes it by code; each handler owns its own +// argument parsing. The dispatch table in OSCManager is the addable surface — a new OSC is a +// new class plus one registration, not a switch arm grown inside the manager. +public abstract class OSCHandler { + protected final Terminal terminal; + + public OSCHandler(Terminal terminal) { + this.terminal = terminal; + } + + // Act on an OSC payload. {@code payload} is everything after the numeric code and its + // separating {@code ;}; for a code with no parameters it is the empty string. + // {@code terminator} is the byte that ended the sequence — BEL (0x07) or the backslash of + // ST (0x5C) — so a handler that replies (e.g. OSC 4 query) can mirror the query's framing, + // matching xterm's unparseputc1(xw, final) (misc.c:2655). + public abstract void execute(String payload, char terminator); +} diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCManager.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCManager.java index f479e17a..5112f523 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCManager.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCManager.java @@ -1,24 +1,82 @@ package li.cil.oc2.common.vm.terminal.escapes.osc; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import li.cil.oc2.common.vm.terminal.Terminal; +// OSC (Operating System Command, ESC ] ... ST) dispatch. Bytes arrive one at a time from +// TerminalOutput once the state machine enters the OSC state; this accumulates the payload +// and, on the ST (ESC \) or BEL terminator, routes it by numeric code to a per-code handler. +// +// OSC is an opaque string terminated by ST/BEL, so — unlike CSI — it is only parseable whole, +// not streaming: the manager buffers the full payload and hands it to the handler on +// termination (xterm accumulates the same way). The handler-per-code split mirrors +// CSISequenceHandler/CSIManager: a new OSC is a new OSCHandler subclass plus one registration, +// not a switch arm grown inside the manager. Implemented: OSC 4 (set/query palette entry) and +// OSC 104 (reset palette entries); the table leaves OSC 0/2/8/10/11 as addable follow-ups. public class OSCManager { + // Bounds the payload buffer so a stream that never terminates can't grow unbounded; 1024 + // is well past any OSC 4/104 payload (a set is ~20 chars). xterm caps similarly. A char[] + // (not a StringBuilder field) is used because OSCManager lives as long as its Terminal + // (PMD AvoidStringBufferField). + private static final int BUFFER_CAP = 1024; private final Terminal terminal; + private final Map handlers = new ConcurrentHashMap<>(); + private final char[] buffer = new char[BUFFER_CAP]; + private int bufferLength = 0; private int lastChar = '\0'; public OSCManager(Terminal terminal) { this.terminal = terminal; + handlers.put(4, new OSC4(terminal)); + handlers.put(104, new OSC104(terminal)); } public void handle(int ch) { + // ST terminator: ESC followed by '\'. BEL terminator: 0x07. The ESC that begins ST is + // armed via lastChar (below) but never buffered, so the payload stays clean. The final + // byte (BEL 0x07, or the '\' of ST 0x5C) is passed to processSequence so a replying + // handler can mirror the query's framing — xterm's unparseputc1(xw, final) (misc.c:2655). if ((lastChar == '\033' && ch == '\\') || ch == '\007') { + processSequence(new String(buffer, 0, bufferLength), (char) ch); + bufferLength = 0; + lastChar = '\0'; terminal.state = Terminal.State.NORMAL; - } else { + return; + } + if (ch == '\033') { + // Start of the ST terminator — arm lastChar, don't buffer it. lastChar = ch; + return; + } + if (bufferLength < BUFFER_CAP) { + buffer[bufferLength++] = (char) ch; } + lastChar = ch; } public void reset() { lastChar = '\0'; + bufferLength = 0; + } + + private void processSequence(final String sequence, final char terminator) { + if (sequence.isEmpty()) { + return; + } + // The OSC code is the prefix up to the first ';' (e.g. "4" in "4;16;rgb:..."). The rest + // is the handler's payload. The code is parsed without the palette-index clamp (OSC codes + // are unbounded — OSC 777/1337 must dispatch, not silently die at the 0-255 palette edge); + // palette entries themselves are clamped inside the handlers via OSCParse.parseClampIndex. + final int sep = sequence.indexOf(';'); + final int code = OSCParse.parseCode(sep < 0 ? sequence : sequence.substring(0, sep)); + if (code < 0) { + return; + } + final OSCHandler handler = handlers.get(code); + if (handler != null) { + handler.execute(sep < 0 ? "" : sequence.substring(sep + 1), terminator); + } + // Unknown code (OSC 0/2/8/10/11 etc.) -> no-op until the follow-up bundle. } -} \ No newline at end of file +} diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCParse.java b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCParse.java new file mode 100644 index 00000000..8a28fb68 --- /dev/null +++ b/src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSCParse.java @@ -0,0 +1,53 @@ +package li.cil.oc2.common.vm.terminal.escapes.osc; + +// Shared parsing helpers for OSC handlers. Kept in one place because OSC 4 (set) and the +// future OSC 10/11 (default fg/bg) both speak the same rgb:rr/gg/bb spec, and the palette +// index clamp is common to OSC 4 and OSC 104. +final class OSCParse { + private OSCParse() { + } + + // Parse a non-negative OSC code (unbounded — OSC 777/1337 must dispatch, not die at the + // 0-255 palette edge). -1 if non-numeric or negative. Used for the routing code only; + // palette entries use parseClampIndex. + static int parseCode(final String s) { + try { + final int v = Integer.parseInt(s); + return v >= 0 ? v : -1; + } catch (NumberFormatException ignored) { + return -1; + } + } + + // Parse a non-negative int in 0-255 (a palette index); -1 if non-numeric or out of range. + // xterm ignores malformed OSC rather than erroring, so callers treat -1 as a silent no-op. + static int parseClampIndex(final String s) { + try { + final int v = Integer.parseInt(s); + return (v >= 0 && v <= 255) ? v : -1; + } catch (NumberFormatException ignored) { + return -1; + } + } + + // Parse "rgb:rr/gg/bb" (2 hex digits per channel) into a packed 0xRRGGBB int; null if + // malformed. #rrggbb shorthand and 4-/16-bit rgb variants are out of scope (xterm extends + // to those; the palette is 8-bit per channel so 2 digits suffice). + static Integer parseRgbSpec(final String pt) { + if (!pt.startsWith("rgb:")) { + return null; + } + final String[] channels = pt.substring(4).split("/", -1); + if (channels.length != 3) { + return null; + } + try { + final int r = Integer.parseInt(channels[0], 16) & 0xFF; + final int g = Integer.parseInt(channels[1], 16) & 0xFF; + final int b = Integer.parseInt(channels[2], 16) & 0xFF; + return (r << 16) | (g << 8) | b; + } catch (NumberFormatException ignored) { + return null; + } + } +} diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/render/TerminalCharRenderer.java b/src/main/java/li/cil/oc2/common/vm/terminal/render/TerminalCharRenderer.java index 28dcb6ee..73a66a9b 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/render/TerminalCharRenderer.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/render/TerminalCharRenderer.java @@ -59,22 +59,22 @@ private static int getForegroundColor(final Terminal terminal, // NOPMD: data-dr final boolean isDim = (style & Terminal.STYLE_DIM_MASK) != 0; // Bold blink alternates normal/bright intensity instead of on/off. final boolean dimBoldForBlink = isBlinking && !invertBackground && blinkOff && isBold; - final int[] palette = isDim ? TerminalColors.DIM_COLORS : TerminalColors.COLORS; - return switch (color.Mode) { - case DEFAULT_FOREGROUND -> - (isDim ? TerminalColors.DIM_COLORS - : (isBold && !dimBoldForBlink) ? TerminalColors.BRIGHT_COLORS - : TerminalColors.COLORS)[TerminalColors.Color.WHITE]; - case SIXTEEN_COLOR -> palette[foregroundChannel(color, invertBackground)]; - case TWO_FIFTY_SIX_COLOR -> - TerminalColors.COLORS_256[foregroundChannel(color, invertBackground)]; + final int channel = foregroundChannel(color, invertBackground); + final int rgb = switch (color.Mode) { + // DEFAULT_FOREGROUND must not track OSC 4 (xterm reserves it for OSC 10/11). + case DEFAULT_FOREGROUND -> TerminalColors.defaultForegroundRgb(isBold && !dimBoldForBlink); + case SIXTEEN_COLOR -> terminal.palette256[channel]; + case TWO_FIFTY_SIX_COLOR -> terminal.palette256[channel]; case TRUE_COLOR -> color.toInt(); + // Bright ANSI (8-15) live at palette256[8..15]; dimBoldForBlink drops back to normal. case SIXTEEN_COLOR_BRIGHT -> - (dimBoldForBlink ? TerminalColors.COLORS : TerminalColors.BRIGHT_COLORS) - [foregroundChannel(color, invertBackground)]; - case DEFAULT_BACKGROUND -> 0x000000; + terminal.palette256[channel + (dimBoldForBlink ? 0 : 8)]; + case DEFAULT_BACKGROUND -> TerminalColors.defaultBackgroundRgb(); default -> throw new AssertionError(color.Mode); }; + // Dim (SGR 2) is a tail modifier on the resolved color — composes with bold/blink and + // now applies to every mode (the old fixed DIM_COLORS table only covered SIXTEEN_COLOR). + return isDim ? TerminalColors.computeFaint(rgb) : rgb; } private static ColorData selectColor( diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalBackgroundRenderer.java b/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalBackgroundRenderer.java index cde8e14d..aa1e4cfe 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalBackgroundRenderer.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalBackgroundRenderer.java @@ -34,7 +34,7 @@ public static void renderBackground(final Terminal terminal, // NOPMD: data-driv final boolean blinkOff = isBlinking && Math.floorMod(System.currentTimeMillis() + terminal.hashCode(), 1000) > 500; final ColorData color = resolveColor(terminal, useAltBuffer, index, invertBackground); - int background = resolveBackground(style, color, invertBackground, isBold, isBlinking, blinkOff); + int background = resolveBackground(terminal, color, invertBackground, isBold, isBlinking, blinkOff); // When the background blinks (inverted + blink), suppress it on the off phase // (non-bold only — bold blink alternates normal/bright instead). if (isBlinking && invertBackground && blinkOff && !isBold) { @@ -62,28 +62,26 @@ private static ColorData resolveColor( : useAltBuffer ? terminal.altColors[index] : terminal.colors[index]; } - private static int resolveBackground(final byte style, // NOPMD: data-driven color-mode switch (bold-bright, blink) + private static int resolveBackground(final Terminal terminal, // NOPMD: data-driven color-mode switch (bold-bright, blink) final ColorData color, final boolean invertBackground, final boolean isBold, final boolean isBlinking, final boolean blinkOff) { - final int[] palette = - (style & Terminal.STYLE_DIM_MASK) != 0 - ? TerminalColors.DIM_COLORS - : TerminalColors.COLORS; // Bold blink alternates normal/bright intensity instead of on/off. final boolean dimBoldForBlink = isBlinking && invertBackground && blinkOff && isBold; + final int channel = backgroundChannel(color, invertBackground); + // Note: SGR 2 (faint/dim) is NOT applied to the background — xterm's getXtermBackground + // (util.c) consumes ATR_FAINT only in the foreground path. The prior code dimmed the + // SIXTEEN_COLOR background, a divergence; dropped here to match xterm. return switch (color.Mode) { - case SIXTEEN_COLOR -> palette[backgroundChannel(color, invertBackground)]; - case TWO_FIFTY_SIX_COLOR -> - TerminalColors.COLORS_256[backgroundChannel(color, invertBackground)]; + case SIXTEEN_COLOR -> terminal.palette256[channel]; + case TWO_FIFTY_SIX_COLOR -> terminal.palette256[channel]; case TRUE_COLOR -> color.toInt(); + // Bright ANSI (8-15) live at palette256[8..15]; dimBoldForBlink drops back to normal. case SIXTEEN_COLOR_BRIGHT -> - (dimBoldForBlink ? TerminalColors.COLORS : TerminalColors.BRIGHT_COLORS) - [backgroundChannel(color, invertBackground)]; - case DEFAULT_BACKGROUND -> 0x000000; - case DEFAULT_FOREGROUND -> - ((isBold && !dimBoldForBlink) ? TerminalColors.BRIGHT_COLORS - : TerminalColors.COLORS)[TerminalColors.Color.WHITE]; + terminal.palette256[channel + (dimBoldForBlink ? 0 : 8)]; + case DEFAULT_BACKGROUND -> TerminalColors.defaultBackgroundRgb(); + // DEFAULT_FOREGROUND must not track OSC 4 (xterm reserves it for OSC 10/11). + case DEFAULT_FOREGROUND -> TerminalColors.defaultForegroundRgb(isBold && !dimBoldForBlink); default -> throw new AssertionError(color.Mode); }; } diff --git a/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalCursorRenderer.java b/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalCursorRenderer.java index 13362834..fe30f031 100644 --- a/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalCursorRenderer.java +++ b/src/main/java/li/cil/oc2/common/vm/terminal/render/overlay/TerminalCursorRenderer.java @@ -44,9 +44,11 @@ public static void renderCursor(final Terminal terminal, final PoseStack stack) Tesselator.getInstance() .begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + // Cursor color is fixed — it does not track OSC 4 (xterm reserves cursor color for + // OSC 12): default foreground normally, default background (black) under DECSCNM. final int foreground = terminal.currentPrivateModeState.DECSCNM - ? TerminalColors.COLORS[TerminalColors.Color.BLACK] - : TerminalColors.COLORS[TerminalColors.Color.WHITE]; + ? TerminalColors.defaultBackgroundRgb() + : TerminalColors.defaultForegroundRgb(false); final float r = ((foreground >> 16) & 0xFF) / 255f; final float g = ((foreground >> 8) & 0xFF) / 255f; final float b = (foreground & 0xFF) / 255f; diff --git a/src/test/java/li/cil/oc2/common/vm/terminal/TerminalBufferTest.java b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalBufferTest.java index 6e392814..f56fb2bd 100644 --- a/src/test/java/li/cil/oc2/common/vm/terminal/TerminalBufferTest.java +++ b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalBufferTest.java @@ -31,6 +31,13 @@ public class TerminalBufferTest { // AvoidDuplicateLiterals threshold. private static final String ESC = "\u001b"; private static final String CSI = ESC + "["; + private static final String OSC = ESC + "]"; + private static final String BEL = "\u0007"; + private static final String ST = ESC + "\\"; + // A reusable OSC 4 set (entry 16 -> red, BEL-terminated) shared across the palette tests so + // the rgb spec literal stays below PMD's AvoidDuplicateLiterals threshold. + private static final String RED_RGB = "rgb:ff/00/00"; + private static final String OSC4_SET_16_RED = OSC + "4;16;" + RED_RGB + BEL; private static final String SAMPLE_LINE = "ABCDEFGH"; private static final String MARGIN_CONTENT = "ABCDEFG"; @@ -156,6 +163,134 @@ void dsrExplicitFiveStillRepliesStatus() { "CSI 5n replies operating-status (\\033[0n)"); } + @Test + void osc4SetChangesInstancePalette() { + // OSC 4;Ps;Pt redefines palette entry Ps. Setting index 16 (first cube color) to red + // must update the per-instance palette256, leaving the immutable default untouched. + write(terminal, OSC4_SET_16_RED); + assertEquals(0xff0000, terminal.palette256[16], + "OSC 4 set must change the per-instance palette entry"); + assertNotEquals(TerminalColors.getDefaultPalette256()[16], terminal.palette256[16], + "the changed entry must differ from the immutable default"); + } + + @Test + void osc4SetStTerminatorWorks() { + // ST-terminated (ESC then backslash) instead of BEL — exercises the two-byte + // terminator: the ESC must arm lastChar without being buffered, then the backslash + // byte completes ST (the broken-ST-detection bug site called out in the plan). + write(terminal, OSC + "4;17;rgb:00/ff/00" + ST); + assertEquals(0x00ff00, terminal.palette256[17], + "OSC 4 set with the ST terminator must take effect"); + } + + @Test + void osc4QueryRepliesRgb() { + // OSC 4;Ps;? queries entry Ps. xterm replies 16-bit per channel (rgb:%04x) and reuses the + // query's own terminator (misc.c:2647,2655): a BEL-terminated query gets a BEL-terminated + // reply. Index 16 was set to 0xff0000 (red) -> r16=0xffff, g/b=0x0000. + write(terminal, OSC4_SET_16_RED); + write(terminal, OSC + "4;16;?" + BEL); + final ByteBuffer reply = terminal.io.getInput(); + assertNotNull(reply, "OSC 4 query must produce a reply"); + final byte[] bytes = new byte[reply.remaining()]; + reply.get(bytes); + assertEquals(ESC + "]4;16;rgb:ffff/0000/0000" + BEL, + new String(bytes, StandardCharsets.US_ASCII), + "OSC 4 query replies 16-bit per channel, mirroring the BEL terminator"); + } + + @Test + void osc4QueryRepliesWithStTerminator() { + // The same query ST-terminated must reply ST-terminated (ESC \), not BEL — the reply + // mirrors the query's framing (F2: xterm unparseputc1(xw, final)). + write(terminal, OSC4_SET_16_RED); + write(terminal, OSC + "4;16;?" + ST); + final ByteBuffer reply = terminal.io.getInput(); + assertNotNull(reply); + final byte[] bytes = new byte[reply.remaining()]; + reply.get(bytes); + assertEquals(ESC + "]4;16;rgb:ffff/0000/0000" + ST, + new String(bytes, StandardCharsets.US_ASCII), + "an ST-terminated query replies ST-terminated"); + } + + @Test + void osc4BatchedSetsMultipleEntries() { + // A single OSC 4 may carry several index;spec pairs (c;spec;c;spec;...), as xterm's + // ChangeAnsiColorRequest (misc.c:2981) does. Both entries must be set, not just the first. + write(terminal, OSC + "4;16;rgb:ff/00/00;17;rgb:00/ff/00" + BEL); + assertEquals(0xff0000, terminal.palette256[16], "first pair (index 16) set"); + assertEquals(0x00ff00, terminal.palette256[17], "second pair (index 17) set"); + } + + @Test + void osc4BatchedSetAndQueryInOneSequence() { + // The batched loop handles mixed set+query pairs: a query pair replies, then a set pair + // applies — both in one OSC 4. + write(terminal, OSC + "4;16;?;17;rgb:00/ff/00" + BEL); + assertEquals(0x00ff00, terminal.palette256[17], "the set pair applied"); + final ByteBuffer reply = terminal.io.getInput(); + assertNotNull(reply, "the query pair replied"); + final byte[] bytes = new byte[reply.remaining()]; + reply.get(bytes); + // Index 16 is still default (0x000000) at this point -> reply reports default 16-bit. + final int default16 = TerminalColors.getDefaultPalette256()[16]; + final int r16 = ((default16 >> 16) & 0xFF) * 0x0101; + final int g16 = ((default16 >> 8) & 0xFF) * 0x0101; + final int b16 = (default16 & 0xFF) * 0x0101; + assertEquals(ESC + "]4;16;rgb:" + String.format("%04x/%04x/%04x", r16, g16, b16) + BEL, + new String(bytes, StandardCharsets.US_ASCII), + "the query pair replied with the (default) entry before the set pair applied"); + } + + @Test + void osc4DoesNotMutateStaticDefault() { + // SpotBugs MS_MUTABLE_ARRAY resolved by design: the static default is immutable. Setting + // an entry on one terminal must not mutate the shared default. Revert-and-fail — a + // shared static (palette256 aliased to COLORS_256) would change getDefaultPalette256()[1]. + final int defaultAt1 = TerminalColors.getDefaultPalette256()[1]; + write(terminal, OSC + "4;1;rgb:12/34/56" + BEL); + assertEquals(defaultAt1, TerminalColors.getDefaultPalette256()[1], + "OSC 4 set on an instance must not mutate the static default palette"); + assertEquals(0x123456, terminal.palette256[1], + "the instance entry should reflect the OSC 4 set"); + } + + @Test + void osc4PerTerminalIsolation() { + // A real VT has a per-terminal palette: redefining a color on one terminal must not + // bleed into another terminal's palette. + final Terminal other = new Terminal(); + final int default16 = TerminalColors.getDefaultPalette256()[16]; + write(terminal, OSC4_SET_16_RED); + assertEquals(0xff0000, terminal.palette256[16], "the sender's entry is redefined"); + assertEquals(default16, other.palette256[16], + "the other terminal's entry must stay at the default"); + } + + @Test + void osc104ResetsIndex() { + // OSC 104;Ps resets entry Ps to its default (set/reset, no reply). + write(terminal, OSC4_SET_16_RED); + assertEquals(0xff0000, terminal.palette256[16], "precondition: entry was redefined"); + write(terminal, OSC + "104;16" + BEL); + assertEquals(TerminalColors.getDefaultPalette256()[16], terminal.palette256[16], + "OSC 104;Ps resets that entry to the default"); + } + + @Test + void osc104ResetsAll() { + // OSC 104 with no Ps resets the whole palette to defaults. + final int[] defaults = TerminalColors.getDefaultPalette256(); + write(terminal, OSC4_SET_16_RED); + write(terminal, OSC + "4;200;rgb:aa/bb/cc" + BEL); + assertNotEquals(defaults[16], terminal.palette256[16], "precondition: entries were redefined"); + write(terminal, OSC + "104" + BEL); + assertArrayEquals(defaults, terminal.palette256, + "OSC 104 with no Ps resets the whole palette to defaults"); + } + @Test void cudMovesCursorDownAndClampsSaturatedCount() { // parseArgument saturates at Integer.MAX_VALUE; terminal.y + args[0] must not overflow to a diff --git a/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffCodecTest.java b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffCodecTest.java new file mode 100644 index 00000000..f9a04ffb --- /dev/null +++ b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffCodecTest.java @@ -0,0 +1,99 @@ +package li.cil.oc2.common.vm.terminal; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Wire round-trip tests for {@link TerminalDiff#STREAM_CODEC}. Regression coverage for the + * palette-sync byte-order desync: the palette was written last (after {@code inputModes}) but + * read right after {@code rowData}, so every snapshot decoded from the wrong offset — in-game + * this corrupted all diff traffic while the capture-to-apply tests (which skip the codec) + * stayed green. Both palette branches must round-trip byte-exactly. + */ +public class TerminalDiffCodecTest { + private static final String ESC = String.valueOf((char) 27); + private static final String BEL = String.valueOf((char) 7); + + @Test + void codecRoundTripWithPalettePreservesAllFields() { + final Terminal server = new Terminal(); + write(server, ESC + "]4;16;rgb:ff/00/00" + BEL); + write(server, "hello"); + final TerminalDiff.Snapshot snapshot = TerminalDiff.capture(server); + + final TerminalDiff.Snapshot decoded = roundTrip(snapshot); + + assertEquals(snapshot.reset(), decoded.reset(), "reset"); + assertEquals(snapshot.width(), decoded.width(), "width"); + assertEquals(snapshot.altBuffer(), decoded.altBuffer(), "altBuffer"); + assertArrayEquals(snapshot.rows(), decoded.rows(), "rows"); + assertEquals(snapshot.cursorX(), decoded.cursorX(), "cursorX"); + assertEquals(snapshot.cursorY(), decoded.cursorY(), "cursorY"); + assertEquals(snapshot.lastRowToDisplay(), decoded.lastRowToDisplay(), "lastRowToDisplay"); + assertEquals(snapshot.lastRowToDisplayMax(), decoded.lastRowToDisplayMax(), + "lastRowToDisplayMax"); + assertEquals(snapshot.cursorMode(), decoded.cursorMode(), "cursorMode"); + assertEquals(snapshot.cursorVisible(), decoded.cursorVisible(), "cursorVisible"); + assertEquals(snapshot.bell(), decoded.bell(), "bell"); + assertEquals(snapshot.inputModes(), decoded.inputModes(), "inputModes"); + assertArrayEquals(snapshot.palette(), decoded.palette(), + "palette must survive the wire (was decoded empty-length from cursorX's byte)"); + } + + @Test + void codecRoundTripWithoutPaletteConsumesAllBytes() { + final Terminal server = new Terminal(); + write(server, "steady state"); + TerminalDiff.capture(server); // ship the initial palette (revision bump from RIS) + write(server, "more"); + final TerminalDiff.Snapshot snapshot = TerminalDiff.capture(server); + + final ByteBuf buf = Unpooled.buffer(); + TerminalDiff.STREAM_CODEC.encode(buf, snapshot); + final TerminalDiff.Snapshot decoded = TerminalDiff.STREAM_CODEC.decode(buf); + + assertNull(decoded.palette(), "unchanged palette must stay absent on the wire"); + assertEquals(snapshot.cursorX(), decoded.cursorX(), "cursorX"); + assertEquals(snapshot.inputModes(), decoded.inputModes(), "inputModes"); + assertEquals(0, buf.readableBytes(), "read and write order must agree to the last byte"); + } + + @Test + void fullRefreshSnapshotCarriesPaletteForLateJoiners() { + // Late-joiner scenario: the palette change was already shipped to an earlier client + // (revision == lastSent), then a new client needs a full rebuild. The reset-flagged + // full-refresh captures row content again — the palette must ride it too, or the + // late joiner renders stale defaults until the next OSC 4/104/RIS. + final Terminal server = new Terminal(); + write(server, ESC + "]4;16;rgb:ff/00/00" + BEL); + TerminalDiff.capture(server); // shipped to the first client; lastSent == revision + + server.markAllDirty(); // forces networkNeedsFullRefresh -> capture emits reset=true + final TerminalDiff.Snapshot full = TerminalDiff.capture(server); + assertTrue(full.reset(), + "precondition: this is a full-refresh (reset) snapshot"); + assertNotNull(full.palette(), + "a reset snapshot must carry the palette even when the revision is unchanged"); + } + + private static TerminalDiff.Snapshot roundTrip(final TerminalDiff.Snapshot snapshot) { + final ByteBuf buf = Unpooled.buffer(); + TerminalDiff.STREAM_CODEC.encode(buf, snapshot); + final TerminalDiff.Snapshot decoded = TerminalDiff.STREAM_CODEC.decode(buf); + assertEquals(0, buf.readableBytes(), "all bytes consumed"); + return decoded; + } + + private static void write(final Terminal target, final String text) { + target.io.putOutput(ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffTest.java b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffTest.java index d908cde3..4e1fdd4f 100644 --- a/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffTest.java +++ b/src/test/java/li/cil/oc2/common/vm/terminal/TerminalDiffTest.java @@ -3,6 +3,7 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import li.cil.oc2.common.vm.terminal.color.TerminalColors; import li.cil.oc2.common.vm.terminal.color.TerminalColors.ColorData; import li.cil.oc2.common.vm.terminal.color.TerminalColors.ColorMode; import org.junit.jupiter.api.BeforeEach; @@ -12,12 +13,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** Round-trip tests for the server->client terminal screen diff (TerminalDiff). */ public class TerminalDiffTest { private static final String ESC = "\u001b"; private static final String CSI = ESC + "["; + private static final String BEL = "\u0007"; private Terminal server; @@ -175,11 +179,75 @@ void truncatedRowPayloadDoesNotThrow() { snapshot.cursorMode(), snapshot.cursorVisible(), snapshot.bell(), - snapshot.inputModes()); + snapshot.inputModes(), + snapshot.palette()); final Terminal client = new Terminal(); TerminalDiff.apply(client, broken); // must not throw } + @Test + void osc4PaletteChangeSyncsToClientAcrossTheDiff() { + // The seam fix: a server-side OSC 4 redefines the palette, and the per-tick diff must + // carry it to the client so the render path (which reads the client's palette256) shows + // the redefined color. Without the Snapshot.palette field the client stays default. + write(server, ESC + "]4;16;rgb:ff/00/00" + BEL); // OSC 4 set index 16 -> red + final Terminal client = new Terminal(); + TerminalDiff.apply(client, TerminalDiff.capture(server)); + + assertEquals(0xff0000, client.palette256[16], + "the client must receive the server's OSC 4 palette redefinition"); + assertNotEquals(TerminalColors.getDefaultPalette256()[16], client.palette256[16], + "the client entry must differ from the default (the redefinition landed)"); + } + + @Test + void unchangedPaletteIsNotSentOnIncrementalDiff() { + // Zero steady-state cost: once a palette change has been shipped, subsequent diffs that + // don't touch the palette must not carry it (Snapshot.palette == null). + write(server, ESC + "]4;16;rgb:ff/00/00" + BEL); + TerminalDiff.capture(server); // first capture ships the palette + + write(server, "x"); // unrelated output, no palette change + final TerminalDiff.Snapshot second = TerminalDiff.capture(server); + assertNull(second.palette(), + "an incremental diff after the palette synced must not re-send the palette"); + } + + @Test + void osc104ResetSyncsToClientAcrossTheDiff() { + // OSC 104 reset-single: the reset must propagate so the client's entry returns to default. + write(server, ESC + "]4;16;rgb:ff/00/00" + BEL); + TerminalDiff.capture(server); // ship the redefinition + + write(server, ESC + "]104;16" + BEL); // OSC 104 reset index 16 + final Terminal client = new Terminal(); + TerminalDiff.apply(client, TerminalDiff.capture(server)); + + assertEquals(TerminalColors.getDefaultPalette256()[16], client.palette256[16], + "OSC 104 reset on the server must reset the client's entry to the default"); + } + + @Test + void risResetSnapshotCarriesTheDefaultPalette() { + // Kimi's reset-path warning: after RIS the server palette resets to default, and the + // captureFull reset snapshot MUST carry it — or a client holding a prior OSC 4 change + // keeps a stale palette across the reset. captureFull forces the palette even when the + // revision hasn't moved since the last (incremental) send. + write(server, ESC + "]4;1;rgb:12/34/56" + BEL); + final Terminal client = new Terminal(); + TerminalDiff.apply(client, TerminalDiff.capture(server)); // client now has 0x123456 at [1] + assertEquals(0x123456, client.palette256[1]); + + // Server RIS resets its palette to default; captureFull must ship that to the client. + li.cil.oc2.common.vm.terminal.escapes.index.RIS.execute(server); + final TerminalDiff.Snapshot reset = TerminalDiff.captureFull(server); + assertNotNull(reset.palette(), "the reset snapshot must carry the palette"); + TerminalDiff.apply(client, reset); + + assertEquals(TerminalColors.getDefaultPalette256()[1], client.palette256[1], + "RIS reset must restore the client's palette to default, not leave it stale"); + } + private static void write(final Terminal target, final String text) { target.io.putOutput(ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8))); } diff --git a/src/test/java/li/cil/oc2/common/vm/terminal/color/TerminalColorsTest.java b/src/test/java/li/cil/oc2/common/vm/terminal/color/TerminalColorsTest.java index d1553d0e..a1122bc4 100644 --- a/src/test/java/li/cil/oc2/common/vm/terminal/color/TerminalColorsTest.java +++ b/src/test/java/li/cil/oc2/common/vm/terminal/color/TerminalColorsTest.java @@ -5,26 +5,32 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /** - * Verifies {@link TerminalColors#COLORS_256} against the canonical xterm-256 palette. + * Verifies the default xterm-256 palette (via {@link TerminalColors#getDefaultPalette256()}) + * against the canonical layout. * - *

Canonical layout: 0-15 are the standard 16 ANSI colors and must match - * {@link TerminalColors#COLORS} (0-7) and {@link TerminalColors#BRIGHT_COLORS} (8-15); - * 16-231 are the 6x6x6 color cube, {@code index = 16 + 36*r + 6*g + b} with per-channel - * levels {@code [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]}; 232-255 are the grayscale ramp, - * {@code 8 + 10*i} per channel. The whole 256-entry table is checked, so any single + *

Canonical layout: 0-15 are the standard 16 ANSI colors and must match the default ANSI-16 + * tables (0-7 normal, 8-15 bright, read via the per-instance accessors since the arrays are + * private); 16-231 are the 6x6x6 color cube, {@code index = 16 + 36*r + 6*g + b} with + * per-channel levels {@code [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]}; 232-255 are the grayscale + * ramp, {@code 8 + 10*i} per channel. The whole 256-entry table is checked, so any single * mis-typed channel value fails the test. */ public class TerminalColorsTest { @Test void xterm256PaletteMatchesCanonicalFormula() { + // Read via the accessors: COLORS/BRIGHT_COLORS/COLORS_256 are private (per-instance + // restructure for OSC 4), so the cross-check uses the defensive-copy getters. + final int[] colors = TerminalColors.getDefaultColors16(); + final int[] bright = TerminalColors.getDefaultBrightColors16(); + final int[] palette = TerminalColors.getDefaultPalette256(); final int[] levels = {0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff}; for (int i = 0; i < 256; i++) { final int expected; if (i < 8) { - expected = TerminalColors.COLORS[i]; + expected = colors[i]; } else if (i < 16) { - expected = TerminalColors.BRIGHT_COLORS[i - 8]; + expected = bright[i - 8]; } else if (i < 232) { final int cube = i - 16; final int r = cube / 36; @@ -35,8 +41,30 @@ void xterm256PaletteMatchesCanonicalFormula() { final int gray = 8 + 10 * (i - 232); expected = gray << 16 | gray << 8 | gray; } - assertEquals(expected, TerminalColors.COLORS_256[i], + assertEquals(expected, palette[i], "index " + i + " deviates from the canonical xterm-256 palette"); } } + + @Test + void computeFaintScalesByHalf() { + // SGR 2 (faint) is a tail modifier scaling the resolved color by 1/2, matching xterm's + // computeFaint mechanism (util.c) at the out-of-box 1/2 factor. This reproduces the + // prior fixed DIM_COLORS table for SIXTEEN_COLOR exactly (0xAA0000 -> 0x550000), so + // existing dim text is unchanged; the gain is dim now composes with bold/256/truecolor. + // The 1/2 factor (not xterm's 2/3) keeps faint/normal/faintbright/bright as four distinct + // shades with the VGA/CGA palette (2/3 would collapse faintbright onto normal at 0xAA). + assertEquals(0x550000, TerminalColors.computeFaint(0xAA0000), + "dim scales each channel by half (red)"); + assertEquals(0x555555, TerminalColors.computeFaint(0xAAAAAA), + "dim scales each channel by half (white)"); + assertEquals(0x000000, TerminalColors.computeFaint(0x000000), + "dim on black stays black"); + // Composes with bright: a bold (bright) red dimmed is a shade in no palette entry. + assertEquals(0x7f2a2a, TerminalColors.computeFaint(0xFF5555), + "dim on bright red composes to a synthesized shade"); + // Applies to truecolor: 24-bit resolved color is scaled per-channel. + assertEquals(0x407f20, TerminalColors.computeFaint(0x80ff40), + "dim scales truecolor per channel (0x80/0xff/0x40 -> 0x40/0x7f/0x20)"); + } }