Add tn3270(no e) and fix animations
Release j3270 / Build & Publish Release (push) Successful in 42s
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 1m10s

This commit is contained in:
2026-08-21 16:32:48 -04:00
parent bf5e4410e6
commit 1212b2cceb
31 changed files with 698 additions and 198 deletions
@@ -17,7 +17,8 @@ public class ConnectionConfig {
private int connectTimeoutMs = 15000;
private int nopIntervalSeconds = 0;
private String terminalName = null; // override terminal type string
private org.lib3270j.graphics.GraphicsMode graphicsMode = org.lib3270j.graphics.GraphicsMode.NONE;
private boolean tn3270eEnabled = true;
private org.lib3270j.graphics.GraphicsMode graphicsMode = org.lib3270j.graphics.GraphicsMode.BOTH;
public ConnectionConfig() {}
@@ -66,6 +67,9 @@ public class ConnectionConfig {
public boolean isTlsVerifyCert() { return tlsVerifyCert; }
public void setTlsVerifyCert(boolean verify) { this.tlsVerifyCert = verify; }
public boolean isTn3270eEnabled() { return tn3270eEnabled; }
public void setTn3270eEnabled(boolean enabled) { this.tn3270eEnabled = enabled; }
public org.lib3270j.tls.TlsCertificateVerifier getCertificateVerifier() { return certificateVerifier; }
public void setCertificateVerifier(org.lib3270j.tls.TlsCertificateVerifier verifier) { this.certificateVerifier = verifier; }
@@ -87,8 +91,8 @@ public class ConnectionConfig {
public void setTerminalName(String name) { this.terminalName = name; }
/**
* Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port")
* or standard "host:port" formats.
* Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"),
* plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), or standard "host:port" formats.
*/
public static ConnectionConfig parseHostString(String hostStr, int defaultPort, TerminalModel defaultModel) {
if (hostStr == null || hostStr.trim().isEmpty()) {
@@ -96,17 +100,35 @@ public class ConnectionConfig {
}
String s = hostStr.trim();
boolean tls = false;
boolean tn3270e = true;
// Check TLS prefixes
if (s.startsWith("L:") || s.startsWith("l:")) {
tls = true;
s = s.substring(2);
} else if (s.startsWith("Y:") || s.startsWith("y:")) {
tls = true;
s = s.substring(2);
} else if (s.toLowerCase().startsWith("ssl:") || s.toLowerCase().startsWith("tls:")) {
tls = true;
s = s.substring(4);
// Parse chained x3270-style prefixes (e.g. "L:P:host:port" or "P:host:port")
boolean prefixFound = true;
while (prefixFound) {
prefixFound = false;
if (s.startsWith("L:") || s.startsWith("l:") || s.startsWith("Y:") || s.startsWith("y:")) {
tls = true;
s = s.substring(2);
prefixFound = true;
} else if (s.toLowerCase().startsWith("ssl:") || s.toLowerCase().startsWith("tls:")) {
tls = true;
s = s.substring(4);
prefixFound = true;
} else if (s.startsWith("N:") || s.startsWith("n:") || s.toLowerCase().startsWith("notls:") || s.toLowerCase().startsWith("nossl:")) {
tls = false;
int colon = s.indexOf(':');
s = s.substring(colon + 1);
prefixFound = true;
} else if (s.startsWith("P:") || s.startsWith("p:")) {
tn3270e = false;
s = s.substring(2);
prefixFound = true;
} else if (s.toLowerCase().startsWith("plain:") || s.toLowerCase().startsWith("non-e:")) {
tn3270e = false;
int colon = s.indexOf(':');
s = s.substring(colon + 1);
prefixFound = true;
}
}
String host = s;
@@ -133,6 +155,7 @@ public class ConnectionConfig {
ConnectionConfig config = new ConnectionConfig(host, port, defaultModel != null ? defaultModel : TerminalModel.IBM_3279_4);
config.setUseTls(tls);
config.setTn3270eEnabled(tn3270e);
return config;
}
@@ -9,6 +9,7 @@ import static org.lib3270j.protocol.DS3270Constants.*;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
@@ -105,74 +106,92 @@ public class DataStreamProcessor {
return;
int cmd = data[offset] & 0xFF;
log.info(">>> CMD: " + commandName(cmd) + " (0x" + String.format("%02x", cmd) + ") " + length + " bytes");
synchronized (screen.getRenderLock()) {
if (log.isLoggable(Level.FINE)) {
log.fine(">>> CMD: " + commandName(cmd) + " (0x" + String.format("%02x", cmd) + ") " + length + " bytes");
}
switch (cmd) {
case CMD_W:
case SNA_CMD_W:
processWrite(data, offset, length, false);
break;
case CMD_EW:
case SNA_CMD_EW:
log.info(">>> ERASE/WRITE: clearing screen (default size)"); {
int oldRows = screen.getRows();
int oldCols = screen.getCols();
screen.erase(false);
graphicsPlane.clear();
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
}
switch (cmd) {
case CMD_W:
case SNA_CMD_W:
programSymbolManager.commitStagedSymbols();
processWrite(data, offset, length, false);
break;
case CMD_EW:
case SNA_CMD_EW:
programSymbolManager.commitStagedSymbols();
if (log.isLoggable(Level.FINE)) {
log.fine(">>> ERASE/WRITE: clearing screen (default size)");
}
{
int oldRows = screen.getRows();
int oldCols = screen.getCols();
screen.erase(false);
graphicsPlane.clear();
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
}
}
break;
case CMD_EWA:
case SNA_CMD_EWA:
programSymbolManager.commitStagedSymbols();
if (log.isLoggable(Level.FINE)) {
log.fine(">>> ERASE/WRITE ALTERNATE: clearing screen (alt size)");
}
{
int oldRows = screen.getRows();
int oldCols = screen.getCols();
screen.erase(true);
graphicsPlane.clear();
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
}
}
break;
case CMD_RB:
case SNA_CMD_RB:
programSymbolManager.commitStagedSymbols();
processReadBuffer();
break;
case CMD_RM:
case SNA_CMD_RM:
programSymbolManager.commitStagedSymbols();
processReadModified(false);
break;
case CMD_RMA:
case SNA_CMD_RMA:
programSymbolManager.commitStagedSymbols();
processReadModified(true);
break;
case CMD_EAU:
case SNA_CMD_EAU:
programSymbolManager.commitStagedSymbols();
log.info(">>> EAU: erasing all unprotected fields");
screen.eraseAllUnprotected();
break;
case CMD_WSF:
case SNA_CMD_WSF:
processWriteStructuredField(data, offset, length);
break;
case CMD_NOP:
log.info(">>> NOP command");
break;
default:
log.warning(">>> UNKNOWN 3270 command: " + String.format("0x%02x", cmd));
break;
}
break;
case CMD_EWA:
case SNA_CMD_EWA:
log.info(">>> ERASE/WRITE ALTERNATE: clearing screen (alt size)"); {
int oldRows = screen.getRows();
int oldCols = screen.getCols();
screen.erase(true);
graphicsPlane.clear();
processWrite(data, offset, length, true);
if (oldRows != screen.getRows() || oldCols != screen.getCols()) {
notifyScreenSizeChanged();
}
}
break;
case CMD_RB:
case SNA_CMD_RB:
processReadBuffer();
break;
case CMD_RM:
case SNA_CMD_RM:
processReadModified(false);
break;
case CMD_RMA:
case SNA_CMD_RMA:
processReadModified(true);
break;
case CMD_EAU:
case SNA_CMD_EAU:
log.info(">>> EAU: erasing all unprotected fields");
screen.eraseAllUnprotected();
break;
case CMD_WSF:
case SNA_CMD_WSF:
processWriteStructuredField(data, offset, length);
break;
case CMD_NOP:
log.info(">>> NOP command");
break;
default:
log.warning(">>> UNKNOWN 3270 command: " + String.format("0x%02x", cmd));
break;
// Translate EBCDIC to Unicode for display
screen.translateToUnicode();
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
// Translate EBCDIC to Unicode for display
screen.translateToUnicode();
screen.markAllChanged();
// Debug: dump non-empty screen lines
if (cmd == SNA_CMD_EWA || cmd == CMD_EWA || cmd == SNA_CMD_EW || cmd == CMD_EW) {
if (log.isLoggable(Level.FINE) && (cmd == SNA_CMD_EWA || cmd == CMD_EWA || cmd == SNA_CMD_EW || cmd == CMD_EW)) {
int r = screen.getRows();
int c = screen.getCols();
StringBuilder dump = new StringBuilder();
@@ -205,7 +224,7 @@ public class DataStreamProcessor {
}
}
if (dump.length() > 0) {
log.info("Screen content after " + commandName(cmd) + ":\n" + dump.toString());
log.fine("Screen content after " + commandName(cmd) + ":\n" + dump.toString());
}
}
}
@@ -798,7 +817,6 @@ public class DataStreamProcessor {
byte[] psData = new byte[fieldLen - 3];
System.arraycopy(data, pos + 3, psData, 0, psData.length);
programSymbolManager.loadps(psData);
notifyScreenUpdated();
}
break;
case org.lib3270j.graphics.GocaConstants.SF_LOADPS: { // 0x0F: 2-byte Structured Field prefix
@@ -810,7 +828,6 @@ public class DataStreamProcessor {
byte[] psData = new byte[fieldLen - 4];
System.arraycopy(data, pos + 4, psData, 0, psData.length);
programSymbolManager.loadps(psData);
notifyScreenUpdated();
}
break;
case org.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB: // 0x11: Object Control (Procedure orders)
@@ -22,7 +22,7 @@ public class QueryReplyBuilder {
private static final int Yr_3279_2 = 0x0002006f;
private final ScreenBuffer screen;
private GraphicsMode graphicsMode = GraphicsMode.NONE;
private GraphicsMode graphicsMode = GraphicsMode.BOTH;
// Base query reply codes (text mode)
private static final int[] SUPPORTED_QR_BASE = {
@@ -313,9 +313,9 @@ public class QueryReplyBuilder {
private byte[] buildCharsets() {
if (graphicsMode.isProgrammedSymbolsEnabled()) {
// Programmed Symbols mode (3279 PS with LoadPS 0x0A)
// Programmed Symbols mode (3279 PS with LoadPS 0x0A, flags1 = 0xA2 for GE + PS + CGCSGID)
ByteArrayOutputStream out = new ByteArrayOutputStream(65);
out.write(0x82); // flags: GE, CGCSGID present
out.write(0xa2); // flags: GE (0x80), PS/Loadable Charsets (0x20), CGCSGID present (0x02)
out.write(0x00); // more flags
out.write(SW_3279_2); // SDW (9)
out.write(SH_3279_2); // SDH (12)
@@ -328,14 +328,14 @@ public class QueryReplyBuilder {
out.write(0x00); out.write(0x10); out.write(0x00); out.write(0x02); out.write(0xb9); out.write(0x00); out.write(0x25);
// Descriptor 2 (SET 1): APL/GE character set
out.write(0x01); out.write(0x00); out.write(0xf1); out.write(0x03); out.write(0xc3); out.write(0x01); out.write(0x36);
// Loadable Single-Plane PS Sets (PSA, PSB: Slots 2, 3)
// Loadable Single-Plane PS Sets (PSA, PSB: Slots 2, 3) - Flags = 0x80 (Loadable, single plane)
out.write(0x02); out.write(0x80); out.write(0x40); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x03); out.write(0x80); out.write(0x41); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
// Loadable Triple-Plane PS Sets (PSC, PSD, PSE, PSF: Slots 4, 5, 6, 7)
out.write(0x04); out.write(0x80); out.write(0x42); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x05); out.write(0x80); out.write(0x43); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x06); out.write(0x80); out.write(0x44); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x07); out.write(0x80); out.write(0x45); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
// Loadable Triple-Plane PS Sets (PSC, PSD, PSE, PSF: Slots 4, 5, 6, 7) - Flags = 0xC0 (0x80 Loadable | 0x40 Triple-plane)
out.write(0x04); out.write(0xc0); out.write(0x42); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x05); out.write(0xc0); out.write(0x43); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x06); out.write(0xc0); out.write(0x44); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
out.write(0x07); out.write(0xc0); out.write(0x45); out.write(0x00); out.write(0x00); out.write(0x00); out.write(0x00);
return out.toByteArray();
}
@@ -17,12 +17,31 @@ public class ProgramSymbolManager {
public static final int NUMBER_TRIPLE_PLANE_PS_SETS = 4; // RWS 4..7 (Sets 2..5)
public static final int NUMBER_GRAPHICS_SYMBOL_SETS = 4; // RWS 8..11 (Sets 6..9)
private int defaultCellWidth = 9;
private int defaultCellHeight = 12; // Standard IBM 3279 PS Slot Default Height (SDH = 0x0C = 12)
public void setDefaultCellDimensions(int width, int height) {
this.defaultCellWidth = (width > 0) ? width : 9;
this.defaultCellHeight = (height > 0) ? height : 12;
}
public int getDefaultCellWidth() {
return defaultCellWidth;
}
public int getDefaultCellHeight() {
return defaultCellHeight;
}
private final ProgramSymbolSet[] sets = new ProgramSymbolSet[NUMBER_SYMBOL_SETS];
private final ProgramSymbolSet[] stagingSets = new ProgramSymbolSet[NUMBER_SYMBOL_SETS];
private final ProgramSymbolSet[] lcidMap = new ProgramSymbolSet[256];
public ProgramSymbolManager() {
for (int i = 0; i < NUMBER_SYMBOL_SETS; i++) {
boolean isTriple = (i >= NUMBER_SINGLE_PLANE_PS_SETS && i < NUMBER_SINGLE_PLANE_PS_SETS + NUMBER_TRIPLE_PLANE_PS_SETS);
sets[i] = new ProgramSymbolSet(isTriple);
stagingSets[i] = new ProgramSymbolSet(isTriple);
}
}
@@ -30,32 +49,67 @@ public class ProgramSymbolManager {
* Resets all symbol sets.
*/
public synchronized void clearAll() {
for (ProgramSymbolSet set : sets) {
set.clear();
set.setLcid(0);
Arrays.fill(lcidMap, null);
for (int i = 0; i < NUMBER_SYMBOL_SETS; i++) {
sets[i].clear();
sets[i].setLcid(0);
stagingSets[i].clear();
stagingSets[i].setLcid(0);
}
}
/**
* Commits all staged multi-plane symbol sets to the active presentation sets atomically.
*/
public synchronized void commitStagedSymbols() {
for (int i = 0; i < NUMBER_SYMBOL_SETS; i++) {
int lcid = stagingSets[i].getLcid();
if (lcid > 0) {
ProgramSymbolSet staged = stagingSets[i];
ProgramSymbolSet active = sets[i];
active.setLcid(lcid);
for (int slot = 0; slot < ProgramSymbolSet.NUM_SLOTS; slot++) {
active.setSlot(slot, staged.getSlot(slot));
}
if (lcid < 256) {
lcidMap[lcid] = active;
}
}
}
}
/**
* Retrieves the symbol set corresponding to a given LCID (0x40 - 0xFE).
*/
public synchronized ProgramSymbolSet getSymbolSet(int lcid) {
if (lcid <= 0) {
public ProgramSymbolSet getSymbolSet(int lcid) {
if (lcid <= 0 || lcid >= 256) {
return null;
}
for (ProgramSymbolSet set : sets) {
if (set.getLcid() == lcid) {
return set;
ProgramSymbolSet set = lcidMap[lcid];
if (set == null) {
for (ProgramSymbolSet s : stagingSets) {
if (s.getLcid() == lcid) return s;
}
}
return null;
return set;
}
/**
* Retrieves a specific symbol glyph by LCID and character code point (0x40 - 0xFE).
*/
public synchronized ProgramSymbolSet.SymbolSlot getSymbol(int lcid, int codePoint) {
ProgramSymbolSet set = getSymbolSet(lcid);
public ProgramSymbolSet.SymbolSlot getSymbol(int lcid, int codePoint) {
if (lcid <= 0 || lcid >= 256) {
return null;
}
ProgramSymbolSet set = lcidMap[lcid];
if (set == null) {
for (ProgramSymbolSet s : stagingSets) {
if (s.getLcid() == lcid) {
set = s;
break;
}
}
}
if (set == null) {
return null;
}
@@ -98,12 +152,12 @@ public class ProgramSymbolManager {
return;
}
ProgramSymbolSet set = sets[setIndex];
boolean isTriplePlane = (rws >= 4 && rws <= 7);
ProgramSymbolSet set = isTriplePlane ? stagingSets[setIndex] : sets[setIndex];
int extHeaderLen = 0;
int cellWidth = 9;
int cellHeight = 16;
int cellWidth = defaultCellWidth;
int cellHeight = defaultCellHeight;
int colorPlane = 0; // 0 = all planes, 1 = Red, 2 = Green, 4 = Blue
if (hasExtHeader && data.length > 4) {
@@ -121,10 +175,10 @@ public class ProgramSymbolManager {
}
}
if (clearAll) {
set.clear();
}
set.setLcid(lcid);
if (!isTriplePlane && lcid > 0 && lcid < 256) {
lcidMap[lcid] = set;
}
int offset = 4 + (hasExtHeader ? extHeaderLen : 0);
int remaining = data.length - offset;
@@ -142,12 +196,10 @@ public class ProgramSymbolManager {
}
while (remaining >= bytesPerSymbol && codeIndex < ProgramSymbolSet.NUM_SLOTS) {
byte[] pixelData;
byte[] pixelData = new byte[cellWidth * cellHeight];
ProgramSymbolSet.SymbolSlot existing = set.getSlot(codeIndex);
if (existing != null && existing.getWidth() == cellWidth && existing.getHeight() == cellHeight) {
pixelData = existing.getPixelData();
} else {
pixelData = new byte[cellWidth * cellHeight];
if (!clearAll && existing != null && existing.getWidth() == cellWidth && existing.getHeight() == cellHeight) {
System.arraycopy(existing.getPixelData(), 0, pixelData, 0, Math.min(existing.getPixelData().length, pixelData.length));
}
if (loadFormat == 1) {
@@ -163,9 +215,17 @@ public class ProgramSymbolManager {
remaining -= bytesPerSymbol;
}
logger.info(String.format("LOADPS: Loaded PS Set LCID=0x%02X (RWS=%d, %s, %dx%d, %d glyphs)",
lcid, rws, isTriplePlane ? "Triple-Plane" : "Single-Plane",
cellWidth, cellHeight, codeIndex - ((startCodePoint >= 0x40) ? (startCodePoint - 0x40) : startCodePoint)));
if (clearAll) {
for (int i = codeIndex; i < ProgramSymbolSet.NUM_SLOTS; i++) {
set.clearSlot(i);
}
}
if (logger.isLoggable(Level.FINE)) {
logger.fine(String.format("LOADPS: Loaded PS Set LCID=0x%02X (RWS=%d, %s, %dx%d, %d glyphs)",
lcid, rws, isTriplePlane ? "Triple-Plane" : "Single-Plane",
cellWidth, cellHeight, codeIndex - ((startCodePoint >= 0x40) ? (startCodePoint - 0x40) : startCodePoint)));
}
}
/**
@@ -64,6 +64,12 @@ public class ProgramSymbolSet {
private int[] cachedRgbArray;
private int cachedFgRgb = -1;
private int cachedBgRgb = -1;
private java.awt.image.BufferedImage cachedImage;
private java.awt.image.BufferedImage cachedScaledImage;
private int cachedTargetW = 0;
private int cachedTargetH = 0;
private int cachedScaledFgRgb = -1;
private int cachedScaledBgRgb = -1;
public SymbolSlot(int width, int height, byte[] pixelData, boolean isTriplePlane) {
this.width = width > 0 ? width : 9;
@@ -88,6 +94,61 @@ public class ProgramSymbolSet {
return isTriplePlane;
}
/**
* Returns an image scaled directly to target dimensions (cellWidth x cellHeight).
* Enables unscaled 1:1 hardware blitting in Java2D.
*/
public synchronized java.awt.image.BufferedImage getScaledImage(int targetW, int targetH, int fgArgb, int bgArgb) {
if (targetW <= 0 || targetH <= 0) {
return getImage(fgArgb, bgArgb);
}
if (targetW == width && targetH == height) {
return getImage(fgArgb, bgArgb);
}
if (cachedScaledImage != null && cachedTargetW == targetW && cachedTargetH == targetH
&& cachedScaledFgRgb == fgArgb && cachedScaledBgRgb == bgArgb) {
return cachedScaledImage;
}
int[] srcRgb = getRgbPixels(fgArgb, bgArgb);
java.awt.image.BufferedImage scaled = new java.awt.image.BufferedImage(targetW, targetH, java.awt.image.BufferedImage.TYPE_INT_ARGB);
int[] dstRgb = ((java.awt.image.DataBufferInt) scaled.getRaster().getDataBuffer()).getData();
for (int dy = 0; dy < targetH; dy++) {
int sy = dy * height / targetH;
int srcRowOffset = sy * width;
int dstRowOffset = dy * targetW;
for (int dx = 0; dx < targetW; dx++) {
int sx = dx * width / targetW;
dstRgb[dstRowOffset + dx] = srcRgb[srcRowOffset + sx];
}
}
this.cachedScaledImage = scaled;
this.cachedTargetW = targetW;
this.cachedTargetH = targetH;
this.cachedScaledFgRgb = fgArgb;
this.cachedScaledBgRgb = bgArgb;
return scaled;
}
/**
* Computes and returns the cached BufferedImage for this symbol glyph.
* Eliminates per-cell heap allocations during high frame rate rendering.
*/
public synchronized java.awt.image.BufferedImage getImage(int fgArgb, int bgArgb) {
if (cachedImage != null && cachedFgRgb == fgArgb && cachedBgRgb == bgArgb) {
return cachedImage;
}
int[] rgb = getRgbPixels(fgArgb, bgArgb);
java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_ARGB);
int[] imgData = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData();
System.arraycopy(rgb, 0, imgData, 0, rgb.length);
this.cachedImage = img;
this.cachedFgRgb = fgArgb;
this.cachedBgRgb = bgArgb;
return img;
}
/**
* Computes and returns the 32-bit ARGB pixel array for this symbol.
* The returned array has length (width * height).
@@ -36,8 +36,12 @@ public class ScreenBuffer {
private byte defaultGr = 0x00;
private byte defaultCs = 0x00;
private byte defaultIc = 0x00;
private final EbcdicTranslator translator;
private final Object renderLock = new Object();
public Object getRenderLock() {
return renderLock;
}
public ScreenBuffer(TerminalModel model, EbcdicTranslator translator) {
this.translator = translator;
@@ -79,6 +83,50 @@ public class ScreenBuffer {
return buffer[addr];
}
private ExtendedAttribute[] displaySnapshot;
private int displayRows;
private int displayCols;
private int displayCursorAddress;
/**
* Atomically creates a snapshot of the current presentation buffer for tear-free rendering.
* Takes ~2 microseconds and eliminates mutual thread contention with the UI thread.
*/
public synchronized void updateDisplaySnapshot() {
int size = rows * cols;
if (displaySnapshot == null || displaySnapshot.length < size) {
displaySnapshot = new ExtendedAttribute[size];
for (int i = 0; i < size; i++) {
displaySnapshot[i] = new ExtendedAttribute();
}
}
for (int i = 0; i < size; i++) {
displaySnapshot[i].copyFrom(buffer[i]);
}
this.displayRows = rows;
this.displayCols = cols;
this.displayCursorAddress = cursorAddress;
}
public synchronized ExtendedAttribute getDisplayCell(int addr) {
if (displaySnapshot == null || addr < 0 || addr >= displayRows * displayCols) {
return getCell(addr);
}
return displaySnapshot[addr];
}
public synchronized int getDisplayRows() {
return displayRows > 0 ? displayRows : rows;
}
public synchronized int getDisplayCols() {
return displayCols > 0 ? displayCols : cols;
}
public synchronized int getDisplayCursorAddress() {
return displayRows > 0 ? displayCursorAddress : cursorAddress;
}
// ========== Dimension accessors ==========
public int getRows() { return rows; }
public int getCols() { return cols; }
@@ -149,9 +149,7 @@ public class TelnetConnection {
log.fine("RCVD " + n + " bytes: " + formatHex(buf, 0, n));
}
try {
for (int i = 0; i < n; i++) {
fsm.feedByte(buf[i] & 0xFF);
}
fsm.feedBytes(buf, 0, n);
fsm.endOfNetworkData();
} catch (Throwable t) {
log.log(Level.SEVERE, "Exception processing incoming data stream", t);
@@ -58,6 +58,7 @@ public class TelnetFSM {
private int eXmitSeq;
private int responseRequired = RSF_NO_RESPONSE;
private boolean deferredWillTtype;
private boolean tn3270eDeviceTypeSent;
// Connection references
private TelnetConnection connection;
@@ -103,6 +104,7 @@ public class TelnetFSM {
java.util.Arrays.fill(hisOpts, false);
java.util.Arrays.fill(eFuncs, false);
tn3270eNegotiated = false;
tn3270eDeviceTypeSent = false;
tn3270eSubmode = TN3270ESubmode.UNBOUND;
tn3270eBound = false;
eXmitSeq = 0;
@@ -118,6 +120,37 @@ public class TelnetFSM {
changeState(ConnectionState.TELNET_PENDING);
}
/**
* Feed a bulk buffer of bytes from the network into the FSM.
*/
public void feedBytes(byte[] buf, int offset, int len) {
int end = offset + len;
int i = offset;
while (i < end) {
if (state == TNS_DATA) {
int start = i;
while (i < end && (buf[i] & 0xFF) != IAC) {
i++;
}
if (i > start) {
if (connectionState == ConnectionState.TELNET_PENDING) {
changeState(ConnectionState.CONNECTED_NVT);
}
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
ibuf.write(buf, start, i - start);
}
}
if (i < end) {
state = TNS_IAC;
i++;
}
} else {
feedByte(buf[i] & 0xFF);
i++;
}
}
}
/**
* Feed a single byte from the network into the FSM.
*/
@@ -172,8 +205,8 @@ public class TelnetFSM {
changeState(ConnectionState.CONNECTED_NVT);
}
// Accumulate data for 3270, TN3270E (including SSCP-LU and unbound states)
if (connectionState.is3270() || connectionState.isTn3270e()) {
// Accumulate data for 3270, TN3270E (including SSCP-LU and unbound states, and pending)
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
ibuf.write(c);
}
// NVT data would go to NVT processor (not implemented in initial version)
@@ -189,7 +222,7 @@ public class TelnetFSM {
break;
case EOR: // End of record — process accumulated 3270 data
log.fine("RCVD EOR");
if (connectionState.is3270() || connectionState.isTn3270e()) {
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
processEndOfRecord();
}
ibuf.reset();
@@ -235,7 +268,9 @@ public class TelnetFSM {
break;
case TELOPT_TN3270E:
if (!hisOpts[opt]) {
if (!config.isTn3270eEnabled()) {
sendCommand(DONT, opt);
} else if (!hisOpts[opt]) {
hisOpts[opt] = true;
sendCommand(DO, opt);
}
@@ -278,7 +313,7 @@ public class TelnetFSM {
case TELOPT_TTYPE:
if (!myOpts[opt]) {
myOpts[opt] = true;
if (hisOpts[TELOPT_TN3270E]) {
if (config.isTn3270eEnabled() && hisOpts[TELOPT_TN3270E]) {
// Defer TTYPE response until TN3270E negotiation completes
deferredWillTtype = true;
} else {
@@ -288,11 +323,16 @@ public class TelnetFSM {
break;
case TELOPT_TN3270E:
if (!myOpts[opt]) {
if (!config.isTn3270eEnabled()) {
sendCommand(WONT, opt);
} else if (!myOpts[opt]) {
myOpts[opt] = true;
sendCommand(WILL, opt);
// Start TN3270E sub-negotiation: send device type request
sendTN3270EDeviceTypeRequest();
if (!tn3270eDeviceTypeSent) {
sendTN3270EDeviceTypeRequest();
tn3270eDeviceTypeSent = true;
}
}
break;
@@ -428,7 +468,10 @@ public class TelnetFSM {
switch (op) {
case OP_SEND:
// Host asks us to send device-type request
sendTN3270EDeviceTypeRequest();
if (!tn3270eDeviceTypeSent) {
sendTN3270EDeviceTypeRequest();
tn3270eDeviceTypeSent = true;
}
break;
case OP_DEVICE_TYPE:
@@ -488,14 +531,19 @@ public class TelnetFSM {
pos++;
}
// Check if REJECT
if (pos < data.length && (data[pos] & 0xFF) == OP_REJECT) {
// Rejection
pos++;
int reason = -1;
if (pos < data.length && (data[pos] & 0xFF) == OP_REASON) {
pos++;
if (pos < data.length) {
reason = data[pos] & 0xFF;
int reason = (pos < data.length) ? (data[pos] & 0xFF) : REASON_UNSUPPORTED_REQ;
if (reason == REASON_INV_DEVICE_TYPE || reason == REASON_UNSUPPORTED_REQ) {
// Try fallback model 2 if we were requesting something else
if (config.getModel() != TerminalModel.IBM_3278_2 &&
config.getModel() != TerminalModel.IBM_3279_2) {
log.warning("TN3270E device-type rejected (" +
TN3270EConstants.reasonName(reason) + "), retrying with IBM-3278-2");
config.setModel(TerminalModel.IBM_3278_2);
sendTN3270EDeviceTypeRequest();
return;
}
}
log.warning("TN3270E device-type rejected: " + TN3270EConstants.reasonName(reason));
@@ -563,33 +611,86 @@ public class TelnetFSM {
log.info("SENT SB TN3270E FUNCTIONS REQUEST " + funcNames + " SE");
}
private void handleTN3270EFunctions(byte[] data) {
// Parse: TN3270E FUNCTIONS IS [func...]
int pos = 2; // Skip TN3270E, FUNCTIONS
private void sendTN3270EFunctionsIs() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(IAC);
out.write(SB);
out.write(TELOPT_TN3270E);
out.write(OP_FUNCTIONS);
out.write(OP_IS);
if (pos < data.length && (data[pos] & 0xFF) == OP_IS) {
pos++; // Skip IS
StringBuilder funcNames = new StringBuilder();
for (int i = 0; i < eFuncs.length; i++) {
if (eFuncs[i]) {
out.write(i);
if (funcNames.length() > 0) funcNames.append(" ");
funcNames.append(TN3270EConstants.functionName(i));
}
}
// The remaining bytes are the agreed-upon functions
java.util.Arrays.fill(eFuncs, false);
StringBuilder funcNames = new StringBuilder();
out.write(IAC);
out.write(SE);
sendBytes(out.toByteArray());
log.info("SENT SB TN3270E FUNCTIONS IS " + funcNames + " SE");
}
private void handleTN3270EFunctions(byte[] data) {
// Parse: TN3270E FUNCTIONS REQUEST [func...] or TN3270E FUNCTIONS IS [func...]
int pos = 1;
boolean isRequest = false;
while (pos < data.length) {
int b = data[pos] & 0xFF;
if (b == OP_FUNCTIONS) {
pos++;
} else if (b == OP_REQUEST) {
isRequest = true;
pos++;
break;
} else if (b == OP_IS) {
isRequest = false;
pos++;
break;
} else {
pos++;
}
}
// The remaining bytes are the proposed/agreed functions
boolean[] hostFuncs = new boolean[8];
while (pos < data.length) {
int func = data[pos] & 0xFF;
if (func <= FUNC_SNA_SENSE) {
eFuncs[func] = true;
if (funcNames.length() > 0) funcNames.append(" ");
funcNames.append(TN3270EConstants.functionName(func));
hostFuncs[func] = true;
}
pos++;
}
if (isRequest) {
// Host sent FUNCTIONS REQUEST -> We reply with FUNCTIONS IS (intersection of functions)
for (int i = 0; i < eFuncs.length; i++) {
eFuncs[i] = eFuncs[i] && hostFuncs[i];
}
sendTN3270EFunctionsIs();
} else {
// Host sent FUNCTIONS IS -> Accept host's agreed function list
for (int i = 0; i < eFuncs.length; i++) {
eFuncs[i] = hostFuncs[i];
}
}
tn3270eNegotiated = true;
log.info("TN3270E functions IS: " + funcNames);
log.info("TN3270E functions negotiated: " + getNegotiatedFunctionNames());
log.info("TN3270E negotiation complete");
// Move to CONNECTED_UNBOUND or CONNECTED_SSCP
changeState(ConnectionState.CONNECTED_UNBOUND);
// RFC 2355: If BIND-IMAGE function is not negotiated, the emulation session is considered
// to be bound immediately upon completion of the FUNCTIONS negotiation.
if (eFuncs[FUNC_BIND_IMAGE]) {
changeState(ConnectionState.CONNECTED_UNBOUND);
} else {
changeState(ConnectionState.CONNECTED_TN3270E);
tn3270eSubmode = TN3270ESubmode.E_3270;
}
// Notify listeners
for (ConnectionListener l : connectionListeners) {
@@ -597,12 +698,29 @@ public class TelnetFSM {
}
}
private String getNegotiatedFunctionNames() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < eFuncs.length; i++) {
if (eFuncs[i]) {
if (sb.length() > 0) sb.append(" ");
sb.append(TN3270EConstants.functionName(i));
}
}
return sb.length() > 0 ? sb.toString() : "<none>";
}
// ========== End of Record processing ==========
private void processEndOfRecord() {
byte[] data = ibuf.toByteArray();
ibuf.reset();
if (data.length == 0) return;
if (connectionState == ConnectionState.TELNET_PENDING && !tn3270eNegotiated) {
log.info("Received EOR during TELNET_PENDING - transitioning to plain TN3270 mode");
changeState(ConnectionState.CONNECTED_3270);
}
if (tn3270eNegotiated) {
// TN3270E mode: data starts with 5-byte header
processTN3270ERecord(data);
@@ -701,11 +819,11 @@ public class TelnetFSM {
bindRa = screenBuffer.getMaxRows();
bindCa = screenBuffer.getMaxCols();
break;
case 0x7e:
case 0x7E:
// Both default and alternate = specified values
bindRa = bindRd; bindCa = bindCd;
break;
case 0x7f:
case 0x7F:
// Default and alternate are both specified separately
bindRa = data[EH_SIZE + BIND_OFF_RA] & 0xFF;
bindCa = data[EH_SIZE + BIND_OFF_CA] & 0xFF;
@@ -767,7 +885,20 @@ public class TelnetFSM {
break;
default:
log.info("Unhandled TN3270E data type: " + dataType);
// Check if the host sent a raw 3270 data stream (e.g. 0xF5 EraseWrite, 0x7E EW Alternate, 0xF1 Write, etc.)
// This happens when the server ignores or drops TN3270E framing and speaks plain 3270 data stream.
if (dataType == 0xF5 || dataType == 0x7E || dataType == 0xF1 || dataType == 0x6F ||
dataType == 0x6E || dataType == 0xF2 || dataType == 0xF6 || dataType == 0x05 ||
dataType == 0x0D || dataType == 0x01 || (data.length >= 2 && (data[0] & 0xFF) == 0x11)) {
log.warning("Received plain 3270 command (0x" + Integer.toHexString(dataType) +
") in TN3270E mode — automatically switching to plain TN3270 mode");
tn3270eNegotiated = false;
changeState(ConnectionState.CONNECTED_3270);
dsProcessor.processRecord(data, 0, data.length, true);
notifyScreenUpdate();
} else {
log.info("Unhandled TN3270E data type: " + dataType);
}
break;
}
}
@@ -790,7 +921,7 @@ public class TelnetFSM {
if (connectionState != ConnectionState.TELNET_PENDING) return;
// For TN3270E, we wait for TN3270E negotiation to complete
if (myOpts[TELOPT_TN3270E] && hisOpts[TELOPT_TN3270E]) {
if (config.isTn3270eEnabled() && myOpts[TELOPT_TN3270E] && hisOpts[TELOPT_TN3270E]) {
return; // TN3270E in progress
}
@@ -15,8 +15,27 @@ public class QueryReplyBuilderTest {
private final QueryReplyBuilder qrBuilder = new QueryReplyBuilder(screen);
@Test
public void testBuildAllQueryRepliesDefaultNone() {
assertEquals(GraphicsMode.NONE, qrBuilder.getGraphicsMode());
public void testBuildAllQueryRepliesDefaultBoth() {
assertEquals(GraphicsMode.BOTH, qrBuilder.getGraphicsMode());
byte[] replies = qrBuilder.buildAllQueryReplies(80, 43, 80 * 43);
assertNotNull(replies);
assertTrue(replies.length > 0);
assertEquals((byte) AID_SF, replies[0]);
// In GraphicsMode.BOTH, Vector Graphics QR 0xB0 must be present
boolean hasB0 = false;
for (int i = 0; i < replies.length - 3; i++) {
if ((replies[i] & 0xFF) == 0x81 && (replies[i + 1] & 0xFF) == QR_GRAPHICS) {
hasB0 = true;
break;
}
}
assertTrue(hasB0);
}
@Test
public void testBuildAllQueryRepliesExplicitNone() {
qrBuilder.setGraphicsMode(GraphicsMode.NONE);
byte[] replies = qrBuilder.buildAllQueryReplies(80, 43, 80 * 43);
assertNotNull(replies);
assertTrue(replies.length > 0);
@@ -156,4 +175,61 @@ public class QueryReplyBuilderTest {
assertEquals(80, altCols);
assertEquals(43, altRows);
}
@Test
public void testProgrammedSymbolsDescriptorsSingleAndTriplePlane() {
qrBuilder.setGraphicsMode(GraphicsMode.PROGRAMMED_SYMBOLS);
byte[] requested = new byte[] { (byte) QR_CHARSETS };
byte[] replies = qrBuilder.buildQueryReplies(requested, 80, 43, 80 * 43);
assertNotNull(replies);
int offset = -1;
for (int i = 0; i < replies.length - 3; i++) {
if ((replies[i] & 0xFF) == 0x81 && (replies[i + 1] & 0xFF) == QR_CHARSETS) {
offset = i + 2;
break;
}
}
assertTrue(offset >= 0, "QR_CHARSETS must be present");
// QR_CHARSETS payload structure:
// Flags (2 bytes), SDW (1 byte), SDH (1 byte), Form (1 byte), DevType (2 bytes), Res (1 byte), DL (1 byte)
// DL is at offset + 8, and is 7 bytes per descriptor.
int dl = replies[offset + 8] & 0xFF;
assertEquals(7, dl);
int descOffset = offset + 9;
// Descriptor 1: SET 0 (Base) -> flags 0x10
assertEquals(0x00, replies[descOffset] & 0xFF);
assertEquals(0x10, replies[descOffset + 1] & 0xFF);
// Descriptor 2: SET 1 (APL) -> flags 0x00
assertEquals(0x01, replies[descOffset + 7] & 0xFF);
assertEquals(0x00, replies[descOffset + 7 + 1] & 0xFF);
// Descriptor 3: PSA (Single plane) -> flags 0x80 (Loadable, single-plane)
assertEquals(0x02, replies[descOffset + 14] & 0xFF);
assertEquals(0x80, replies[descOffset + 14 + 1] & 0xFF);
// Descriptor 4: PSB (Single plane) -> flags 0x80 (Loadable, single-plane)
assertEquals(0x03, replies[descOffset + 21] & 0xFF);
assertEquals(0x80, replies[descOffset + 21 + 1] & 0xFF);
// Descriptor 5: PSC (Triple plane) -> flags 0xC0 (Loadable | Triple-plane)
assertEquals(0x04, replies[descOffset + 28] & 0xFF);
assertEquals(0xC0, replies[descOffset + 28 + 1] & 0xFF);
// Descriptor 6: PSD (Triple plane) -> flags 0xC0 (Loadable | Triple-plane)
assertEquals(0x05, replies[descOffset + 35] & 0xFF);
assertEquals(0xC0, replies[descOffset + 35 + 1] & 0xFF);
// Descriptor 7: PSE (Triple plane) -> flags 0xC0 (Loadable | Triple-plane)
assertEquals(0x06, replies[descOffset + 42] & 0xFF);
assertEquals(0xC0, replies[descOffset + 42 + 1] & 0xFF);
// Descriptor 8: PSF (Triple plane) -> flags 0xC0 (Loadable | Triple-plane)
assertEquals(0x07, replies[descOffset + 49] & 0xFF);
assertEquals(0xC0, replies[descOffset + 49 + 1] & 0xFF);
}
}
@@ -46,11 +46,11 @@ public class GraphicsModeTest {
@Test
public void testConnectionConfigDefault() {
ConnectionConfig config = new ConnectionConfig();
assertEquals(GraphicsMode.NONE, config.getGraphicsMode());
config.setGraphicsMode(GraphicsMode.BOTH);
assertEquals(GraphicsMode.BOTH, config.getGraphicsMode());
config.setGraphicsMode(GraphicsMode.NONE);
assertEquals(GraphicsMode.NONE, config.getGraphicsMode());
config.setGraphicsMode(null);
assertEquals(GraphicsMode.NONE, config.getGraphicsMode());
}
@@ -51,7 +51,7 @@ public class ProgramSymbolManagerTest {
ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41);
assertNotNull(slot);
assertEquals(9, slot.getWidth());
assertEquals(16, slot.getHeight());
assertEquals(12, slot.getHeight());
assertFalse(slot.isTriplePlane());
// Verify pixel data (Row 0 Col 0 is 1)
@@ -64,10 +64,33 @@ public class ProgramSymbolManagerTest {
int bg = 0xFF000000; // Black
int[] rgb = slot.getRgbPixels(fg, bg);
assertNotNull(rgb);
assertEquals(9 * 16, rgb.length);
assertEquals(9 * 12, rgb.length);
assertEquals(fg, rgb[0]); // Row 0 Col 0 pixel should be foreground Green
}
@Test
public void testExplicitCellDimensions() {
ProgramSymbolManager manager = new ProgramSymbolManager();
manager.setDefaultCellDimensions(9, 16);
byte[] payload = new byte[4 + 18];
payload[0] = 0x01; // Format 1
payload[1] = 0x40; // LCID 0x40
payload[2] = 0x41; // Code point 0x41
payload[3] = 0x02; // RWS 2
payload[4] = (byte) 0x80;
payload[5] = (byte) 0x00;
for (int r = 0; r < 16; r++) {
payload[6 + r] = (byte) 0xFF;
}
manager.loadps(payload);
ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41);
assertNotNull(slot);
assertEquals(9, slot.getWidth());
assertEquals(16, slot.getHeight());
}
@Test
public void testTriplePlaneMultiColorComposite() {
ProgramSymbolManager manager = new ProgramSymbolManager();