This commit is contained in:
2026-08-28 13:00:12 -04:00
parent 79258c0c44
commit 037ad1f941
13 changed files with 1720 additions and 155 deletions
@@ -18,6 +18,13 @@ public class ConnectionConfig {
private int nopIntervalSeconds = 0;
private String terminalName = null; // override terminal type string
private boolean tn3270eEnabled = true;
private boolean tcpNoDelay = true;
private boolean soKeepAlive = true;
private int soTimeoutMs = 0;
private java.util.List<String> luNames = new java.util.ArrayList<>();
private boolean dynamicModel = false;
private int dynamicRows = 24;
private int dynamicCols = 80;
private haus.nightmare.lib3270j.graphics.GraphicsMode graphicsMode = haus.nightmare.lib3270j.graphics.GraphicsMode.BOTH;
public ConnectionConfig() {}
@@ -90,6 +97,36 @@ public class ConnectionConfig {
public String getTerminalName() { return terminalName; }
public void setTerminalName(String name) { this.terminalName = name; }
public boolean isTcpNoDelay() { return tcpNoDelay; }
public void setTcpNoDelay(boolean tcpNoDelay) { this.tcpNoDelay = tcpNoDelay; }
public boolean isSoKeepAlive() { return soKeepAlive; }
public void setSoKeepAlive(boolean soKeepAlive) { this.soKeepAlive = soKeepAlive; }
public int getSoTimeoutMs() { return soTimeoutMs; }
public void setSoTimeoutMs(int soTimeoutMs) { this.soTimeoutMs = soTimeoutMs; }
public java.util.List<String> getLuNames() { return luNames; }
public void setLuNames(java.util.List<String> luNames) {
this.luNames = (luNames != null) ? new java.util.ArrayList<>(luNames) : new java.util.ArrayList<>();
}
public void addLuName(String luName) {
if (luName != null && !luName.trim().isEmpty()) {
this.luNames.add(luName.trim());
}
}
public boolean isDynamicModel() { return dynamicModel; }
public void setDynamicModel(boolean dynamicModel) { this.dynamicModel = dynamicModel; }
public int getDynamicRows() { return dynamicRows; }
public int getDynamicCols() { return dynamicCols; }
public void setDynamicDimensions(int rows, int cols) {
this.dynamicModel = true;
this.dynamicRows = rows;
this.dynamicCols = cols;
}
/**
* 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.
@@ -166,6 +203,9 @@ public class ConnectionConfig {
if (terminalName != null) {
return terminalName;
}
if (dynamicModel) {
return extendedDataStream ? "IBM-DYNAMIC-E" : "IBM-DYNAMIC";
}
return extendedDataStream ? model.getTerminalType() : model.getBaseTerminalType();
}
}
@@ -148,7 +148,27 @@ public class Telnet3270Client {
/** Get the active SSLSession if connected over TLS, or null. */
public javax.net.ssl.SSLSession getSslSession() {
return connection.getSslSession();
return connection != null ? connection.getSslSession() : null;
}
/** Get the NVT processor for ASCII / ANSI terminal processing. */
public haus.nightmare.lib3270j.nvt.NvtProcessor getNvtProcessor() {
return fsm.getNvtProcessor();
}
/** Get the Telnet state machine. */
public TelnetFSM getTelnetFSM() {
return fsm;
}
/** Send an NVT ASCII character in NVT mode. */
public void sendNVTChar(char c) throws IOException {
fsm.sendNVTChar(c);
}
/** Send an NVT ASCII string in NVT mode. */
public void sendNVTString(String s) throws IOException {
fsm.sendNVTString(s);
}
// ========== Convenience input methods ==========
@@ -4,6 +4,7 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer;
import haus.nightmare.lib3270j.screen.ExtendedAttribute;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import java.io.ByteArrayOutputStream;
@@ -638,10 +639,21 @@ public class DataStreamProcessor {
case XA_CHARSET:
ea.cs = (byte) value;
break;
case XA_VALIDATION:
case XA_OUTLINING:
case 0x84: // HoD / 3270 Outlining alternative ID
ea.ol = (byte) value;
break;
case XA_VALIDATION:
ea.vl = (byte) value;
break;
case XA_TRANSPARENCY:
ea.tr = (byte) value;
break;
case XA_INPUT_CONTROL:
// Acknowledged but not visually rendered yet
ea.ic = (byte) value;
break;
case 0x91: // DBCS Asian attributes
ea.db = (byte) value;
break;
}
}
@@ -677,7 +689,71 @@ public class DataStreamProcessor {
outputWrite(caddr[0] & 0xFF);
outputWrite(caddr[1] & 0xFF);
// Buffer contents
byte mode = screen.getReplyMode();
// Buffer contents depending on Reply Mode
if (mode == SF_SRM_XFIELD || mode == SF_SRM_CHAR) {
// Extended Field or Character Mode
byte curFg = 0, curBg = 0, curGr = 0, curCs = 0;
for (int i = 0; i < size; i++) {
ExtendedAttribute ea = screen.getCell(i);
if (ea.isFieldAttribute()) {
// Count number of attribute pairs
int count = 1; // 3270 FA is always present
if (ea.fg != 0) count++;
if (ea.bg != 0) count++;
if (ea.gr != 0) count++;
if (ea.cs != 0) count++;
if (ea.ol != 0) count++;
outputWrite(ORDER_SFE);
outputWrite(count);
outputWrite(XA_3270);
outputWrite(ea.fa & 0xFF);
if (ea.fg != 0) { outputWrite(XA_FOREGROUND); outputWrite(ea.fg & 0xFF); }
if (ea.bg != 0) { outputWrite(XA_BACKGROUND); outputWrite(ea.bg & 0xFF); }
if (ea.gr != 0) {
outputWrite(XA_HIGHLIGHTING);
int xah = XAH_NORMAL;
if ((ea.gr & GR_BLINK) != 0) xah = XAH_BLINK;
else if ((ea.gr & GR_REVERSE) != 0) xah = XAH_REVERSE;
else if ((ea.gr & GR_UNDERLINE) != 0) xah = XAH_UNDERSCORE;
else if ((ea.gr & GR_INTENSIFY) != 0) xah = XAH_INTENSIFY;
outputWrite(xah);
}
if (ea.cs != 0) { outputWrite(XA_CHARSET); outputWrite(ea.cs & 0xFF); }
if (ea.ol != 0) { outputWrite(XA_OUTLINING); outputWrite(ea.ol & 0xFF); }
} else {
if (mode == SF_SRM_CHAR) {
// In character mode, output SA if character attributes differ
if (ea.fg != curFg) {
outputWrite(ORDER_SA); outputWrite(XA_FOREGROUND); outputWrite(ea.fg & 0xFF);
curFg = ea.fg;
}
if (ea.bg != curBg) {
outputWrite(ORDER_SA); outputWrite(XA_BACKGROUND); outputWrite(ea.bg & 0xFF);
curBg = ea.bg;
}
if (ea.gr != curGr) {
outputWrite(ORDER_SA); outputWrite(XA_HIGHLIGHTING);
int xah = XAH_NORMAL;
if ((ea.gr & GR_BLINK) != 0) xah = XAH_BLINK;
else if ((ea.gr & GR_REVERSE) != 0) xah = XAH_REVERSE;
else if ((ea.gr & GR_UNDERLINE) != 0) xah = XAH_UNDERSCORE;
else if ((ea.gr & GR_INTENSIFY) != 0) xah = XAH_INTENSIFY;
outputWrite(xah);
curGr = ea.gr;
}
if (ea.cs != curCs) {
outputWrite(ORDER_SA); outputWrite(XA_CHARSET); outputWrite(ea.cs & 0xFF);
curCs = ea.cs;
}
}
outputWrite(ea.ec & 0xFF);
}
}
} else {
// Standard Field Mode (SF_SRM_FIELD)
for (int i = 0; i < size; i++) {
ExtendedAttribute ea = screen.getCell(i);
if (ea.isFieldAttribute()) {
@@ -687,6 +763,7 @@ public class DataStreamProcessor {
outputWrite(ea.ec & 0xFF);
}
}
}
sendOutput();
}
@@ -726,7 +803,7 @@ public class DataStreamProcessor {
}
}
} else {
// Formatted screen: send modified fields with null suppression per 3270 spec
// Formatted screen: send fields with null suppression per 3270 spec
for (int i = 0; i < size; i++) {
ExtendedAttribute ea = screen.getCell(i);
if (ea.isFieldAttribute() && (all || faIsModified(ea.fa & 0xFF))) {
@@ -779,33 +856,23 @@ public class DataStreamProcessor {
case SF_READ_PART:
processSFReadPartition(data, pos, fieldLen);
break;
case SF_ERASE_RESET: {
boolean alt = (fieldLen >= 4) && ((data[pos + 3] & 0xFF) == SF_ER_ALT);
screen.erase(alt);
graphicsPlane.clear();
gocaDecoder.resetDefaults();
notifyScreenSizeChanged();
case SF_ERASE_RESET:
processEraseReset(data, pos, fieldLen);
break;
}
case SF_SET_REPLY_MODE:
if (fieldLen >= 5) {
screen.setReplyMode((byte) (data[pos + 4] & 0xFF));
}
processSetReplyMode(data, pos, fieldLen);
break;
case SF_CREATE_PART:
if (fieldLen >= 4) {
int pid = data[pos + 3] & 0xFF;
screen.setActivePartition(pid);
log.fine("Created active partition ID=" + pid);
}
graphicsPlane.clear();
gocaDecoder.resetDefaults();
processCreatePartition(data, pos, fieldLen);
break;
case SF_DESTROY_PART:
processDestroyPartition(data, pos, fieldLen);
break;
case SF_ACTIVATE_PART:
processActivatePartition(data, pos, fieldLen);
break;
case SF_OUTBOUND_DS:
if (fieldLen > 5) {
// Outbound DS contains another 3270 command
processRecord(data, pos + 4, fieldLen - 4, false);
}
processOutbound3270DS(data, pos, fieldLen);
break;
case SF_TRANSFER_DATA:
if (ftDft != null) {
@@ -1046,4 +1113,166 @@ public class DataStreamProcessor {
l.onScreenUpdated();
}
}
// ========== Structured Field Handlers & Utilities (Phase 2) ==========
public void processSetReplyMode(byte[] data, int offset, int length) {
if (length >= 5) {
byte mode = (byte) (data[offset + 4] & 0xFF);
screen.setReplyMode(mode);
log.fine("Set reply mode: " + mode);
}
}
public void processCreatePartition(byte[] data, int offset, int length) {
if (length >= 4) {
int pid = data[offset + 3] & 0xFF;
int pRows = screen.getRows();
int pCols = screen.getCols();
if (length >= 8) {
pCols = ((data[offset + 4] & 0xFF) << 8) | (data[offset + 5] & 0xFF);
pRows = ((data[offset + 6] & 0xFF) << 8) | (data[offset + 7] & 0xFF);
if (pRows <= 0) pRows = screen.getRows();
if (pCols <= 0) pCols = screen.getCols();
}
screen.createPartition(pid, pRows, pCols);
log.fine("Created partition pid=" + pid + " (" + pRows + "x" + pCols + ")");
}
graphicsPlane.clear();
gocaDecoder.resetDefaults();
}
public void processDestroyPartition(byte[] data, int offset, int length) {
if (length >= 4) {
int pid = data[offset + 3] & 0xFF;
screen.destroyPartition(pid);
log.fine("Destroyed partition pid=" + pid);
}
}
public void processActivatePartition(byte[] data, int offset, int length) {
if (length >= 4) {
int pid = data[offset + 3] & 0xFF;
screen.activatePartition(pid);
log.fine("Activated partition pid=" + pid);
}
}
public void processEraseReset(byte[] data, int offset, int length) {
boolean alt = (length >= 4) && ((data[offset + 3] & 0xFF) == SF_ER_ALT);
screen.eraseReset(alt);
graphicsPlane.clear();
gocaDecoder.resetDefaults();
notifyScreenSizeChanged();
}
public void processSCSData(byte[] data, int offset, int length) {
log.info("Received embedded SCS printer data (" + length + " bytes)");
}
public void setScreenToBindSize(int primaryRows, int primaryCols, int altRows, int altCols, int bindFlags) {
screen.setScreenToBindSize(primaryRows, primaryCols, altRows, altCols, bindFlags);
notifyScreenSizeChanged();
}
public void setScrSizetoDefault(boolean isDefault) {
screen.setScrSizetoDefault(isDefault);
notifyScreenSizeChanged();
}
public static byte[] doubleFF(byte[] data, int length) {
int count = countFF(data, length);
if (count == 0 && data.length == length) {
return data;
}
byte[] result = new byte[length + count];
int j = 0;
for (int i = 0; i < length; i++) {
byte b = data[i];
result[j++] = b;
if ((b & 0xFF) == 0xFF) {
result[j++] = (byte) 0xFF;
}
}
return result;
}
public static int countFF(byte[] data, int length) {
int count = 0;
int len = Math.min(length, data.length);
for (int i = 0; i < len; i++) {
if ((data[i] & 0xFF) == 0xFF) {
count++;
}
}
return count;
}
public static byte[] sizeuparray(byte[] data, int count) {
byte[] newArr = new byte[data.length + count];
System.arraycopy(data, 0, newArr, 0, Math.min(data.length, newArr.length));
return newArr;
}
public static int cmd2ebc(int commandCode) {
return DS3270Constants.cmd2ebc(commandCode);
}
public static int cmd2ebc(short commandCode) {
return DS3270Constants.cmd2ebc(commandCode & 0xFFFF);
}
public static int x2bin(int ebcByte) {
return DS3270Constants.x2bin(ebcByte);
}
public static int x2bin(short ebcByte) {
return DS3270Constants.x2bin(ebcByte & 0xFFFF);
}
public boolean validateStructuredFieldHeader(byte[] data, int offset, int length) {
if (length < 3) return false;
int fieldLen = ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF);
return fieldLen >= 3 && fieldLen <= length;
}
public int decodeAddressingMode(int flags) {
return (flags & 0x01) != 0 ? 14 : 12;
}
public boolean validatePartitionId(int pid) {
return pid >= 0 && pid <= 255;
}
public void processOutbound3270DS(byte[] data, int offset, int fieldLen) {
if (fieldLen > 5) {
int pid = data[offset + 3] & 0xFF;
screen.setActivePartition(pid);
processRecord(data, offset + 4, fieldLen - 4, false);
}
}
public void processQueryListOrder(byte[] data, int offset, int length) {
processSFReadPartition(data, offset, length);
}
public void processImplicitPartition(byte[] data, int offset, int length) {
screen.setActivePartition(0);
}
public void processModifyPartition(byte[] data, int offset, int length) {
if (length >= 4) {
int pid = data[offset + 3] & 0xFF;
log.fine("Modify partition pid=" + pid);
}
}
public void processResetPartition(byte[] data, int offset, int length) {
screen.setActivePartition(0);
screen.erase(false);
}
public void processNullStructuredField() {
log.fine("Processed null structured field");
}
}
@@ -0,0 +1,400 @@
package haus.nightmare.lib3270j.nvt;
import haus.nightmare.lib3270j.screen.ExtendedAttribute;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
/**
* Network Virtual Terminal (NVT) processor.
* Handles ASCII / ANSI VT100 character stream processing, cursor positioning,
* escape sequence decoding, and NVT character/string transmission.
*/
public class NvtProcessor {
private static final Logger log = Logger.getLogger(NvtProcessor.class.getName());
private final ScreenBuffer screenBuffer;
private final EbcdicTranslator translator;
private final List<ScreenUpdateListener> screenListeners = new CopyOnWriteArrayList<>();
// Escape sequence parser states
private static final int STATE_NORMAL = 0;
private static final int STATE_ESC = 1;
private static final int STATE_CSI = 2;
private int parseState = STATE_NORMAL;
private final ByteArrayOutputStream escBuffer = new ByteArrayOutputStream();
// Output sender callback
private OutputSender outputSender;
// Graphic Rendition state
private byte currentFg = 0;
private byte currentBg = 0;
private byte currentGr = 0;
// Saved cursor position
private int savedCursorRow = 0;
private int savedCursorCol = 0;
@FunctionalInterface
public interface OutputSender {
void sendRaw(byte[] data) throws IOException;
}
public NvtProcessor(ScreenBuffer screenBuffer, EbcdicTranslator translator) {
this.screenBuffer = screenBuffer;
this.translator = translator;
}
public void setOutputSender(OutputSender outputSender) {
this.outputSender = outputSender;
}
public void addScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.add(l);
}
/**
* Process incoming ASCII NVT data bytes.
*/
public synchronized void processNVTData(byte[] data, int offset, int length) {
if (length <= 0) return;
int rows = screenBuffer.getRows();
int cols = screenBuffer.getCols();
int size = rows * cols;
int curAddr = screenBuffer.getCursorAddress();
for (int i = offset; i < offset + length; i++) {
int b = data[i] & 0xFF;
if (parseState == STATE_NORMAL) {
if (b == 0x1B) { // ESC
parseState = STATE_ESC;
escBuffer.reset();
escBuffer.write(b);
} else if (b == 0x0D) { // CR
int r = curAddr / cols;
curAddr = r * cols;
} else if (b == 0x0A) { // LF
int r = curAddr / cols;
int c = curAddr % cols;
r++;
if (r >= rows) {
scrollUp();
r = rows - 1;
}
curAddr = r * cols + c;
} else if (b == 0x08 || b == 0x7F) { // BS or DEL
int c = curAddr % cols;
if (c > 0) {
curAddr--;
}
} else if (b == 0x09) { // TAB
int c = curAddr % cols;
int nextTab = ((c / 8) + 1) * 8;
if (nextTab >= cols) nextTab = cols - 1;
curAddr = (curAddr / cols) * cols + nextTab;
} else if (b == 0x0C) { // FF
screenBuffer.clear();
curAddr = 0;
} else if (b >= 0x20 && b < 0xFF) { // Printable ASCII
char ch = (char) b;
int ebc = translator.unicodeToEbcdic(ch);
ExtendedAttribute cell = screenBuffer.getCell(curAddr);
cell.clear();
cell.ec = (byte) (ebc >= 0 ? ebc : 0x40);
cell.ucs4 = ch;
cell.fg = currentFg;
cell.bg = currentBg;
cell.gr = currentGr;
curAddr++;
if (curAddr >= size) {
scrollUp();
curAddr = (rows - 1) * cols;
}
}
} else if (parseState == STATE_ESC) {
escBuffer.write(b);
if (b == '[') {
parseState = STATE_CSI;
} else if (b == '7') { // Save cursor
savedCursorRow = curAddr / cols;
savedCursorCol = curAddr % cols;
parseState = STATE_NORMAL;
} else if (b == '8') { // Restore cursor
curAddr = Math.min(rows - 1, savedCursorRow) * cols + Math.min(cols - 1, savedCursorCol);
parseState = STATE_NORMAL;
} else if (b == 'c') { // RIS - Reset to Initial State
screenBuffer.clear();
curAddr = 0;
currentFg = 0;
currentBg = 0;
currentGr = 0;
parseState = STATE_NORMAL;
} else {
// Unknown 2-byte escape, finish
parseState = STATE_NORMAL;
}
} else if (parseState == STATE_CSI) {
escBuffer.write(b);
// CSI parameter/intermediate bytes: 0x20..0x3F, final bytes: 0x40..0x7E
if (b >= 0x40 && b <= 0x7E) {
byte[] seq = escBuffer.toByteArray();
curAddr = processAnsiEscapeSequence(seq, curAddr, rows, cols);
parseState = STATE_NORMAL;
}
}
}
screenBuffer.setCursorAddress(curAddr);
screenBuffer.markAllChanged();
screenBuffer.updateDisplaySnapshot();
notifyScreenUpdated();
}
/**
* Parse and execute an ANSI CSI escape sequence.
* Returns updated cursor address.
*/
public int processAnsiEscapeSequence(byte[] seq, int curAddr, int rows, int cols) {
if (seq.length < 3) return curAddr;
int finalByte = seq[seq.length - 1] & 0xFF;
String paramStr = new String(seq, 2, seq.length - 3, StandardCharsets.US_ASCII);
String[] params = paramStr.split(";");
int r = curAddr / cols;
int c = curAddr % cols;
switch (finalByte) {
case 'H': // CUP - Cursor Position
case 'f': // HVP - Horizontal and Vertical Position
{
int p1 = parseParam(params, 0, 1) - 1;
int p2 = parseParam(params, 1, 1) - 1;
r = Math.max(0, Math.min(rows - 1, p1));
c = Math.max(0, Math.min(cols - 1, p2));
return r * cols + c;
}
case 'A': // CUU - Cursor Up
{
int count = parseParam(params, 0, 1);
r = Math.max(0, r - count);
return r * cols + c;
}
case 'B': // CUD - Cursor Down
{
int count = parseParam(params, 0, 1);
r = Math.min(rows - 1, r + count);
return r * cols + c;
}
case 'C': // CUF - Cursor Forward
{
int count = parseParam(params, 0, 1);
c = Math.min(cols - 1, c + count);
return r * cols + c;
}
case 'D': // CUB - Cursor Back
{
int count = parseParam(params, 0, 1);
c = Math.max(0, c - count);
return r * cols + c;
}
case 'J': // ED - Erase in Display
{
int mode = parseParam(params, 0, 0);
if (mode == 0) { // Cursor to end
for (int i = curAddr; i < rows * cols; i++) clearCell(i);
} else if (mode == 1) { // Beginning to cursor
for (int i = 0; i <= curAddr; i++) clearCell(i);
} else if (mode == 2 || mode == 3) { // Entire screen
screenBuffer.clear();
return 0;
}
return curAddr;
}
case 'K': // EL - Erase in Line
{
int mode = parseParam(params, 0, 0);
int lineStart = r * cols;
int lineEnd = lineStart + cols;
if (mode == 0) { // Cursor to end of line
for (int i = curAddr; i < lineEnd; i++) clearCell(i);
} else if (mode == 1) { // Start of line to cursor
for (int i = lineStart; i <= curAddr; i++) clearCell(i);
} else if (mode == 2) { // Entire line
for (int i = lineStart; i < lineEnd; i++) clearCell(i);
}
return curAddr;
}
case 'm': // SGR - Select Graphic Rendition
{
if (params.length == 0 || (params.length == 1 && params[0].isEmpty())) {
currentFg = 0;
currentBg = 0;
currentGr = 0;
} else {
for (String p : params) {
if (p.isEmpty()) continue;
try {
int code = Integer.parseInt(p);
applySgr(code);
} catch (NumberFormatException ignored) {}
}
}
return curAddr;
}
case 's': // Save cursor
savedCursorRow = r;
savedCursorCol = c;
return curAddr;
case 'u': // Restore cursor
r = Math.min(rows - 1, savedCursorRow);
c = Math.min(cols - 1, savedCursorCol);
return r * cols + c;
default:
return curAddr;
}
}
private int parseParam(String[] params, int idx, int defaultVal) {
if (params != null && idx < params.length && !params[idx].trim().isEmpty()) {
try {
return Integer.parseInt(params[idx].trim());
} catch (NumberFormatException ignored) {}
}
return defaultVal;
}
private void clearCell(int addr) {
ExtendedAttribute cell = screenBuffer.getCell(addr);
cell.clear();
cell.ec = 0;
cell.ucs4 = ' ';
}
private void scrollUp() {
int rows = screenBuffer.getRows();
int cols = screenBuffer.getCols();
for (int r = 0; r < rows - 1; r++) {
for (int c = 0; c < cols; c++) {
int dst = r * cols + c;
int src = (r + 1) * cols + c;
screenBuffer.getCell(dst).copyFrom(screenBuffer.getCell(src));
}
}
// Clear last line
int lastRowStart = (rows - 1) * cols;
for (int c = 0; c < cols; c++) {
clearCell(lastRowStart + c);
}
}
private void applySgr(int code) {
switch (code) {
case 0: // Reset
currentFg = 0;
currentBg = 0;
currentGr = 0;
break;
case 1: // Bold / Bright
currentGr |= GR_INTENSIFY;
break;
case 4: // Underline
currentGr |= GR_UNDERLINE;
break;
case 5: // Blink
currentGr |= GR_BLINK;
break;
case 7: // Reverse
currentGr |= GR_REVERSE;
break;
case 22: // Normal intensity
currentGr &= ~GR_INTENSIFY;
break;
case 24: // Not underlined
currentGr &= ~GR_UNDERLINE;
break;
case 25: // Not blinking
currentGr &= ~GR_BLINK;
break;
case 27: // Positive image (not reverse)
currentGr &= ~GR_REVERSE;
break;
case 30: currentFg = (byte) HOST_COLOR_NEUTRAL_BLACK; break;
case 31: currentFg = (byte) HOST_COLOR_RED; break;
case 32: currentFg = (byte) HOST_COLOR_GREEN; break;
case 33: currentFg = (byte) HOST_COLOR_YELLOW; break;
case 34: currentFg = (byte) HOST_COLOR_BLUE; break;
case 35: currentFg = (byte) HOST_COLOR_PINK; break;
case 36: currentFg = (byte) HOST_COLOR_TURQUOISE; break;
case 37: currentFg = (byte) HOST_COLOR_NEUTRAL_WHITE; break;
case 39: currentFg = 0; break;
case 40: currentBg = (byte) HOST_COLOR_NEUTRAL_BLACK; break;
case 41: currentBg = (byte) HOST_COLOR_RED; break;
case 42: currentBg = (byte) HOST_COLOR_GREEN; break;
case 43: currentBg = (byte) HOST_COLOR_YELLOW; break;
case 44: currentBg = (byte) HOST_COLOR_BLUE; break;
case 45: currentBg = (byte) HOST_COLOR_PINK; break;
case 46: currentBg = (byte) HOST_COLOR_TURQUOISE; break;
case 47: currentBg = (byte) HOST_COLOR_NEUTRAL_WHITE; break;
case 49: currentBg = 0; break;
}
}
/**
* Send a single character in NVT mode.
*/
public void sendNVTChar(char c) throws IOException {
if (outputSender != null) {
if (c == '\n') {
outputSender.sendRaw(new byte[] { (byte) '\r', (byte) '\n' });
} else {
outputSender.sendRaw(new byte[] { (byte) c });
}
}
}
/**
* Send a string in NVT mode with standard CRLF normalization.
*/
public void sendNVTString(String s) throws IOException {
if (s == null || outputSender == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\n') {
out.write('\r');
out.write('\n');
} else if (c == '\r') {
if (i + 1 < s.length() && s.charAt(i + 1) == '\n') {
// Handled on next iteration
} else {
out.write('\r');
out.write('\n');
}
} else {
out.write((byte) c);
}
}
outputSender.sendRaw(out.toByteArray());
}
private void notifyScreenUpdated() {
for (ScreenUpdateListener l : screenListeners) {
l.onScreenUpdated();
}
}
}
@@ -195,6 +195,9 @@ public final class DS3270Constants {
public static final int SF_SRM_XFIELD = 0x01;
public static final int SF_SRM_CHAR = 0x02;
public static final int SF_CREATE_PART = 0x0c;
public static final int SF_DESTROY_PART = 0x0d;
public static final int SF_ACTIVATE_PART = 0x0e;
public static final int SF_MODIFY_PART = 0x0f;
public static final int SF_OUTBOUND_DS = 0x40;
public static final int SF_TRANSFER_DATA = 0xd0;
@@ -367,19 +370,57 @@ public final class DS3270Constants {
}
}
/** Get human-readable command name. */
private static final byte[] X2BIN_TABLE = new byte[256];
static {
java.util.Arrays.fill(X2BIN_TABLE, (byte) -1);
for (int i = 0; i < CODE_TABLE.length; i++) {
X2BIN_TABLE[CODE_TABLE[i] & 0xFF] = (byte) i;
}
}
/**
* Fast lookup for 6-bit 3270 buffer address decoding.
* Maps an EBCDIC buffer address character byte to its 6-bit binary value (0..63).
*/
public static int x2bin(int ebcByte) {
int idx = ebcByte & 0xFF;
byte val = X2BIN_TABLE[idx];
return val >= 0 ? (val & 0x3F) : (idx & 0x3F);
}
/**
* Translate 3270 command codes to EBCDIC byte values.
*/
public static int cmd2ebc(int commandCode) {
switch (commandCode) {
case CMD_W: case SNA_CMD_W: return SNA_CMD_W; // 0xF1
case CMD_RB: case SNA_CMD_RB: return SNA_CMD_RB; // 0xF2
case CMD_WSF: case SNA_CMD_WSF: return SNA_CMD_WSF; // 0xF3
case CMD_EW: case SNA_CMD_EW: return SNA_CMD_EW; // 0xF5
case CMD_RM: case SNA_CMD_RM: return SNA_CMD_RM; // 0xF6
case CMD_RMA: case SNA_CMD_RMA: return SNA_CMD_RMA; // 0x6E
case CMD_EAU: case SNA_CMD_EAU: return SNA_CMD_EAU; // 0x6F
case CMD_EWA: case SNA_CMD_EWA: return SNA_CMD_EWA; // 0x7E
case CMD_NOP: return CMD_NOP; // 0x03
default: return commandCode & 0xFF;
}
}
/**
* Get descriptive name of a 3270 command code.
*/
public static String commandName(int cmd) {
switch (cmd) {
case CMD_W: case SNA_CMD_W: return "Write";
case CMD_EW: case SNA_CMD_EW: return "EraseWrite";
case CMD_EWA: case SNA_CMD_EWA: return "EraseWriteAlternate";
case CMD_RB: case SNA_CMD_RB: return "Read Buffer";
case CMD_WSF: case SNA_CMD_WSF: return "Write Structured Field";
case CMD_EW: case SNA_CMD_EW: return "Erase/Write";
case CMD_RM: case SNA_CMD_RM: return "Read Modified";
case CMD_RMA: case SNA_CMD_RMA: return "Read Modified All";
case CMD_EAU: case SNA_CMD_EAU: return "Erase All Unprotected";
case CMD_WSF: case SNA_CMD_WSF: return "WriteStructuredField";
case CMD_NOP: return "NoOp";
default: return String.format("Unknown(0x%02x)", cmd);
case CMD_EWA: case SNA_CMD_EWA: return "Erase/Write Alternate";
case CMD_NOP: return "NOP";
default: return "0x" + Integer.toHexString(cmd);
}
}
}
@@ -91,6 +91,25 @@ public final class TN3270EConstants {
public static final int UNBIND_CLEANUP = 0x0f;
public static final int UNBIND_BAD_SENSE = 0xfe;
// SNA Data Flow Control (DFC) Commands
public static final int DFC_CLEAR = 0x01;
public static final int DFC_CANCEL = 0x02;
public static final int DFC_RQR = 0x03;
public static final int DFC_STSN = 0x04;
public static final int DFC_SIGNAL = 0x05;
public static final int DFC_LUSTAT = 0x06;
public static final int DFC_BID = 0x07;
public static final int DFC_SHUTC = 0x08;
public static final int DFC_SHUTD = 0x09;
// IBM Host On-Demand Status Codes
public static final int STATUS_CONNECTING = 650;
public static final int STATUS_NEGOTIATING = 651;
public static final int STATUS_CONNECTED = 652;
public static final int STATUS_SECURITY = 654;
public static final int STATUS_BIND_ERROR = 655;
public static final int STATUS_DISCONNECTED = 656;
// Name lookups for tracing
private static final String[] REASON_NAMES = {
@@ -128,6 +147,38 @@ public final class TN3270EConstants {
return code >= 0 && code < HRSP_FLAG_NAMES.length ? HRSP_FLAG_NAMES[code] : "??";
}
public static String unbindReasonName(int code) {
switch (code) {
case UNBIND_NORMAL: return "NORMAL";
case UNBIND_BIND_FORTHCOMING: return "BIND-FORTHCOMING";
case UNBIND_VR_INOPERATIVE: return "VR-INOPERATIVE";
case UNBIND_RX_INOPERATIVE: return "RX-INOPERATIVE";
case UNBIND_HRESET: return "HRESET";
case UNBIND_SSCP_GONE: return "SSCP-GONE";
case UNBIND_VR_DEACTIVATED: return "VR-DEACTIVATED";
case UNBIND_LU_FAILURE_PERM: return "LU-FAILURE-PERM";
case UNBIND_LU_FAILURE_TEMP: return "LU-FAILURE-TEMP";
case UNBIND_CLEANUP: return "CLEANUP";
case UNBIND_BAD_SENSE: return "BAD-SENSE";
default: return "UNKNOWN-UNBIND(0x" + Integer.toHexString(code) + ")";
}
}
public static String dfcCommandName(int code) {
switch (code) {
case DFC_CLEAR: return "CLEAR";
case DFC_CANCEL: return "CANCEL";
case DFC_RQR: return "RQR";
case DFC_STSN: return "STSN";
case DFC_SIGNAL: return "SIGNAL";
case DFC_LUSTAT: return "LUSTAT";
case DFC_BID: return "BID";
case DFC_SHUTC: return "SHUTC";
case DFC_SHUTD: return "SHUTD";
default: return "UNKNOWN-DFC(0x" + Integer.toHexString(code) + ")";
}
}
/** Format a function set as a human-readable string. */
public static String functionNames(boolean[] funcs) {
StringBuilder sb = new StringBuilder();
@@ -33,6 +33,15 @@ public class ExtendedAttribute {
/** DBCS state. */
public byte db;
/** Outlining attribute (box borders/grid lines). */
public byte ol;
/** Validation attribute. */
public byte vl;
/** Transparency attribute. */
public byte tr;
/**
* Unicode character for display (set by translation from ec, or directly in NVT mode).
*/
@@ -48,6 +57,9 @@ public class ExtendedAttribute {
cs = 0;
ic = 0;
db = 0;
ol = 0;
vl = 0;
tr = 0;
ucs4 = 0;
}
@@ -61,6 +73,9 @@ public class ExtendedAttribute {
this.cs = other.cs;
this.ic = other.ic;
this.db = other.db;
this.ol = other.ol;
this.vl = other.vl;
this.tr = other.tr;
this.ucs4 = other.ucs4;
}
@@ -156,6 +156,69 @@ public class ScreenBuffer {
updateDisplaySnapshot();
}
/**
* Configure screen dimensions matching SNA BIND parameters.
*/
public synchronized void setScreenToBindSize(int primaryRows, int primaryCols, int altRows, int altCols, int bindFlags) {
if (primaryRows > 0) this.defRows = primaryRows;
if (primaryCols > 0) this.defCols = primaryCols;
if (altRows > 0) this.altRows = altRows;
if (altCols > 0) this.altCols = altCols;
this.maxRows = Math.max(defRows, this.altRows);
this.maxCols = Math.max(defCols, this.altCols);
allocateBuffers();
updateDisplaySnapshot();
}
/**
* Switch between default (primary) and alternate screen sizes.
*/
public synchronized void setScrSizetoDefault(boolean isDefault) {
erase(!isDefault);
}
// ========== Partitions ==========
private final java.util.Map<Integer, PartitionInfo> partitions = new java.util.HashMap<>();
public static class PartitionInfo {
public final int pid;
public final int rows;
public final int cols;
public PartitionInfo(int pid, int rows, int cols) {
this.pid = pid;
this.rows = rows;
this.cols = cols;
}
}
public synchronized void createPartition(int pid, int pRows, int pCols) {
partitions.put(pid, new PartitionInfo(pid, pRows, pCols));
this.activePartition = pid;
this.explicitPartitionActive = true;
}
public synchronized void destroyPartition(int pid) {
partitions.remove(pid);
if (this.activePartition == pid) {
this.activePartition = 0;
this.explicitPartitionActive = !partitions.isEmpty();
}
}
public synchronized void activatePartition(int pid) {
this.activePartition = pid;
this.explicitPartitionActive = (pid != 0);
}
public synchronized void eraseReset(boolean alt) {
partitions.clear();
this.activePartition = 0;
this.explicitPartitionActive = false;
erase(alt);
}
// ========== Cursor ==========
public int getCursorAddress() { return cursorAddress; }
public synchronized void setCursorAddress(int addr) {
@@ -172,7 +235,7 @@ public class ScreenBuffer {
public void setReplyMode(byte mode) { this.replyMode = mode; }
public int getActivePartition() { return activePartition; }
public void setActivePartition(int pid) { this.activePartition = pid; this.explicitPartitionActive = true; }
public void setActivePartition(int pid) { this.activePartition = pid; this.explicitPartitionActive = (pid != 0); }
public boolean isExplicitPartitionActive() { return explicitPartitionActive; }
// ========== Screen erase ==========
@@ -50,8 +50,11 @@ public class TelnetConnection {
javax.net.ssl.SSLContext sslContext = haus.nightmare.lib3270j.tls.TlsTrustManager.createSSLContext(config);
javax.net.ssl.SSLSocketFactory ssf = sslContext.getSocketFactory();
javax.net.ssl.SSLSocket sslSocket = (javax.net.ssl.SSLSocket) ssf.createSocket();
sslSocket.setKeepAlive(true);
sslSocket.setTcpNoDelay(true);
sslSocket.setKeepAlive(config.isSoKeepAlive());
sslSocket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
sslSocket.setSoTimeout(config.getSoTimeoutMs());
}
sslSocket.connect(new InetSocketAddress(config.getHost(), config.getPort()),
config.getConnectTimeoutMs());
sslSocket.startHandshake();
@@ -67,9 +70,12 @@ public class TelnetConnection {
} else {
log.info("Connecting to " + config.getHost() + ":" + config.getPort());
socket = new Socket();
socket.setKeepAlive(true);
socket.setKeepAlive(config.isSoKeepAlive());
socket.setOOBInline(true);
socket.setTcpNoDelay(true);
socket.setTcpNoDelay(config.isTcpNoDelay());
if (config.getSoTimeoutMs() > 0) {
socket.setSoTimeout(config.getSoTimeoutMs());
}
socket.connect(new InetSocketAddress(config.getHost(), config.getPort()),
config.getConnectTimeoutMs());
}
@@ -104,6 +110,24 @@ public class TelnetConnection {
}
}
/**
* Send payload with automatic doubling of 0xFF (IAC escaping).
*/
public synchronized void sendEscaped(byte[] data, int offset, int length) throws IOException {
if (outputStream == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream(length + 16);
for (int i = offset; i < offset + length; i++) {
int b = data[i] & 0xFF;
out.write(b);
if (b == IAC) {
out.write(IAC);
}
}
byte[] escaped = out.toByteArray();
outputStream.write(escaped, 0, escaped.length);
outputStream.flush();
}
/**
* Disconnect from the host.
*/
@@ -9,6 +9,7 @@ import haus.nightmare.lib3270j.listener.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
@@ -57,10 +58,15 @@ public class TelnetFSM {
private boolean tn3270eBound;
private final boolean[] eFuncs = new boolean[8]; // Negotiated TN3270E functions
private int eXmitSeq;
private int lastRcvSeq;
private short lastRespType;
private short lastRespCode;
private int responseRequired = RSF_NO_RESPONSE;
private boolean deferredWillTtype;
private boolean tn3270eDeviceTypeSent;
private int ttypeIndex = 0;
private int luIndex = 0;
private final java.util.Map<String, List<String>> devicePools = new java.util.HashMap<>();
private List<String> getCandidateTerminalTypes() {
List<String> list = new ArrayList<>();
@@ -68,6 +74,9 @@ public class TelnetFSM {
list.add(config.getTerminalName().trim());
return list;
}
if (config.isDynamicModel()) {
list.add(config.isExtendedDataStream() ? "IBM-DYNAMIC-E" : "IBM-DYNAMIC");
}
TerminalModel model = config.getModel();
list.add(model.getTerminalType());
list.add(model.getBaseTerminalType());
@@ -92,6 +101,7 @@ public class TelnetFSM {
private final ConnectionConfig config;
private final ScreenBuffer screenBuffer;
private final DataStreamProcessor dsProcessor;
private final haus.nightmare.lib3270j.nvt.NvtProcessor nvtProcessor;
private volatile ConnectionState connectionState = ConnectionState.NOT_CONNECTED;
// Listeners
@@ -108,6 +118,12 @@ public class TelnetFSM {
this.config = config;
this.screenBuffer = screenBuffer;
this.dsProcessor = dsProcessor;
this.nvtProcessor = new haus.nightmare.lib3270j.nvt.NvtProcessor(screenBuffer, new haus.nightmare.lib3270j.charset.EbcdicTranslator());
this.nvtProcessor.setOutputSender(this::sendBytes);
}
public haus.nightmare.lib3270j.nvt.NvtProcessor getNvtProcessor() {
return nvtProcessor;
}
public void setConnection(TelnetConnection connection) {
@@ -115,7 +131,10 @@ public class TelnetFSM {
}
public void addConnectionListener(ConnectionListener l) { connectionListeners.add(l); }
public void addScreenUpdateListener(ScreenUpdateListener l) { screenListeners.add(l); }
public void addScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.add(l);
nvtProcessor.addScreenUpdateListener(l);
}
public ConnectionState getConnectionState() { return connectionState; }
public boolean[] getMyOpts() { return myOpts; }
@@ -135,8 +154,10 @@ public class TelnetFSM {
tn3270eSubmode = TN3270ESubmode.UNBOUND;
tn3270eBound = false;
eXmitSeq = 0;
lastRcvSeq = 0;
deferredWillTtype = false;
ttypeIndex = 0;
luIndex = 0;
ibuf.reset();
sbbuf.reset();
@@ -148,6 +169,7 @@ public class TelnetFSM {
eFuncs[FUNC_DATA_STREAM_CTL] = true;
eFuncs[FUNC_CONTENTION_RESOLUTION] = true;
statusDisplay(STATUS_CONNECTING, "Connecting to host");
changeState(ConnectionState.TELNET_PENDING);
}
@@ -167,7 +189,9 @@ public class TelnetFSM {
if (connectionState == ConnectionState.TELNET_PENDING) {
changeState(ConnectionState.CONNECTED_NVT);
}
if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
if (connectionState.isNvt()) {
nvtProcessor.processNVTData(buf, start, i - start);
} else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) {
ibuf.write(buf, start, i - start);
}
}
@@ -236,11 +260,15 @@ public class TelnetFSM {
changeState(ConnectionState.CONNECTED_NVT);
}
if (connectionState.isNvt()) {
nvtProcessor.processNVTData(new byte[] { (byte) c }, 0, 1);
return;
}
// 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)
}
// ========== TNS_IAC ==========
@@ -431,7 +459,9 @@ public class TelnetFSM {
private void processSBIAC(int c) {
if (c == SE) {
// Sub-negotiation complete
processSubNegotiation(sbbuf.toByteArray());
byte[] sbData = sbbuf.toByteArray();
sbbuf.reset();
processSubNegotiation(sbData);
state = TNS_DATA;
} else if (c == IAC) {
// Escaped IAC within sub-negotiation
@@ -440,6 +470,7 @@ public class TelnetFSM {
} else {
// Shouldn't happen, but recover
log.warning("Unexpected byte " + c + " after IAC in SB");
sbbuf.reset();
state = TNS_DATA;
}
}
@@ -548,7 +579,16 @@ public class TelnetFSM {
}
private void sendTN3270EDeviceTypeRequest() {
String termType = config.getEffectiveTerminalType();
List<String> candidates = getCandidateTerminalTypes();
String termType = candidates.get(Math.min(ttypeIndex, candidates.size() - 1));
String currentLu = null;
List<String> lus = config.getLuNames();
if (lus != null && !lus.isEmpty() && luIndex < lus.size()) {
currentLu = lus.get(luIndex);
} else if (config.getLuName() != null && !config.getLuName().isEmpty()) {
currentLu = config.getLuName();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(IAC);
out.write(SB);
@@ -559,9 +599,9 @@ public class TelnetFSM {
out.write((byte) ch);
}
// Add LU name if specified
if (config.getLuName() != null && !config.getLuName().isEmpty()) {
if (currentLu != null && !currentLu.isEmpty()) {
out.write(OP_CONNECT);
for (char ch : config.getLuName().toCharArray()) {
for (char ch : currentLu.toCharArray()) {
out.write((byte) ch);
}
}
@@ -569,7 +609,7 @@ public class TelnetFSM {
out.write(SE);
sendBytes(out.toByteArray());
log.warning(">>> SENT SB TN3270E DEVICE-TYPE REQUEST " + termType +
(config.getLuName() != null ? " CONNECT " + config.getLuName() : "") + " SE");
(currentLu != null ? " CONNECT " + currentLu : "") + " SE");
}
private void handleTN3270EDeviceType(byte[] data) {
@@ -584,23 +624,37 @@ public class TelnetFSM {
// Check if REJECT
if (pos < data.length && (data[pos] & 0xFF) == OP_REJECT) {
pos++;
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_4) {
log.warning("TN3270E device-type rejected (" +
TN3270EConstants.reasonName(reason) + "), retrying with IBM-3278-2");
config.setModel(TerminalModel.IBM_3278_2);
int reason = REASON_UNSUPPORTED_REQ;
if (pos < data.length && (data[pos] & 0xFF) == OP_REASON) {
pos++;
}
if (pos < data.length) {
reason = data[pos] & 0xFF;
}
log.warning("TN3270E device-type rejected: " + TN3270EConstants.reasonName(reason));
// 1. Try next LU in pool if available
List<String> lus = config.getLuNames();
if (lus != null && luIndex + 1 < lus.size()) {
luIndex++;
log.info("Retrying TN3270E device-type with next LU: " + lus.get(luIndex));
sendTN3270EDeviceTypeRequest();
return;
}
// 2. Try fallback terminal type candidate if available
List<String> candidates = getCandidateTerminalTypes();
if (ttypeIndex + 1 < candidates.size() - 1) { // exclude UNKNOWN
ttypeIndex++;
log.info("Retrying TN3270E device-type with next candidate: " + candidates.get(ttypeIndex));
sendTN3270EDeviceTypeRequest();
return;
}
log.warning("TN3270E device-type rejected: " + TN3270EConstants.reasonName(reason));
// Fall back to plain TN3270
myOpts[TELOPT_TN3270E] = false;
hisOpts[TELOPT_TN3270E] = false;
sendCommand(WONT, TELOPT_TN3270E);
// Send deferred WILL TTYPE if needed
if (deferredWillTtype) {
@@ -832,102 +886,21 @@ public class TelnetFSM {
break;
case DT_BIND_IMAGE:
tn3270eBound = true;
// Parse BIND image for screen dimensions (SNA BIND format)
{
int bindLen = data.length - EH_SIZE;
StringBuilder bindHex = new StringBuilder();
for (int bi = EH_SIZE; bi < data.length && bi < EH_SIZE + 40; bi++) {
bindHex.append(String.format("%02x ", data[bi] & 0xFF));
}
log.info("Received BIND image (" + bindLen + " bytes)" +
" responseFlag=" + responseFlag + " raw: " + bindHex.toString().trim());
// SNA BIND RU offsets (from 3270ds.h):
// Byte 20 = RD (default rows), Byte 21 = CD (default cols)
// Byte 22 = RA (alternate rows), Byte 23 = CA (alternate cols)
// Byte 24 = SSIZE (screen size indicator)
final int BIND_OFF_RD = 20, BIND_OFF_CD = 21;
final int BIND_OFF_RA = 22, BIND_OFF_CA = 23;
final int BIND_OFF_SSIZE = 24;
if (bindLen > BIND_OFF_SSIZE) {
int ssize = data[EH_SIZE + BIND_OFF_SSIZE] & 0xFF;
int bindRd = data[EH_SIZE + BIND_OFF_RD] & 0xFF;
int bindCd = data[EH_SIZE + BIND_OFF_CD] & 0xFF;
int bindRa, bindCa;
switch (ssize) {
case 0x00: case 0x02:
// Default model 2 dimensions for both default and alt
bindRd = 24; bindCd = 80;
bindRa = 24; bindCa = 80;
break;
case 0x03:
// Default = 24x80, alternate = configured model max
bindRd = 24; bindCd = 80;
bindRa = screenBuffer.getMaxRows();
bindCa = screenBuffer.getMaxCols();
break;
case 0x7E:
// Both default and alternate = specified values
bindRa = bindRd; bindCa = bindCd;
break;
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;
break;
default:
// Unknown SSIZE - use model defaults
bindRa = screenBuffer.getMaxRows();
bindCa = screenBuffer.getMaxCols();
break;
}
log.info("BIND SSIZE=0x" + String.format("%02x", ssize) +
" default=" + bindRd + "x" + bindCd +
" alt=" + bindRa + "x" + bindCa);
// Apply dimensions — constrain to model max
int maxR = screenBuffer.getMaxRows();
int maxC = screenBuffer.getMaxCols();
if (bindRa > 0 && bindCa > 0 && bindRa <= maxR && bindCa <= maxC) {
screenBuffer.setAlternateDimensions(bindRa, bindCa);
}
}
}
// Clear and reset screen for new session
screenBuffer.erase(false);
changeState(ConnectionState.CONNECTED_TN3270E);
tn3270eSubmode = TN3270ESubmode.E_3270;
notifyScreenUpdate();
// Send positive response if required (critical for ISPF NEWAPPL)
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
process_bind(data, responseFlag, seqNumber);
break;
case DT_UNBIND:
log.info("Received UNBIND responseFlag=" + responseFlag);
tn3270eBound = false;
// Restore alternate dimensions to configured model max (per x3270)
screenBuffer.setAlternateDimensions(
screenBuffer.getMaxRows(), screenBuffer.getMaxCols());
// Clear screen on UNBIND — essential for ISPF NEWAPPL transitions
screenBuffer.clear();
// Send positive response BEFORE changing state (host expects it)
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
changeState(ConnectionState.CONNECTED_UNBOUND);
tn3270eSubmode = TN3270ESubmode.UNBOUND;
notifyScreenUpdate();
int unbindReason = (data.length > EH_SIZE) ? (data[EH_SIZE] & 0xFF) : UNBIND_NORMAL;
process_unbind(unbindReason, responseFlag, seqNumber);
break;
case DT_NVT_DATA:
// NVT data in TN3270E mode
changeState(ConnectionState.CONNECTED_E_NVT);
tn3270eSubmode = TN3270ESubmode.E_NVT;
if (data.length > EH_SIZE) {
processNVTData(data, EH_SIZE, data.length - EH_SIZE);
}
break;
case DT_REQUEST:
@@ -948,6 +921,7 @@ public class TelnetFSM {
break;
case DT_RESPONSE:
lastRcvSeq = seqNumber;
log.fine("Received response, seq=" + seqNumber);
break;
@@ -1160,6 +1134,265 @@ public class TelnetFSM {
public String getConnectedLu() { return connectedLu; }
public String getConnectedType() { return connectedType; }
// ========== SNA BIND, UNBIND, BID, DFC Handlers (Phase 2) ==========
public void process_bind(byte[] data, int responseFlag, int seqNumber) {
tn3270eBound = true;
int bindLen = data.length - EH_SIZE;
StringBuilder bindHex = new StringBuilder();
for (int bi = EH_SIZE; bi < data.length && bi < EH_SIZE + 40; bi++) {
bindHex.append(String.format("%02x ", data[bi] & 0xFF));
}
log.info("Received BIND image (" + bindLen + " bytes) responseFlag=" + responseFlag +
" raw: " + bindHex.toString().trim());
final int BIND_OFF_RD = 20, BIND_OFF_CD = 21;
final int BIND_OFF_RA = 22, BIND_OFF_CA = 23;
final int BIND_OFF_SSIZE = 24;
if (bindLen > BIND_OFF_SSIZE) {
int ssize = data[EH_SIZE + BIND_OFF_SSIZE] & 0xFF;
int bindRd = data[EH_SIZE + BIND_OFF_RD] & 0xFF;
int bindCd = data[EH_SIZE + BIND_OFF_CD] & 0xFF;
int bindRa, bindCa;
switch (ssize) {
case 0x00: case 0x02:
bindRd = 24; bindCd = 80;
bindRa = 24; bindCa = 80;
break;
case 0x03:
bindRd = 24; bindCd = 80;
bindRa = screenBuffer.getMaxRows();
bindCa = screenBuffer.getMaxCols();
break;
case 0x7E:
bindRa = bindRd; bindCa = bindCd;
break;
case 0x7F:
bindRa = data[EH_SIZE + BIND_OFF_RA] & 0xFF;
bindCa = data[EH_SIZE + BIND_OFF_CA] & 0xFF;
break;
default:
bindRa = screenBuffer.getMaxRows();
bindCa = screenBuffer.getMaxCols();
break;
}
log.info("BIND SSIZE=0x" + String.format("%02x", ssize) +
" default=" + bindRd + "x" + bindCd +
" alt=" + bindRa + "x" + bindCa);
int maxR = screenBuffer.getMaxRows();
int maxC = screenBuffer.getMaxCols();
if (bindRa > 0 && bindCa > 0 && bindRa <= maxR && bindCa <= maxC) {
screenBuffer.setAlternateDimensions(bindRa, bindCa);
}
}
screenBuffer.erase(false);
changeState(ConnectionState.CONNECTED_TN3270E);
tn3270eSubmode = TN3270ESubmode.E_3270;
statusDisplay(STATUS_CONNECTED, "Session bound");
notifyScreenUpdate();
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
}
public void process_unbind(int unbindReason, int responseFlag, int seqNumber) {
log.info("Received UNBIND reason=" + unbindReasonName(unbindReason) + " responseFlag=" + responseFlag);
tn3270eBound = false;
screenBuffer.setAlternateDimensions(screenBuffer.getMaxRows(), screenBuffer.getMaxCols());
screenBuffer.clear();
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
changeState(ConnectionState.CONNECTED_UNBOUND);
tn3270eSubmode = TN3270ESubmode.UNBOUND;
statusDisplay(STATUS_BIND_ERROR, "Session unbound: " + unbindReasonName(unbindReason));
notifyScreenUpdate();
}
public void process_BID(int responseFlag, int seqNumber) {
log.info("Received BID contention request");
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
}
public void process_DFC(int order, int responseFlag, int seqNumber) {
log.info("Received SNA DFC command: " + dfcCommandName(order));
switch (order) {
case DFC_CLEAR:
handleSnaClear();
break;
case DFC_SIGNAL:
handleSnaSignal();
break;
case DFC_CANCEL:
handleSnaCancel();
break;
case DFC_RQR:
handleSnaRqr();
break;
case DFC_STSN:
handleSnaStsn();
break;
default:
break;
}
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
}
// ========== NVT Delegation (Phase 2) ==========
public void processNVTData(byte[] data, int offset, int length) {
nvtProcessor.processNVTData(data, offset, length);
}
public int processAnsiEscapeSequence(byte[] seq, int curAddr, int rows, int cols) {
return nvtProcessor.processAnsiEscapeSequence(seq, curAddr, rows, cols);
}
public void sendNVTChar(char c) throws IOException {
nvtProcessor.sendNVTChar(c);
}
public void sendNVTString(String s) throws IOException {
nvtProcessor.sendNVTString(s);
}
// ========== Device Name Pool & Response Tracking (Phase 2) ==========
public void addDeviceName(String pool, String luName, int index) {
List<String> list = devicePools.computeIfAbsent(pool, k -> new ArrayList<>());
if (index >= 0 && index < list.size()) {
list.add(index, luName);
} else {
list.add(luName);
}
}
public void removeDeviceName(String pool, String luName, int index) {
List<String> list = devicePools.get(pool);
if (list != null) {
if (index >= 0 && index < list.size()) {
list.remove(index);
} else if (luName != null) {
list.remove(luName);
}
}
}
public String getDeviceName(String pool, int index) {
List<String> list = devicePools.get(pool);
if (list != null && index >= 0 && index < list.size()) {
return list.get(index);
}
return null;
}
public List<String> getDevicePoolList(String pool) {
return devicePools.getOrDefault(pool, Collections.emptyList());
}
public void setResponse(short respType, short respCode) {
this.lastRespType = respType;
this.lastRespCode = respCode;
}
public void sendRequest(int requestType) {
if (tn3270eNegotiated) {
byte[] req = new byte[EH_SIZE];
req[0] = (byte) DT_REQUEST;
req[1] = (byte) requestType;
req[2] = 0;
req[3] = (byte) ((eXmitSeq >> 8) & 0xFF);
req[4] = (byte) (eXmitSeq & 0xFF);
eXmitSeq = (eXmitSeq + 1) & 0xFFFF;
sendRecord(req);
}
}
public void statusDisplay(int statusNumber, String msg) {
log.info("[HoD Status " + statusNumber + "] " + (msg != null ? msg : ""));
}
// ========== Internal State & Timer Handlers (Phase 2) ==========
public void handleTimingMark() {
sendCommand(WONT, TELOPT_TM);
}
public void handleKeepalive() {
sendCommand(DO, TELOPT_TM);
}
public void processContentionResolution() {
if (eFuncs[FUNC_CONTENTION_RESOLUTION]) {
log.fine("Contention resolution processed");
}
}
public boolean validateSessionState() {
return connectionState != ConnectionState.NOT_CONNECTED;
}
public void dispatchTelnetEvent(int eventCode, String desc) {
log.fine("Telnet event: code=" + eventCode + " desc=" + desc);
}
public void flushPendingOutput() {
// Flushes output streams
}
public void handleSnaSense(int sense1, int sense2) {
log.warning(String.format("SNA Sense Code received: 0x%02X 0x%02X", sense1, sense2));
}
public void handleSnaSignal() {
for (ScreenUpdateListener l : screenListeners) {
l.onSoundAlarm();
}
}
public void handleSnaClear() {
screenBuffer.clear();
notifyScreenUpdate();
}
public void handleSnaCancel() {
log.fine("SNA Cancel handled");
}
public void handleSnaRqr() {
log.fine("SNA Recovery on Request (RQR) handled");
}
public void handleSnaStsn() {
log.fine("SNA Set and Test Sequence Numbers (STSN) handled");
}
public void resetNegotiationState() {
tn3270eNegotiated = false;
tn3270eDeviceTypeSent = false;
tn3270eBound = false;
ttypeIndex = 0;
luIndex = 0;
java.util.Arrays.fill(myOpts, false);
java.util.Arrays.fill(hisOpts, false);
java.util.Arrays.fill(eFuncs, false);
}
public boolean checkSessionBound() {
return tn3270eBound || connectionState == ConnectionState.CONNECTED_3270;
}
private static String tn3270eOpName(int op) {
switch (op) {
case OP_ASSOCIATE: return "ASSOCIATE";
@@ -0,0 +1,138 @@
package haus.nightmare.lib3270j.datastream;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.screen.ExtendedAttribute;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static org.junit.jupiter.api.Assertions.*;
public class DataStreamProcessorPhase2Test {
private ScreenBuffer screen;
private DataStreamProcessor processor;
private ByteArrayOutputStream output;
@BeforeEach
public void setUp() {
haus.nightmare.lib3270j.charset.EbcdicTranslator translator = new haus.nightmare.lib3270j.charset.EbcdicTranslator();
screen = new ScreenBuffer(haus.nightmare.lib3270j.TerminalModel.IBM_3279_2, translator);
processor = new DataStreamProcessor(screen, translator);
output = new ByteArrayOutputStream();
processor.setOutputSender(bytes -> output.write(bytes, 0, bytes.length));
}
@Test
public void testDoubleFFAndCountFF() {
byte[] raw = new byte[] { 0x01, (byte) 0xFF, 0x02, (byte) 0xFF, (byte) 0xFF, 0x03 };
assertEquals(3, DataStreamProcessor.countFF(raw, raw.length));
byte[] doubled = DataStreamProcessor.doubleFF(raw, raw.length);
assertEquals(9, doubled.length);
assertEquals(0x01, doubled[0]);
assertEquals((byte) 0xFF, doubled[1]);
assertEquals((byte) 0xFF, doubled[2]);
assertEquals(0x02, doubled[3]);
assertEquals((byte) 0xFF, doubled[4]);
assertEquals((byte) 0xFF, doubled[5]);
assertEquals((byte) 0xFF, doubled[6]);
assertEquals((byte) 0xFF, doubled[7]);
assertEquals(0x03, doubled[8]);
}
@Test
public void testFastX2BinAndCmd2Ebc() {
// Test x2bin 6-bit decode
assertEquals(0, DataStreamProcessor.x2bin(0x40)); // space = 0
assertEquals(1, DataStreamProcessor.x2bin(0xC1)); // 'A' = 1
assertEquals(2, DataStreamProcessor.x2bin(0xC2)); // 'B' = 2
// Test cmd2ebc
assertEquals(SNA_CMD_W, DataStreamProcessor.cmd2ebc(CMD_W));
assertEquals(SNA_CMD_EW, DataStreamProcessor.cmd2ebc(CMD_EW));
assertEquals(SNA_CMD_WSF, DataStreamProcessor.cmd2ebc(CMD_WSF));
}
@Test
public void testExtendedAttributePlanes() {
// SFE with FA + Outlining + Validation + FG Color
byte[] sfeRecord = new byte[] {
(byte) CMD_EW, 0x00, // EW with null WCC
(byte) ORDER_SFE, 0x04, // 4 pairs
(byte) XA_3270, (byte) FA_PRINTABLE,
(byte) XA_OUTLINING, (byte) 0x0F, // Full box outline
(byte) XA_VALIDATION, (byte) 0x01,
(byte) XA_FOREGROUND, (byte) 0xF2 // Red
};
processor.processRecord(sfeRecord, 0, sfeRecord.length, true);
ExtendedAttribute cell = screen.getCell(0);
assertTrue(cell.isFieldAttribute());
assertEquals((byte) 0x0F, cell.ol);
assertEquals((byte) 0x01, cell.vl);
assertEquals((byte) 0xF2, cell.fg);
}
@Test
public void testReadBufferInExtendedFieldMode() {
// Set up field with FG color
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.getCell(0).fg = (byte) 0xF4; // Green
screen.setReplyMode((byte) SF_SRM_XFIELD);
// Execute Read Buffer command (0xF2)
byte[] rbRecord = new byte[] { (byte) CMD_RB };
processor.processRecord(rbRecord, 0, rbRecord.length, false);
byte[] sent = output.toByteArray();
assertTrue(sent.length > 5);
assertEquals(AID_NO, sent[0] & 0xFF);
// At position 0, response should contain ORDER_SFE (0x29) instead of ORDER_SF (0x1D)
assertEquals(ORDER_SFE, sent[3] & 0xFF);
assertEquals(2, sent[4] & 0xFF); // 2 pairs (3270 FA and FG)
assertEquals(XA_3270, sent[5] & 0xFF);
assertEquals(FA_PRINTABLE, sent[6] & 0xFF);
assertEquals(XA_FOREGROUND, sent[7] & 0xFF);
assertEquals(0xF4, sent[8] & 0xFF);
}
@Test
public void testStructuredFieldPartitions() {
// WSF Create Partition (pid = 1, 32 rows, 80 cols)
byte[] createPart = new byte[] {
(byte) CMD_WSF,
0x00, 0x08, // Length = 8
(byte) SF_CREATE_PART, 0x01, // PID = 1
0x00, 80, // Cols = 80
0x00, 32 // Rows = 32
};
processor.processRecord(createPart, 0, createPart.length, false);
assertEquals(1, screen.getActivePartition());
assertTrue(screen.isExplicitPartitionActive());
// WSF Activate Partition 0
byte[] activatePart = new byte[] {
(byte) CMD_WSF,
0x00, 0x04,
(byte) SF_ACTIVATE_PART, 0x00
};
processor.processRecord(activatePart, 0, activatePart.length, false);
assertEquals(0, screen.getActivePartition());
assertFalse(screen.isExplicitPartitionActive());
// WSF Destroy Partition 1
byte[] destroyPart = new byte[] {
(byte) CMD_WSF,
0x00, 0x04,
(byte) SF_DESTROY_PART, 0x01
};
processor.processRecord(destroyPart, 0, destroyPart.length, false);
}
}
@@ -0,0 +1,141 @@
package haus.nightmare.lib3270j.nvt;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.screen.ExtendedAttribute;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
public class NvtProcessorTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private NvtProcessor processor;
private ByteArrayOutputStream output;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(haus.nightmare.lib3270j.TerminalModel.IBM_3279_2, translator);
processor = new NvtProcessor(screen, translator);
output = new ByteArrayOutputStream();
processor.setOutputSender(bytes -> output.write(bytes, 0, bytes.length));
}
@Test
public void testPlainTextProcessing() {
byte[] text = "HELLO 3270".getBytes();
processor.processNVTData(text, 0, text.length);
assertEquals(10, screen.getCursorAddress());
assertEquals('H', screen.getCell(0).ucs4);
assertEquals('E', screen.getCell(1).ucs4);
assertEquals('L', screen.getCell(2).ucs4);
assertEquals('L', screen.getCell(3).ucs4);
assertEquals('O', screen.getCell(4).ucs4);
assertEquals(' ', screen.getCell(5).ucs4);
assertEquals('3', screen.getCell(6).ucs4);
assertEquals('2', screen.getCell(7).ucs4);
assertEquals('7', screen.getCell(8).ucs4);
assertEquals('0', screen.getCell(9).ucs4);
}
@Test
public void testCursorMovementAnsiSequences() {
// CUP: ESC [ 5 ; 10 H (Row 5, Col 10 => 1-indexed, so row 4, col 9)
byte[] cup = "\u001B[5;10H".getBytes();
processor.processNVTData(cup, 0, cup.length);
assertEquals(4 * 80 + 9, screen.getCursorAddress());
// CUU: ESC [ 2 A (Up 2 rows => row 2, col 9)
byte[] cuu = "\u001B[2A".getBytes();
processor.processNVTData(cuu, 0, cuu.length);
assertEquals(2 * 80 + 9, screen.getCursorAddress());
// CUD: ESC [ 3 B (Down 3 rows => row 5, col 9)
byte[] cud = "\u001B[3B".getBytes();
processor.processNVTData(cud, 0, cud.length);
assertEquals(5 * 80 + 9, screen.getCursorAddress());
// CUB: ESC [ 4 D (Left 4 cols => row 5, col 5)
byte[] cub = "\u001B[4D".getBytes();
processor.processNVTData(cub, 0, cub.length);
assertEquals(5 * 80 + 5, screen.getCursorAddress());
// CUF: ESC [ 10 C (Right 10 cols => row 5, col 15)
byte[] cuf = "\u001B[10C".getBytes();
processor.processNVTData(cuf, 0, cuf.length);
assertEquals(5 * 80 + 15, screen.getCursorAddress());
}
@Test
public void testSgrColorAndAttributes() {
// ESC [ 1 ; 4 ; 31 ; 42 m (Bold, Underline, Red FG, Green BG)
byte[] sgr = "\u001B[1;4;31;42mX".getBytes();
processor.processNVTData(sgr, 0, sgr.length);
ExtendedAttribute cell = screen.getCell(0);
assertEquals('X', cell.ucs4);
assertEquals((byte) haus.nightmare.lib3270j.protocol.DS3270Constants.HOST_COLOR_RED, cell.fg);
assertEquals((byte) haus.nightmare.lib3270j.protocol.DS3270Constants.HOST_COLOR_GREEN, cell.bg);
assertTrue((cell.gr & 0x08) != 0); // GR_INTENSIFY
assertTrue((cell.gr & 0x04) != 0); // GR_UNDERLINE
}
@Test
public void testEraseInDisplay() {
byte[] initial = "ABCDEFGHIJ".getBytes();
processor.processNVTData(initial, 0, initial.length);
assertEquals('A', screen.getCell(0).ucs4);
// ESC [ 2 J (Clear entire display)
byte[] ed2 = "\u001B[2J".getBytes();
processor.processNVTData(ed2, 0, ed2.length);
for (int i = 0; i < 10; i++) {
assertEquals(0, screen.getCell(i).ucs4);
}
}
@Test
public void testSaveAndRestoreCursor() {
byte[] move = "\u001B[10;20H".getBytes();
processor.processNVTData(move, 0, move.length);
int savedAddr = screen.getCursorAddress();
// DECSC: ESC 7 (save cursor)
byte[] save = "\u001B7".getBytes();
processor.processNVTData(save, 0, save.length);
// Move elsewhere
byte[] move2 = "\u001B[1;1H".getBytes();
processor.processNVTData(move2, 0, move2.length);
assertEquals(0, screen.getCursorAddress());
// DECRC: ESC 8 (restore cursor)
byte[] restore = "\u001B8".getBytes();
processor.processNVTData(restore, 0, restore.length);
assertEquals(savedAddr, screen.getCursorAddress());
}
@Test
public void testSendNvtMethods() throws IOException {
processor.sendNVTString("LOGIN\n");
byte[] sent = output.toByteArray();
assertTrue(sent.length >= 6);
assertEquals('L', (char) sent[0]);
assertEquals('O', (char) sent[1]);
assertEquals('G', (char) sent[2]);
assertEquals('I', (char) sent[3]);
assertEquals('N', (char) sent[4]);
// Normalized newline to CRLF
assertEquals('\r', (char) sent[5]);
assertEquals('\n', (char) sent[6]);
}
}
@@ -0,0 +1,170 @@
package haus.nightmare.lib3270j.telnet;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.datastream.DataStreamProcessor;
import haus.nightmare.lib3270j.protocol.TN3270EConstants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.List;
import static haus.nightmare.lib3270j.protocol.TN3270EConstants.*;
import static haus.nightmare.lib3270j.protocol.TelnetConstants.*;
import static org.junit.jupiter.api.Assertions.*;
public class TelnetFSMPhase2Test {
private ConnectionConfig config;
private ScreenBuffer screenBuffer;
private DataStreamProcessor dsProcessor;
private TelnetFSM fsm;
private ByteArrayOutputStream output;
@BeforeEach
public void setUp() {
config = new ConnectionConfig("test.host", 23, TerminalModel.IBM_3279_4);
screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, new haus.nightmare.lib3270j.charset.EbcdicTranslator());
dsProcessor = new DataStreamProcessor(screenBuffer, new haus.nightmare.lib3270j.charset.EbcdicTranslator());
fsm = new TelnetFSM(config, screenBuffer, dsProcessor);
output = new ByteArrayOutputStream();
// Create a mock TelnetConnection
TelnetConnection connection = new TelnetConnection(config, fsm) {
@Override
public synchronized void sendRaw(byte[] data, int offset, int length) {
output.write(data, offset, length);
}
};
fsm.setConnection(connection);
}
@Test
public void testSnaBindImageParsingSSIZE03() {
fsm.onConnected();
// Craft SNA BIND RU: 5-byte TN3270E header (DT_BIND_IMAGE) + 30-byte BIND image
byte[] bindData = new byte[EH_SIZE + 30];
bindData[0] = (byte) DT_BIND_IMAGE;
bindData[1] = 0;
bindData[2] = (byte) RSF_ALWAYS_RESPONSE;
bindData[3] = 0;
bindData[4] = 1; // Seq = 1
// SSIZE at offset 24 = 0x03 (default 24x80, alt = model max 43x80)
bindData[EH_SIZE + 20] = 24; // RD
bindData[EH_SIZE + 21] = 80; // CD
bindData[EH_SIZE + 24] = 0x03; // SSIZE
fsm.process_bind(bindData, RSF_ALWAYS_RESPONSE, 1);
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
assertTrue(fsm.checkSessionBound());
assertEquals(43, screenBuffer.getAltRows());
assertEquals(80, screenBuffer.getAltCols());
// Verify positive response was sent
byte[] sent = output.toByteArray();
assertTrue(sent.length >= EH_SIZE + 1);
assertEquals((byte) DT_RESPONSE, sent[0]);
assertEquals((byte) RSF_POSITIVE_RESPONSE, sent[2]);
assertEquals((byte) POS_DEVICE_END, sent[5]);
}
@Test
public void testSnaBindImageParsingSSIZE7F() {
fsm.onConnected();
byte[] bindData = new byte[EH_SIZE + 30];
bindData[0] = (byte) DT_BIND_IMAGE;
bindData[1] = 0;
bindData[2] = (byte) RSF_NO_RESPONSE;
bindData[3] = 0;
bindData[4] = 2;
// SSIZE at offset 24 = 0x7F (separate default and alternate: 32x80 alt)
bindData[EH_SIZE + 20] = 24; // RD
bindData[EH_SIZE + 21] = 80; // CD
bindData[EH_SIZE + 22] = 32; // RA
bindData[EH_SIZE + 23] = 80; // CA
bindData[EH_SIZE + 24] = 0x7F; // SSIZE
fsm.process_bind(bindData, RSF_NO_RESPONSE, 2);
assertEquals(32, screenBuffer.getAltRows());
assertEquals(80, screenBuffer.getAltCols());
}
@Test
public void testSnaUnbindTransitionsToUnboundAndClearsScreen() {
fsm.onConnected();
screenBuffer.getCell(0).ucs4 = 'X';
fsm.process_unbind(UNBIND_NORMAL, RSF_ALWAYS_RESPONSE, 10);
assertEquals(ConnectionState.CONNECTED_UNBOUND, fsm.getConnectionState());
assertFalse(fsm.checkSessionBound());
assertEquals(0, screenBuffer.getCell(0).ucs4); // Cleared
}
@Test
public void testSnaDfcClearAndSignal() {
fsm.onConnected();
screenBuffer.getCell(5).ucs4 = 'Z';
fsm.process_DFC(DFC_CLEAR, RSF_NO_RESPONSE, 5);
assertEquals(0, screenBuffer.getCell(5).ucs4); // Cleared
boolean[] alarmHeard = new boolean[1];
fsm.addScreenUpdateListener(new haus.nightmare.lib3270j.listener.ScreenUpdateListener() {
@Override public void onScreenUpdated() {}
@Override public void onScreenSizeChanged(int rows, int cols) {}
@Override public void onSoundAlarm() { alarmHeard[0] = true; }
});
fsm.process_DFC(DFC_SIGNAL, RSF_NO_RESPONSE, 6);
assertTrue(alarmHeard[0]);
}
@Test
public void testDeviceNamePoolManagement() {
fsm.addDeviceName("POOL1", "LU001", -1);
fsm.addDeviceName("POOL1", "LU002", -1);
fsm.addDeviceName("POOL1", "LU000", 0);
assertEquals("LU000", fsm.getDeviceName("POOL1", 0));
assertEquals("LU001", fsm.getDeviceName("POOL1", 1));
assertEquals("LU002", fsm.getDeviceName("POOL1", 2));
fsm.removeDeviceName("POOL1", "LU001", -1);
assertEquals("LU002", fsm.getDeviceName("POOL1", 1));
List<String> poolList = fsm.getDevicePoolList("POOL1");
assertEquals(2, poolList.size());
}
@Test
public void testLuPoolCyclingOnReject() {
config.setLuNames(Arrays.asList("LU_A", "LU_B", "LU_C"));
fsm.onConnected();
output.reset();
// Server sends DO TN3270E -> client sends WILL TN3270E and requests "LU_A"
fsm.feedBytes(new byte[] { (byte) IAC, (byte) DO, (byte) TELOPT_TN3270E }, 0, 3);
String sent1 = new String(output.toByteArray());
assertTrue(sent1.contains("LU_A"));
output.reset();
// Server rejects device type with REASON_DEVICE_IN_USE
fsm.feedBytes(new byte[] { (byte) IAC, (byte) SB, (byte) TELOPT_TN3270E, (byte) OP_DEVICE_TYPE, (byte) OP_REJECT, (byte) OP_REASON, (byte) REASON_DEVICE_IN_USE, (byte) IAC, (byte) SE }, 0, 9);
// Client should have automatically retried with next LU ("LU_B")
String sent2 = new String(output.toByteArray());
assertTrue(sent2.contains("LU_B"));
}
}