diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index e942c63..b93dd7f 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -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 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 getLuNames() { return luNames; } + public void setLuNames(java.util.List 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(); } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index a73031b..c0f8c7f 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -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 ========== diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index 6bedaf2..0f96631 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -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,14 +689,79 @@ public class DataStreamProcessor { outputWrite(caddr[0] & 0xFF); outputWrite(caddr[1] & 0xFF); - // Buffer contents - for (int i = 0; i < size; i++) { - ExtendedAttribute ea = screen.getCell(i); - if (ea.isFieldAttribute()) { - outputWrite(ORDER_SF); - outputWrite(ea.fa & 0xFF); - } else { - outputWrite(ea.ec & 0xFF); + 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()) { + outputWrite(ORDER_SF); + outputWrite(ea.fa & 0xFF); + } else { + outputWrite(ea.ec & 0xFF); + } } } @@ -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"); + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java new file mode 100644 index 0000000..1dad215 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java @@ -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 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(); + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/DS3270Constants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/DS3270Constants.java index d1c44e7..2534db9 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/DS3270Constants.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/DS3270Constants.java @@ -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 "ReadBuffer"; - case CMD_RM: case SNA_CMD_RM: return "ReadModified"; - case CMD_RMA: case SNA_CMD_RMA: return "ReadModifiedAll"; - case CMD_EAU: case SNA_CMD_EAU: return "EraseAllUnprotected"; - case CMD_WSF: case SNA_CMD_WSF: return "WriteStructuredField"; - case CMD_NOP: return "NoOp"; - default: return String.format("Unknown(0x%02x)", cmd); + case CMD_W: case SNA_CMD_W: return "Write"; + 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_EWA: case SNA_CMD_EWA: return "Erase/Write Alternate"; + case CMD_NOP: return "NOP"; + default: return "0x" + Integer.toHexString(cmd); } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TN3270EConstants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TN3270EConstants.java index 96db9e7..65683a2 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TN3270EConstants.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/protocol/TN3270EConstants.java @@ -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(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java index e9ad4c0..2975c6e 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java @@ -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; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java index cecb1e8..a51fb47 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ScreenBuffer.java @@ -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 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 ========== diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java index cf17b0d..b43f56d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java @@ -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. */ diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index ef8b83f..34fa892 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -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> devicePools = new java.util.HashMap<>(); private List getCandidateTerminalTypes() { List 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 candidates = getCandidateTerminalTypes(); + String termType = candidates.get(Math.min(ttypeIndex, candidates.size() - 1)); + String currentLu = null; + List 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); - sendTN3270EDeviceTypeRequest(); - return; - } + 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 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 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; + } + // 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 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 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 list = devicePools.get(pool); + if (list != null && index >= 0 && index < list.size()) { + return list.get(index); + } + return null; + } + + public List 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"; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorPhase2Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorPhase2Test.java new file mode 100644 index 0000000..3bcfe9e --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorPhase2Test.java @@ -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); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java new file mode 100644 index 0000000..f906150 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/nvt/NvtProcessorTest.java @@ -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]); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase2Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase2Test.java new file mode 100644 index 0000000..59d331f --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/TelnetFSMPhase2Test.java @@ -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 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")); + } +}