Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 137 additions & 54 deletions config/spotbugs/baseline.xml

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@
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;
Expand Down Expand Up @@ -351,7 +363,7 @@
}

/** Dirty state since the last consume: full-refresh request plus changed buffer rows. */
public record NetworkDirty(boolean fullRefresh, int[] rows) {}

Check warning on line 366 in src/main/java/li/cil/oc2/common/vm/terminal/Terminal.java

View workflow job for this annotation

GitHub Actions / build

[ArrayRecordComponent] Record components should not be arrays.

public NetworkDirty consumeNetworkDirty() {
networkDirtyLock.lock();
Expand All @@ -366,6 +378,41 @@
}
}

/**
* 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<int[]> 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();
Expand Down
56 changes: 43 additions & 13 deletions src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,15 +52,15 @@
private static final ColorMode MODE_ORDINAL_FALLBACK = ColorMode.TRUE_COLOR;

/**
* @param rows absolute buffer row indices (alt-buffer: screen rows 0..23)

Check warning on line 55 in src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java

View workflow job for this annotation

GitHub Actions / build

[MissingSummary] A summary line is required on public/protected Javadocs.
* @param rowData serialized cell data, one array per entry of {@code rows}
*/
public record Snapshot(
boolean reset,
int width,
boolean altBuffer,
int[] rows,

Check warning on line 62 in src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java

View workflow job for this annotation

GitHub Actions / build

[ArrayRecordComponent] Record components should not be arrays.
byte[][] rowData,

Check warning on line 63 in src/main/java/li/cil/oc2/common/vm/terminal/TerminalDiff.java

View workflow job for this annotation

GitHub Actions / build

[ArrayRecordComponent] Record components should not be arrays.
int cursorX,
int cursorY,
int lastRowToDisplay,
Expand All @@ -67,7 +68,8 @@
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
Expand Down Expand Up @@ -127,20 +129,25 @@
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,
Expand All @@ -154,7 +161,8 @@
terminal.cursorMode,
terminal.currentPrivateModeState.DECTCEM,
bell,
packInputModes(terminal.currentPrivateModeState));
packInputModes(terminal.currentPrivateModeState),
palette);
}

private static int[] visibleWindowRows(final Terminal terminal) {
Expand Down Expand Up @@ -314,6 +322,11 @@
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();
}

Expand Down Expand Up @@ -451,6 +464,11 @@
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) {
Expand All @@ -463,20 +481,32 @@
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -55,6 +79,36 @@
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 =
Expand Down Expand Up @@ -128,7 +182,7 @@
Mode = ColorMode.SIXTEEN_COLOR;
}

public ColorData(final int r, final int g, final int b, final ColorMode mode) {

Check warning on line 185 in src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java

View workflow job for this annotation

GitHub Actions / build

[InconsistentCapitalization] Found the field 'Mode' with the same name as the parameter 'mode' but with different capitalization.

Check warning on line 185 in src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java

View workflow job for this annotation

GitHub Actions / build

[InconsistentCapitalization] Found the field 'B' with the same name as the parameter 'b' but with different capitalization.

Check warning on line 185 in src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java

View workflow job for this annotation

GitHub Actions / build

[InconsistentCapitalization] Found the field 'G' with the same name as the parameter 'g' but with different capitalization.

Check warning on line 185 in src/main/java/li/cil/oc2/common/vm/terminal/color/TerminalColors.java

View workflow job for this annotation

GitHub Actions / build

[InconsistentCapitalization] Found the field 'R' with the same name as the parameter 'r' but with different capitalization.
R = r;
G = g;
B = b;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
52 changes: 52 additions & 0 deletions src/main/java/li/cil/oc2/common/vm/terminal/escapes/osc/OSC4.java
Original file line number Diff line number Diff line change
@@ -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 <terminator>. 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));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading