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)");
+ }
}